feat: add websocket upstream adapter
This commit is contained in:
Generated
+74
@@ -391,6 +391,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crank-adapter-websocket"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"crank-core",
|
||||||
|
"futures-util",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crank-core"
|
name = "crank-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -448,6 +462,7 @@ dependencies = [
|
|||||||
"crank-adapter-graphql",
|
"crank-adapter-graphql",
|
||||||
"crank-adapter-grpc",
|
"crank-adapter-grpc",
|
||||||
"crank-adapter-rest",
|
"crank-adapter-rest",
|
||||||
|
"crank-adapter-websocket",
|
||||||
"crank-core",
|
"crank-core",
|
||||||
"crank-mapping",
|
"crank-mapping",
|
||||||
"crank-schema",
|
"crank-schema",
|
||||||
@@ -456,6 +471,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -519,6 +535,12 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -2125,6 +2147,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -2495,6 +2528,22 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.26.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tungstenite",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@@ -2693,6 +2742,25 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.26.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"sha1",
|
||||||
|
"thiserror",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
@@ -2772,6 +2840,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ members = [
|
|||||||
"crates/crank-adapter-rest",
|
"crates/crank-adapter-rest",
|
||||||
"crates/crank-adapter-graphql",
|
"crates/crank-adapter-graphql",
|
||||||
"crates/crank-adapter-grpc",
|
"crates/crank-adapter-grpc",
|
||||||
|
"crates/crank-adapter-websocket",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
@@ -46,4 +47,5 @@ tonic-build = "0.14"
|
|||||||
tonic-prost-build = "0.14"
|
tonic-prost-build = "0.14"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||||
uuid = { version = "1", features = ["serde", "v7"] }
|
uuid = { version = "1", features = ["serde", "v7"] }
|
||||||
|
|||||||
@@ -2,25 +2,19 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/streaming-e2e`
|
### `feat/websocket-upstream-adapter`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- local fixture stack includes:
|
- bounded WebSocket window execution is supported
|
||||||
- REST SSE server
|
- WebSocket protocol is exposed through capability APIs
|
||||||
- gRPC server-streaming server
|
- session mode can seed from WebSocket window collection
|
||||||
- WebSocket event server
|
- runtime and adapter tests cover WebSocket collection and reconnects
|
||||||
- Playwright covers:
|
|
||||||
- REST window flow
|
|
||||||
- gRPC streaming flow
|
|
||||||
- session tool flow
|
|
||||||
- async job flow
|
|
||||||
- CI runs streaming e2e against the local fixture stack
|
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/websocket-upstream-adapter`
|
- `feat/soap-architecture-and-core-model`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
@@ -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::WebsocketAdapter(_) => "runtime_websocket_error",
|
||||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error",
|
||||||
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error",
|
||||||
|
|||||||
@@ -1347,7 +1347,12 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_protocol_capabilities(&self) -> Vec<ProtocolCapabilityView> {
|
pub async fn list_protocol_capabilities(&self) -> Vec<ProtocolCapabilityView> {
|
||||||
[Protocol::Rest, Protocol::Graphql, Protocol::Grpc]
|
[
|
||||||
|
Protocol::Rest,
|
||||||
|
Protocol::Graphql,
|
||||||
|
Protocol::Grpc,
|
||||||
|
Protocol::Websocket,
|
||||||
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(protocol_capability_view)
|
.map(protocol_capability_view)
|
||||||
.collect()
|
.collect()
|
||||||
@@ -3601,6 +3606,7 @@ fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), A
|
|||||||
(Protocol::Rest, Target::Rest(_))
|
(Protocol::Rest, Target::Rest(_))
|
||||||
| (Protocol::Graphql, Target::Graphql(_))
|
| (Protocol::Graphql, Target::Graphql(_))
|
||||||
| (Protocol::Grpc, Target::Grpc(_))
|
| (Protocol::Grpc, Target::Grpc(_))
|
||||||
|
| (Protocol::Websocket, Target::Websocket(_))
|
||||||
);
|
);
|
||||||
|
|
||||||
if is_match {
|
if is_match {
|
||||||
@@ -3827,6 +3833,7 @@ fn demo_grpc_operation_payload() -> OperationPayload {
|
|||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
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,
|
||||||
}),
|
}),
|
||||||
streaming: None,
|
streaming: None,
|
||||||
},
|
},
|
||||||
@@ -4007,6 +4014,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::WebsocketAdapter(_) => "websocket_error",
|
||||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||||
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
||||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||||
@@ -4034,7 +4042,7 @@ fn protocol_capability_view(protocol: Protocol) -> ProtocolCapabilityView {
|
|||||||
.filter(|behavior| protocol.supports_transport_behavior(*behavior))
|
.filter(|behavior| protocol.supports_transport_behavior(*behavior))
|
||||||
.collect();
|
.collect();
|
||||||
let supports_upload_artifacts = match protocol {
|
let supports_upload_artifacts = match protocol {
|
||||||
Protocol::Rest | Protocol::Graphql => 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()],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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::WebsocketAdapter(_) => "adapter_execution_error",
|
||||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||||
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error",
|
||||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
<option value="rest" selected>REST</option>
|
<option value="rest" selected>REST</option>
|
||||||
<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>
|
||||||
</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']" :key="p">
|
<template x-for="p in ['rest','graphql','grpc','websocket']" :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' : 'gRPC'"></span>
|
<span x-text="p === 'rest' ? 'REST' : p === 'graphql' ? 'GraphQL' : p === 'grpc' ? 'gRPC' : 'WebSocket'"></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>
|
||||||
|
|||||||
@@ -416,12 +416,14 @@ document.addEventListener('alpine:init', function() {
|
|||||||
protocolBadge(operation) {
|
protocolBadge(operation) {
|
||||||
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';
|
||||||
return 'badge badge-grpc';
|
return 'badge badge-grpc';
|
||||||
},
|
},
|
||||||
|
|
||||||
protocolLabel(operation) {
|
protocolLabel(operation) {
|
||||||
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';
|
||||||
return 'gRPC';
|
return 'gRPC';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ document.addEventListener('alpine:init', function() {
|
|||||||
return operation.method ? ('REST · ' + operation.method) : 'REST';
|
return operation.method ? ('REST · ' + operation.method) : 'REST';
|
||||||
}
|
}
|
||||||
if (operation.protocol === 'graphql') return 'GraphQL';
|
if (operation.protocol === 'graphql') return 'GraphQL';
|
||||||
|
if (operation.protocol === 'websocket') return 'WebSocket';
|
||||||
return 'gRPC';
|
return 'gRPC';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ var TRANSLATIONS = {
|
|||||||
'workspace_setup.custom_protocol.rest': 'REST',
|
'workspace_setup.custom_protocol.rest': 'REST',
|
||||||
'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',
|
||||||
|
|
||||||
// Wizard
|
// Wizard
|
||||||
'wizard.back_catalog': 'Operations',
|
'wizard.back_catalog': 'Operations',
|
||||||
@@ -444,6 +445,8 @@ var TRANSLATIONS = {
|
|||||||
'wizard.step1.graphql_tagline': 'Query and mutate a GraphQL schema. Supports queries and mutations over one fixed operation.',
|
'wizard.step1.graphql_tagline': 'Query and mutate a GraphQL schema. Supports queries and mutations over one fixed operation.',
|
||||||
'wizard.step1.grpc_name': 'gRPC',
|
'wizard.step1.grpc_name': 'gRPC',
|
||||||
'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_tagline': 'Stateful event streams and push-oriented integrations normalized into bounded MCP streaming tools.',
|
||||||
'wizard.step1.query': 'Query',
|
'wizard.step1.query': 'Query',
|
||||||
'wizard.step1.mutation': 'Mutation',
|
'wizard.step1.mutation': 'Mutation',
|
||||||
'wizard.step1.unary': 'Unary',
|
'wizard.step1.unary': 'Unary',
|
||||||
@@ -1192,6 +1195,7 @@ var TRANSLATIONS = {
|
|||||||
'workspace_setup.custom_protocol.rest': 'REST',
|
'workspace_setup.custom_protocol.rest': 'REST',
|
||||||
'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',
|
||||||
|
|
||||||
// Wizard
|
// Wizard
|
||||||
'wizard.back_catalog': 'Операции',
|
'wizard.back_catalog': 'Операции',
|
||||||
@@ -1226,6 +1230,8 @@ var TRANSLATIONS = {
|
|||||||
'wizard.step1.graphql_tagline': 'Запросы и мутации к GraphQL-схеме. Поддерживаются query и mutation для одной фиксированной операции.',
|
'wizard.step1.graphql_tagline': 'Запросы и мутации к GraphQL-схеме. Поддерживаются query и mutation для одной фиксированной операции.',
|
||||||
'wizard.step1.grpc_name': 'gRPC',
|
'wizard.step1.grpc_name': 'gRPC',
|
||||||
'wizard.step1.grpc_tagline': 'Высокопроизводительный бинарный RPC поверх HTTP/2. Подходит для низколатентных внутренних сервисов.',
|
'wizard.step1.grpc_tagline': 'Высокопроизводительный бинарный RPC поверх HTTP/2. Подходит для низколатентных внутренних сервисов.',
|
||||||
|
'wizard.step1.websocket_name': 'WebSocket',
|
||||||
|
'wizard.step1.websocket_tagline': 'Состояние соединения, event streams и push-oriented интеграции, нормализованные в bounded MCP streaming tools.',
|
||||||
'wizard.step1.query': 'Запрос',
|
'wizard.step1.query': 'Запрос',
|
||||||
'wizard.step1.mutation': 'Мутация',
|
'wizard.step1.mutation': 'Мутация',
|
||||||
'wizard.step1.unary': 'Unary',
|
'wizard.step1.unary': 'Unary',
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (protocol === 'grpc' || protocol === 'Grpc') {
|
if (protocol === 'grpc' || protocol === 'Grpc') {
|
||||||
return 'var(--accent)';
|
return 'var(--accent)';
|
||||||
}
|
}
|
||||||
|
if (protocol === 'websocket' || protocol === 'Websocket') {
|
||||||
|
return 'var(--teal)';
|
||||||
|
}
|
||||||
return 'var(--blue)';
|
return 'var(--blue)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +71,9 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (protocol === 'grpc' || protocol === 'Grpc') {
|
if (protocol === 'grpc' || protocol === 'Grpc') {
|
||||||
return 'gRPC';
|
return 'gRPC';
|
||||||
}
|
}
|
||||||
|
if (protocol === 'websocket' || protocol === 'Websocket') {
|
||||||
|
return 'WebSocket';
|
||||||
|
}
|
||||||
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'
|
var wizardProtocol = 'rest'; // 'rest' | 'graphql' | 'grpc' | 'websocket'
|
||||||
var wizardMode = 'create'; // 'create' | 'edit'
|
var wizardMode = 'create'; // 'create' | 'edit'
|
||||||
var wizardEditId = null;
|
var wizardEditId = null;
|
||||||
var wizardWorkspaceId = null;
|
var wizardWorkspaceId = null;
|
||||||
@@ -32,6 +32,10 @@ function defaultProtocolCapabilities() {
|
|||||||
supports_execution_modes: ['unary', 'window', 'session', 'async_job'],
|
supports_execution_modes: ['unary', 'window', 'session', 'async_job'],
|
||||||
supports_transport_behaviors: ['request_response', 'server_stream'],
|
supports_transport_behaviors: ['request_response', 'server_stream'],
|
||||||
},
|
},
|
||||||
|
websocket: {
|
||||||
|
supports_execution_modes: ['window', 'session', 'async_job'],
|
||||||
|
supports_transport_behaviors: ['server_stream', 'stateful_session', 'deferred_result'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "crank-adapter-websocket"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
crank-core = { path = "../crank-core" }
|
||||||
|
futures-util = "0.3"
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["net", "time"] }
|
||||||
|
tokio-tungstenite.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
use std::{collections::BTreeMap, time::Duration};
|
||||||
|
|
||||||
|
use crank_core::WebsocketTarget;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::time::{Instant, sleep};
|
||||||
|
use tokio_tungstenite::{
|
||||||
|
MaybeTlsStream, WebSocketStream, connect_async,
|
||||||
|
tungstenite::{
|
||||||
|
Error as TungsteniteError, Message,
|
||||||
|
client::IntoClientRequest,
|
||||||
|
protocol::{CloseFrame, frame::coding::CloseCode},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
HeartbeatPolicy, ReconnectPolicy, WebsocketAdapterError, WebsocketWindowRequest,
|
||||||
|
WebsocketWindowResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WebsocketAdapter;
|
||||||
|
|
||||||
|
impl Default for WebsocketAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebsocketAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_window(
|
||||||
|
&self,
|
||||||
|
target: &WebsocketTarget,
|
||||||
|
request: &WebsocketWindowRequest,
|
||||||
|
) -> Result<WebsocketWindowResponse, WebsocketAdapterError> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
let deadline = started_at + Duration::from_millis(request.window_duration_ms);
|
||||||
|
let heartbeat = request
|
||||||
|
.heartbeat_interval_ms
|
||||||
|
.map(Duration::from_millis)
|
||||||
|
.map(|interval| crate::HeartbeatPolicy { interval });
|
||||||
|
let reconnect = ReconnectPolicy {
|
||||||
|
max_attempts: request.reconnect_max_attempts,
|
||||||
|
backoff: Duration::from_millis(request.reconnect_backoff_ms),
|
||||||
|
};
|
||||||
|
let mut attempts = 0_u32;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut connected_headers = BTreeMap::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (mut stream, headers) = connect_websocket(target, request).await?;
|
||||||
|
if connected_headers.is_empty() {
|
||||||
|
connected_headers = headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
send_subscribe_message(&mut stream, target).await?;
|
||||||
|
|
||||||
|
let completed = collect_window(
|
||||||
|
&mut stream,
|
||||||
|
request.max_items,
|
||||||
|
deadline,
|
||||||
|
heartbeat.as_ref(),
|
||||||
|
&mut items,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
send_unsubscribe_message(&mut stream, target).await?;
|
||||||
|
return Ok(WebsocketWindowResponse {
|
||||||
|
status_code: 101,
|
||||||
|
headers: connected_headers,
|
||||||
|
body: serde_json::json!({
|
||||||
|
"items": items,
|
||||||
|
"done": true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempts >= reconnect.max_attempts {
|
||||||
|
return Err(WebsocketAdapterError::ReconnectExhausted);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts = attempts.saturating_add(1);
|
||||||
|
reconnect_if_needed(&reconnect, attempts).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
|
||||||
|
|
||||||
|
pub async fn connect_websocket(
|
||||||
|
target: &WebsocketTarget,
|
||||||
|
request: &WebsocketWindowRequest,
|
||||||
|
) -> Result<(WsStream, BTreeMap<String, String>), WebsocketAdapterError> {
|
||||||
|
let mut client_request = target.url.as_str().into_client_request().map_err(|_| {
|
||||||
|
WebsocketAdapterError::InvalidUrl {
|
||||||
|
url: target.url.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let headers = build_headers(target, request)?;
|
||||||
|
for (name, value) in &headers {
|
||||||
|
client_request
|
||||||
|
.headers_mut()
|
||||||
|
.insert(name.clone(), value.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !target.subprotocols.is_empty() {
|
||||||
|
let value = target.subprotocols.join(", ");
|
||||||
|
let header_value = HeaderValue::from_str(&value)
|
||||||
|
.map_err(|_| WebsocketAdapterError::InvalidSubprotocol { value })?;
|
||||||
|
client_request
|
||||||
|
.headers_mut()
|
||||||
|
.insert("Sec-WebSocket-Protocol", header_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (stream, response) = connect_async(client_request).await?;
|
||||||
|
let response_headers = response
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(name, value)| {
|
||||||
|
value
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.map(|value| (name.as_str().to_owned(), value.to_owned()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((stream, response_headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_subscribe_message(
|
||||||
|
stream: &mut WsStream,
|
||||||
|
target: &WebsocketTarget,
|
||||||
|
) -> Result<(), WebsocketAdapterError> {
|
||||||
|
let Some(payload) = target.subscribe_message_template.as_ref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = serde_json::to_string(payload)
|
||||||
|
.map_err(|_| WebsocketAdapterError::InvalidSubscribePayload)?;
|
||||||
|
stream.send(Message::Text(message.into())).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_unsubscribe_message(
|
||||||
|
stream: &mut WsStream,
|
||||||
|
target: &WebsocketTarget,
|
||||||
|
) -> Result<(), WebsocketAdapterError> {
|
||||||
|
let Some(payload) = target.unsubscribe_message_template.as_ref() else {
|
||||||
|
let _ = stream
|
||||||
|
.close(Some(CloseFrame {
|
||||||
|
code: CloseCode::Normal,
|
||||||
|
reason: "completed".into(),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = serde_json::to_string(payload)
|
||||||
|
.map_err(|_| WebsocketAdapterError::InvalidUnsubscribePayload)?;
|
||||||
|
if let Err(error) = stream.send(Message::Text(message.into())).await {
|
||||||
|
if !matches!(
|
||||||
|
error,
|
||||||
|
TungsteniteError::ConnectionClosed
|
||||||
|
| TungsteniteError::AlreadyClosed
|
||||||
|
| TungsteniteError::Protocol(
|
||||||
|
tokio_tungstenite::tungstenite::error::ProtocolError::SendAfterClosing
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = stream
|
||||||
|
.close(Some(CloseFrame {
|
||||||
|
code: CloseCode::Normal,
|
||||||
|
reason: "completed".into(),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_window(
|
||||||
|
stream: &mut WsStream,
|
||||||
|
max_items: Option<u32>,
|
||||||
|
deadline: Instant,
|
||||||
|
heartbeat: Option<&HeartbeatPolicy>,
|
||||||
|
items: &mut Vec<Value>,
|
||||||
|
) -> Result<bool, WebsocketAdapterError> {
|
||||||
|
let mut heartbeat_deadline = heartbeat.map(|policy| Instant::now() + policy.interval);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Ok(!items.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let next_tick = heartbeat_deadline.unwrap_or(deadline);
|
||||||
|
let sleep_until = std::cmp::min(next_tick, deadline);
|
||||||
|
let wait = sleep_until.saturating_duration_since(now);
|
||||||
|
let timer = sleep(wait);
|
||||||
|
tokio::pin!(timer);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut timer => {
|
||||||
|
if heartbeat_deadline.is_some_and(|value| value <= Instant::now()) {
|
||||||
|
heartbeat_tick(stream).await?;
|
||||||
|
heartbeat_deadline = heartbeat.map(|policy| Instant::now() + policy.interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(!items.is_empty());
|
||||||
|
}
|
||||||
|
frame = read_next_frame(stream) => {
|
||||||
|
match frame? {
|
||||||
|
Some(value) => {
|
||||||
|
items.push(value);
|
||||||
|
if max_items.is_some_and(|limit| items.len() as u32 >= limit) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => return Ok(!items.is_empty()),
|
||||||
|
}
|
||||||
|
heartbeat_deadline = heartbeat.map(|policy| Instant::now() + policy.interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_next_frame(
|
||||||
|
stream: &mut WsStream,
|
||||||
|
) -> Result<Option<Value>, WebsocketAdapterError> {
|
||||||
|
loop {
|
||||||
|
let Some(frame) = stream.next().await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
match frame? {
|
||||||
|
Message::Text(text) => return Ok(Some(decode_text_frame(text.as_ref())?)),
|
||||||
|
Message::Binary(_) => return Err(WebsocketAdapterError::InvalidFramePayload),
|
||||||
|
Message::Ping(payload) => {
|
||||||
|
stream.send(Message::Pong(payload)).await?;
|
||||||
|
}
|
||||||
|
Message::Pong(_) => {}
|
||||||
|
Message::Frame(_) => {}
|
||||||
|
Message::Close(_) => return Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_text_frame(text: &str) -> Result<Value, WebsocketAdapterError> {
|
||||||
|
serde_json::from_str(text).or_else(|_| Ok(Value::String(text.to_owned())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn heartbeat_tick(stream: &mut WsStream) -> Result<(), WebsocketAdapterError> {
|
||||||
|
stream.send(Message::Ping(Vec::new().into())).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reconnect_if_needed(policy: &ReconnectPolicy, attempts: u32) {
|
||||||
|
if attempts == 0 || policy.backoff.is_zero() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(policy.backoff).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_headers(
|
||||||
|
target: &WebsocketTarget,
|
||||||
|
request: &WebsocketWindowRequest,
|
||||||
|
) -> Result<HeaderMap, WebsocketAdapterError> {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
|
||||||
|
for (name, value) in target.static_headers.iter().chain(request.headers.iter()) {
|
||||||
|
let header_name = HeaderName::try_from(name.as_str()).map_err(|_| {
|
||||||
|
WebsocketAdapterError::InvalidHeaderName {
|
||||||
|
header: name.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let header_value = HeaderValue::try_from(value.as_str()).map_err(|_| {
|
||||||
|
WebsocketAdapterError::InvalidHeaderValue {
|
||||||
|
header: name.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{collections::BTreeMap, sync::Arc};
|
||||||
|
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::{net::TcpListener, sync::Mutex};
|
||||||
|
use tokio_tungstenite::{
|
||||||
|
accept_hdr_async,
|
||||||
|
tungstenite::handshake::server::{Request, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{WebsocketAdapter, WebsocketWindowRequest};
|
||||||
|
use crank_core::WebsocketTarget;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collects_window_messages_and_sends_subscribe_payload() {
|
||||||
|
let received = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let target_url = spawn_server(received.clone(), false).await;
|
||||||
|
let adapter = WebsocketAdapter::new();
|
||||||
|
let target = WebsocketTarget {
|
||||||
|
url: target_url,
|
||||||
|
subprotocols: vec!["events.v1".to_owned()],
|
||||||
|
subscribe_message_template: Some(json!({"type":"subscribe","topic":"metrics"})),
|
||||||
|
unsubscribe_message_template: Some(json!({"type":"unsubscribe"})),
|
||||||
|
static_headers: BTreeMap::from([("x-test-env".to_owned(), "ci".to_owned())]),
|
||||||
|
};
|
||||||
|
let response = adapter
|
||||||
|
.execute_window(
|
||||||
|
&target,
|
||||||
|
&WebsocketWindowRequest {
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
window_duration_ms: 1_000,
|
||||||
|
max_items: Some(3),
|
||||||
|
heartbeat_interval_ms: None,
|
||||||
|
reconnect_max_attempts: 0,
|
||||||
|
reconnect_backoff_ms: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status_code, 101);
|
||||||
|
assert_eq!(response.body["items"].as_array().unwrap().len(), 3);
|
||||||
|
assert_eq!(response.body["items"][0]["seq"], 1);
|
||||||
|
|
||||||
|
let received = received.lock().await.clone();
|
||||||
|
assert!(
|
||||||
|
received
|
||||||
|
.iter()
|
||||||
|
.any(|value| value == &json!({"type":"subscribe","topic":"metrics"}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconnects_when_socket_closes_early() {
|
||||||
|
let received = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let target_url = spawn_server(received, true).await;
|
||||||
|
let adapter = WebsocketAdapter::new();
|
||||||
|
let target = WebsocketTarget {
|
||||||
|
url: target_url,
|
||||||
|
subprotocols: Vec::new(),
|
||||||
|
subscribe_message_template: None,
|
||||||
|
unsubscribe_message_template: None,
|
||||||
|
static_headers: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let response = adapter
|
||||||
|
.execute_window(
|
||||||
|
&target,
|
||||||
|
&WebsocketWindowRequest {
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
window_duration_ms: 1_000,
|
||||||
|
max_items: Some(3),
|
||||||
|
heartbeat_interval_ms: None,
|
||||||
|
reconnect_max_attempts: 2,
|
||||||
|
reconnect_backoff_ms: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.body["items"].as_array().unwrap().len(), 3);
|
||||||
|
assert_eq!(response.body["items"][2]["seq"], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_server(received: Arc<Mutex<Vec<Value>>>, close_early: bool) -> String {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut accepted = 0_u32;
|
||||||
|
loop {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let received = received.clone();
|
||||||
|
accepted = accepted.saturating_add(1);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let websocket =
|
||||||
|
accept_hdr_async(stream, |request: &Request, mut response: Response| {
|
||||||
|
if let Some(value) = request.headers().get("x-test-env") {
|
||||||
|
assert_eq!(value, "ci");
|
||||||
|
}
|
||||||
|
if let Some(value) = request.headers().get("sec-websocket-protocol") {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("sec-websocket-protocol", value.clone());
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (mut sink, mut source) = websocket.split();
|
||||||
|
let mut sent = 0_u32;
|
||||||
|
|
||||||
|
if !close_early {
|
||||||
|
if let Some(message) = source.next().await {
|
||||||
|
let message = message.unwrap();
|
||||||
|
if let tokio_tungstenite::tungstenite::Message::Text(text) = message {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(&text) {
|
||||||
|
received.lock().await.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let payloads = if close_early && accepted == 1 {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![json!({"seq": 1}), json!({"seq": 2}), json!({"seq": 3})]
|
||||||
|
};
|
||||||
|
|
||||||
|
for payload in payloads {
|
||||||
|
sink.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||||
|
payload.to_string().into(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sent += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if sent < 3 {
|
||||||
|
let _ = sink.close().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
format!("ws://{}", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum WebsocketAdapterError {
|
||||||
|
#[error("invalid websocket url: {url}")]
|
||||||
|
InvalidUrl { url: String },
|
||||||
|
#[error("invalid websocket header name {header}")]
|
||||||
|
InvalidHeaderName { header: String },
|
||||||
|
#[error("invalid websocket header value for {header}")]
|
||||||
|
InvalidHeaderValue { header: String },
|
||||||
|
#[error("invalid websocket subprotocol {value}")]
|
||||||
|
InvalidSubprotocol { value: String },
|
||||||
|
#[error("websocket connect failed")]
|
||||||
|
Connect(#[from] tokio_tungstenite::tungstenite::Error),
|
||||||
|
#[error("websocket window expired before collecting any items")]
|
||||||
|
WindowExpired,
|
||||||
|
#[error("websocket stream produced malformed frame payload")]
|
||||||
|
InvalidFramePayload,
|
||||||
|
#[error("websocket endpoint returned close frame before collection completed")]
|
||||||
|
ClosedEarly,
|
||||||
|
#[error("websocket reconnect policy exhausted")]
|
||||||
|
ReconnectExhausted,
|
||||||
|
#[error("websocket upstream returned invalid subscribe payload")]
|
||||||
|
InvalidSubscribePayload,
|
||||||
|
#[error("websocket upstream returned invalid unsubscribe payload")]
|
||||||
|
InvalidUnsubscribePayload,
|
||||||
|
#[error("websocket upstream status {status}")]
|
||||||
|
UnexpectedStatus { status: u16, body: Value },
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
mod client;
|
||||||
|
mod error;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub use client::WebsocketAdapter;
|
||||||
|
pub use error::WebsocketAdapterError;
|
||||||
|
pub use session::{HeartbeatPolicy, ReconnectPolicy};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct WebsocketWindowRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
pub window_duration_ms: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_items: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub heartbeat_interval_ms: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reconnect_max_attempts: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reconnect_backoff_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WebsocketWindowResponse {
|
||||||
|
pub status_code: u16,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
pub body: Value,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ReconnectPolicy {
|
||||||
|
pub max_attempts: u32,
|
||||||
|
pub backoff: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ReconnectPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_attempts: 0,
|
||||||
|
backoff: Duration::from_millis(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct HeartbeatPolicy {
|
||||||
|
pub interval: Duration,
|
||||||
|
}
|
||||||
@@ -30,7 +30,8 @@ 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,
|
RetryPolicy, Samples, Target, ToolDescription, ToolExample, 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};
|
||||||
|
|||||||
@@ -50,12 +50,26 @@ pub struct GrpcTarget {
|
|||||||
pub descriptor_set_b64: String,
|
pub descriptor_set_b64: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WebsocketTarget {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub subprotocols: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub subscribe_message_template: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub unsubscribe_message_template: Option<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub static_headers: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
Rest(RestTarget),
|
Rest(RestTarget),
|
||||||
Graphql(GraphqlTarget),
|
Graphql(GraphqlTarget),
|
||||||
Grpc(GrpcTarget),
|
Grpc(GrpcTarget),
|
||||||
|
Websocket(WebsocketTarget),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
@@ -68,10 +82,22 @@ pub struct GrpcProtocolOptions {
|
|||||||
pub use_tls: bool,
|
pub use_tls: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct WebsocketProtocolOptions {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub heartbeat_interval_ms: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reconnect_max_attempts: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reconnect_backoff_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[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")]
|
||||||
|
pub websocket: Option<WebsocketProtocolOptions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -209,6 +235,7 @@ mod tests {
|
|||||||
operation::{
|
operation::{
|
||||||
ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus,
|
ConfigExport, ExecutionConfig, GraphqlTarget, Operation, OperationStatus,
|
||||||
ProtocolOptions, RestTarget, Samples, Target, ToolDescription, ToolExample,
|
ProtocolOptions, RestTarget, Samples, Target, ToolDescription, ToolExample,
|
||||||
|
WebsocketProtocolOptions, WebsocketTarget,
|
||||||
},
|
},
|
||||||
protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
protocol::{AuthKind, ExportMode, GraphqlOperationType, HttpMethod, Protocol},
|
||||||
};
|
};
|
||||||
@@ -244,6 +271,23 @@ mod tests {
|
|||||||
assert_eq!(value["operation_type"], "mutation");
|
assert_eq!(value["operation_type"], "mutation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_target_serializes_templates() {
|
||||||
|
let target = Target::Websocket(WebsocketTarget {
|
||||||
|
url: "wss://events.example.com/stream".to_owned(),
|
||||||
|
subprotocols: vec!["graphql-transport-ws".to_owned()],
|
||||||
|
subscribe_message_template: Some(json!({ "type": "subscribe" })),
|
||||||
|
unsubscribe_message_template: Some(json!({ "type": "unsubscribe" })),
|
||||||
|
static_headers: BTreeMap::from([("x-env".to_owned(), "test".to_owned())]),
|
||||||
|
});
|
||||||
|
|
||||||
|
let value = serde_json::to_value(target).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["kind"], "websocket");
|
||||||
|
assert_eq!(value["subprotocols"][0], "graphql-transport-ws");
|
||||||
|
assert_eq!(value["subscribe_message_template"]["type"], "subscribe");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn operation_exposes_local_domain_helpers() {
|
fn operation_exposes_local_domain_helpers() {
|
||||||
let operation = Operation {
|
let operation = Operation {
|
||||||
@@ -269,7 +313,14 @@ mod tests {
|
|||||||
retry_policy: None,
|
retry_policy: None,
|
||||||
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
protocol_options: Some(ProtocolOptions::default()),
|
protocol_options: Some(ProtocolOptions {
|
||||||
|
grpc: None,
|
||||||
|
websocket: Some(WebsocketProtocolOptions {
|
||||||
|
heartbeat_interval_ms: Some(5_000),
|
||||||
|
reconnect_max_attempts: Some(3),
|
||||||
|
reconnect_backoff_ms: Some(250),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
streaming: None,
|
streaming: None,
|
||||||
},
|
},
|
||||||
tool_description: ToolDescription {
|
tool_description: ToolDescription {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub enum Protocol {
|
|||||||
Rest,
|
Rest,
|
||||||
Graphql,
|
Graphql,
|
||||||
Grpc,
|
Grpc,
|
||||||
|
Websocket,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -49,6 +50,7 @@ impl Protocol {
|
|||||||
Self::Rest => true,
|
Self::Rest => true,
|
||||||
Self::Graphql => matches!(mode, ExecutionMode::Unary),
|
Self::Graphql => matches!(mode, ExecutionMode::Unary),
|
||||||
Self::Grpc => true,
|
Self::Grpc => true,
|
||||||
|
Self::Websocket => !matches!(mode, ExecutionMode::Unary),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ impl Protocol {
|
|||||||
Self::Rest => true,
|
Self::Rest => true,
|
||||||
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,4 +82,20 @@ mod tests {
|
|||||||
assert!(Protocol::Grpc.supports_execution_mode(ExecutionMode::Session));
|
assert!(Protocol::Grpc.supports_execution_mode(ExecutionMode::Session));
|
||||||
assert!(!Protocol::Grpc.supports_transport_behavior(TransportBehavior::DeferredResult));
|
assert!(!Protocol::Grpc.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn websocket_requires_streaming_modes() {
|
||||||
|
assert!(!Protocol::Websocket.supports_execution_mode(ExecutionMode::Unary));
|
||||||
|
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::Window));
|
||||||
|
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::Session));
|
||||||
|
assert!(Protocol::Websocket.supports_execution_mode(ExecutionMode::AsyncJob));
|
||||||
|
assert!(
|
||||||
|
!Protocol::Websocket.supports_transport_behavior(TransportBehavior::RequestResponse)
|
||||||
|
);
|
||||||
|
assert!(Protocol::Websocket.supports_transport_behavior(TransportBehavior::ServerStream));
|
||||||
|
assert!(
|
||||||
|
Protocol::Websocket.supports_transport_behavior(TransportBehavior::StatefulSession)
|
||||||
|
);
|
||||||
|
assert!(Protocol::Websocket.supports_transport_behavior(TransportBehavior::DeferredResult));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3958,6 +3958,7 @@ fn target_summary(target: &Target) -> (String, String) {
|
|||||||
.to_owned(),
|
.to_owned(),
|
||||||
),
|
),
|
||||||
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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-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" }
|
||||||
crank-schema = { path = "../crank-schema" }
|
crank-schema = { path = "../crank-schema" }
|
||||||
@@ -21,3 +22,4 @@ axum.workspace = true
|
|||||||
crank-adapter-grpc = { path = "../crank-adapter-grpc", features = ["test-support"] }
|
crank-adapter-grpc = { path = "../crank-adapter-grpc", features = ["test-support"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
tokio-tungstenite.workspace = true
|
||||||
|
|||||||
@@ -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_websocket::WebsocketAdapterError;
|
||||||
use crank_core::{ExecutionMode, Protocol};
|
use crank_core::{ExecutionMode, Protocol};
|
||||||
use crank_mapping::MappingError;
|
use crank_mapping::MappingError;
|
||||||
use crank_schema::SchemaError;
|
use crank_schema::SchemaError;
|
||||||
@@ -18,6 +19,8 @@ pub enum RuntimeError {
|
|||||||
GrpcAdapter(#[from] GrpcAdapterError),
|
GrpcAdapter(#[from] GrpcAdapterError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
RestAdapter(#[from] RestAdapterError),
|
RestAdapter(#[from] RestAdapterError),
|
||||||
|
#[error(transparent)]
|
||||||
|
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 },
|
||||||
#[error("operation {operation_id} does not define streaming config")]
|
#[error("operation {operation_id} does not define streaming config")]
|
||||||
|
|||||||
@@ -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_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};
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ pub struct RuntimeExecutor {
|
|||||||
graphql_adapter: GraphqlAdapter,
|
graphql_adapter: GraphqlAdapter,
|
||||||
grpc_adapter: GrpcAdapter,
|
grpc_adapter: GrpcAdapter,
|
||||||
rest_adapter: RestAdapter,
|
rest_adapter: RestAdapter,
|
||||||
|
websocket_adapter: WebsocketAdapter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RuntimeExecutor {
|
impl Default for RuntimeExecutor {
|
||||||
@@ -29,6 +31,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(),
|
||||||
|
websocket_adapter: WebsocketAdapter::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +66,10 @@ impl RuntimeExecutor {
|
|||||||
let adapter_response = if matches!(
|
let adapter_response = if matches!(
|
||||||
streaming.transport_behavior,
|
streaming.transport_behavior,
|
||||||
TransportBehavior::ServerStream
|
TransportBehavior::ServerStream
|
||||||
) && matches!(operation.target, Target::Rest(_) | Target::Grpc(_))
|
) && matches!(
|
||||||
{
|
operation.target,
|
||||||
|
Target::Rest(_) | Target::Grpc(_) | Target::Websocket(_)
|
||||||
|
) {
|
||||||
self.execute_window_adapter(operation, prepared_request)
|
self.execute_window_adapter(operation, prepared_request)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
@@ -200,6 +205,10 @@ impl RuntimeExecutor {
|
|||||||
data: Value::Null,
|
data: Value::Null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Target::Websocket(_) => Err(RuntimeError::UnsupportedExecutionMode {
|
||||||
|
operation_id: operation.operation_id.as_str().to_owned(),
|
||||||
|
mode: ExecutionMode::Unary,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +283,46 @@ impl RuntimeExecutor {
|
|||||||
data: Value::Null,
|
data: Value::Null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Target::Websocket(target) => {
|
||||||
|
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
|
||||||
|
return Err(RuntimeError::MissingStreamingConfig {
|
||||||
|
operation_id: operation.operation_id.as_str().to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let websocket_options = operation
|
||||||
|
.execution_config
|
||||||
|
.protocol_options
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|options| options.websocket.as_ref());
|
||||||
|
let request = WebsocketWindowRequest {
|
||||||
|
headers: merge_headers(
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&operation.execution_config.headers,
|
||||||
|
&prepared_request.headers,
|
||||||
|
),
|
||||||
|
window_duration_ms: streaming.window_duration_ms.unwrap_or_default(),
|
||||||
|
max_items: streaming.max_items.map(|value| value.saturating_add(1)),
|
||||||
|
heartbeat_interval_ms: websocket_options
|
||||||
|
.and_then(|options| options.heartbeat_interval_ms),
|
||||||
|
reconnect_max_attempts: websocket_options
|
||||||
|
.and_then(|options| options.reconnect_max_attempts)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
reconnect_backoff_ms: websocket_options
|
||||||
|
.and_then(|options| options.reconnect_backoff_ms)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
};
|
||||||
|
let response = self
|
||||||
|
.websocket_adapter
|
||||||
|
.execute_window(target, &request)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(AdapterResponse {
|
||||||
|
status_code: response.status_code,
|
||||||
|
headers: response.headers,
|
||||||
|
body: response.body,
|
||||||
|
data: Value::Null,
|
||||||
|
})
|
||||||
|
}
|
||||||
_ => self.execute_adapter(operation, prepared_request).await,
|
_ => self.execute_adapter(operation, prepared_request).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,14 +440,16 @@ mod tests {
|
|||||||
use crank_core::{
|
use crank_core::{
|
||||||
AggregationMode, DescriptorId, ExecutionConfig, ExecutionMode, GeneratedDraft,
|
AggregationMode, DescriptorId, ExecutionConfig, ExecutionMode, GeneratedDraft,
|
||||||
GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
||||||
Operation, OperationId, OperationStatus, Protocol, RestTarget, Samples, StreamingConfig,
|
Operation, OperationId, OperationStatus, Protocol, ProtocolOptions, RestTarget, Samples,
|
||||||
Target, ToolDescription, ToolExample, ToolFamilyConfig, TransportBehavior,
|
StreamingConfig, Target, ToolDescription, ToolExample, ToolFamilyConfig, TransportBehavior,
|
||||||
|
WebsocketProtocolOptions, WebsocketTarget,
|
||||||
};
|
};
|
||||||
use crank_mapping::{MappingRule, MappingSet};
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_schema::{Schema, SchemaKind};
|
use crank_schema::{Schema, SchemaKind};
|
||||||
use futures_util::stream;
|
use futures_util::{SinkExt, StreamExt, stream};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
use tokio_tungstenite::accept_async;
|
||||||
|
|
||||||
use crate::{RuntimeError, RuntimeExecutor, RuntimeOperation};
|
use crate::{RuntimeError, RuntimeExecutor, RuntimeOperation};
|
||||||
|
|
||||||
@@ -464,6 +515,26 @@ mod tests {
|
|||||||
assert!(!result.window_complete);
|
assert!(!result.window_complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn executes_websocket_window_mode_end_to_end() {
|
||||||
|
let target_url = spawn_websocket_server().await;
|
||||||
|
let executor = RuntimeExecutor::new();
|
||||||
|
let operation =
|
||||||
|
test_websocket_window_operation(&target_url, AggregationMode::RawItems, Some(3));
|
||||||
|
|
||||||
|
let result = executor
|
||||||
|
.execute_window(&operation, &json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.items.len(), 3);
|
||||||
|
assert_eq!(result.items[0], json!({ "seq": 1, "value": 101 }));
|
||||||
|
assert_eq!(result.items[2], json!({ "seq": 3, "value": 103 }));
|
||||||
|
assert!(result.window_complete);
|
||||||
|
assert!(!result.truncated);
|
||||||
|
assert!(!result.has_more);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rejects_invalid_input_shape() {
|
async fn rejects_invalid_input_shape() {
|
||||||
let base_url = spawn_runtime_server().await;
|
let base_url = spawn_runtime_server().await;
|
||||||
@@ -652,6 +723,43 @@ mod tests {
|
|||||||
format!("http://{}", address)
|
format!("http://{}", address)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn spawn_websocket_server() -> String {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let websocket = accept_async(stream).await.unwrap();
|
||||||
|
let (mut sink, mut source) = websocket.split();
|
||||||
|
|
||||||
|
if let Some(message) = source.next().await {
|
||||||
|
let message = message.unwrap();
|
||||||
|
if !matches!(message, tokio_tungstenite::tungstenite::Message::Text(_)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for payload in [
|
||||||
|
json!({ "seq": 1, "value": 101 }),
|
||||||
|
json!({ "seq": 2, "value": 102 }),
|
||||||
|
json!({ "seq": 3, "value": 103 }),
|
||||||
|
] {
|
||||||
|
sink.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||||
|
payload.to_string().into(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let _ = sink.close().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
format!("ws://{}", address)
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_lead(Json(payload): Json<Value>) -> (axum::http::StatusCode, Json<Value>) {
|
async fn create_lead(Json(payload): Json<Value>) -> (axum::http::StatusCode, Json<Value>) {
|
||||||
let should_fail = payload
|
let should_fail = payload
|
||||||
.get("fail")
|
.get("fail")
|
||||||
@@ -1168,6 +1276,104 @@ mod tests {
|
|||||||
operation
|
operation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_websocket_window_operation(
|
||||||
|
target_url: &str,
|
||||||
|
aggregation_mode: AggregationMode,
|
||||||
|
max_items: Option<u32>,
|
||||||
|
) -> RuntimeOperation {
|
||||||
|
RuntimeOperation::from(Operation {
|
||||||
|
id: OperationId::new("op_websocket_window_runtime"),
|
||||||
|
name: "telemetry_window_ws".to_owned(),
|
||||||
|
display_name: "Telemetry Window WebSocket".to_owned(),
|
||||||
|
category: "ops".to_owned(),
|
||||||
|
protocol: Protocol::Websocket,
|
||||||
|
status: OperationStatus::Published,
|
||||||
|
version: 1,
|
||||||
|
target: Target::Websocket(WebsocketTarget {
|
||||||
|
url: target_url.to_owned(),
|
||||||
|
subprotocols: Vec::new(),
|
||||||
|
subscribe_message_template: Some(json!({"type":"subscribe","topic":"telemetry"})),
|
||||||
|
unsubscribe_message_template: Some(
|
||||||
|
json!({"type":"unsubscribe","topic":"telemetry"}),
|
||||||
|
),
|
||||||
|
static_headers: BTreeMap::new(),
|
||||||
|
}),
|
||||||
|
input_schema: Schema {
|
||||||
|
kind: SchemaKind::Object,
|
||||||
|
description: None,
|
||||||
|
required: true,
|
||||||
|
nullable: false,
|
||||||
|
default_value: None,
|
||||||
|
fields: BTreeMap::new(),
|
||||||
|
items: None,
|
||||||
|
enum_values: Vec::new(),
|
||||||
|
variants: Vec::new(),
|
||||||
|
},
|
||||||
|
output_schema: object_schema("ignored", SchemaKind::String),
|
||||||
|
input_mapping: MappingSet {
|
||||||
|
rules: vec![MappingRule {
|
||||||
|
source: "$.mcp".to_owned(),
|
||||||
|
target: "$.request.body".to_owned(),
|
||||||
|
required: false,
|
||||||
|
default_value: None,
|
||||||
|
transform: None,
|
||||||
|
condition: None,
|
||||||
|
notes: None,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
output_mapping: MappingSet { rules: Vec::new() },
|
||||||
|
execution_config: ExecutionConfig {
|
||||||
|
timeout_ms: 1_000,
|
||||||
|
retry_policy: None,
|
||||||
|
auth_profile_ref: None,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
protocol_options: Some(ProtocolOptions {
|
||||||
|
grpc: None,
|
||||||
|
websocket: Some(WebsocketProtocolOptions {
|
||||||
|
heartbeat_interval_ms: Some(100),
|
||||||
|
reconnect_max_attempts: Some(1),
|
||||||
|
reconnect_backoff_ms: Some(10),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
streaming: Some(StreamingConfig {
|
||||||
|
mode: ExecutionMode::Window,
|
||||||
|
transport_behavior: TransportBehavior::ServerStream,
|
||||||
|
window_duration_ms: Some(2_000),
|
||||||
|
poll_interval_ms: None,
|
||||||
|
upstream_timeout_ms: Some(1_000),
|
||||||
|
idle_timeout_ms: None,
|
||||||
|
max_session_lifetime_ms: None,
|
||||||
|
max_items,
|
||||||
|
max_bytes: None,
|
||||||
|
aggregation_mode,
|
||||||
|
summary_path: None,
|
||||||
|
items_path: Some("$.items".to_owned()),
|
||||||
|
cursor_path: None,
|
||||||
|
status_path: None,
|
||||||
|
done_path: Some("$.done".to_owned()),
|
||||||
|
redacted_paths: Vec::new(),
|
||||||
|
truncate_item_fields: false,
|
||||||
|
max_field_length: None,
|
||||||
|
drop_duplicates: false,
|
||||||
|
sampling_rate: None,
|
||||||
|
tool_family: ToolFamilyConfig::default(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tool_description: ToolDescription {
|
||||||
|
title: "Telemetry Window WebSocket".to_owned(),
|
||||||
|
description: "Collects bounded WebSocket telemetry.".to_owned(),
|
||||||
|
tags: vec!["websocket".to_owned(), "stream".to_owned()],
|
||||||
|
examples: Vec::new(),
|
||||||
|
},
|
||||||
|
samples: None,
|
||||||
|
generated_draft: None,
|
||||||
|
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_sse_operation(
|
fn test_window_sse_operation(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
aggregation_mode: AggregationMode,
|
aggregation_mode: AggregationMode,
|
||||||
|
|||||||
@@ -430,6 +430,20 @@
|
|||||||
|
|
||||||
- `kind`
|
- `kind`
|
||||||
- `wsdl_ref`
|
- `wsdl_ref`
|
||||||
|
|
||||||
|
## 5. `ProtocolOptions`
|
||||||
|
|
||||||
|
`ProtocolOptions` хранит protocol-specific runtime tuning, который не должен смешиваться с общей `ExecutionConfig`.
|
||||||
|
|
||||||
|
### 5.1. `GrpcProtocolOptions`
|
||||||
|
|
||||||
|
- `use_tls`
|
||||||
|
|
||||||
|
### 5.2. `WebsocketProtocolOptions`
|
||||||
|
|
||||||
|
- `heartbeat_interval_ms`
|
||||||
|
- `reconnect_max_attempts`
|
||||||
|
- `reconnect_backoff_ms`
|
||||||
- `service_name`
|
- `service_name`
|
||||||
- `port_name`
|
- `port_name`
|
||||||
- `operation_name`
|
- `operation_name`
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ WebSocket operation должна включать:
|
|||||||
- `url`
|
- `url`
|
||||||
- `headers`
|
- `headers`
|
||||||
- `subprotocols`
|
- `subprotocols`
|
||||||
- `connect_timeout_ms`
|
|
||||||
- `heartbeat_interval_ms`
|
|
||||||
- `subscribe_message_template`
|
- `subscribe_message_template`
|
||||||
- `unsubscribe_message_template`
|
- `unsubscribe_message_template`
|
||||||
- `input_mapping`
|
- `input_mapping`
|
||||||
@@ -88,6 +86,12 @@ WebSocket operation должна включать:
|
|||||||
- `execution_config`
|
- `execution_config`
|
||||||
- `tool_description`
|
- `tool_description`
|
||||||
|
|
||||||
|
Protocol-specific runtime tuning хранится отдельно в `ProtocolOptions.websocket`:
|
||||||
|
|
||||||
|
- `heartbeat_interval_ms`
|
||||||
|
- `reconnect_max_attempts`
|
||||||
|
- `reconnect_backoff_ms`
|
||||||
|
|
||||||
## 7. Как оператор настраивает WebSocket operation
|
## 7. Как оператор настраивает WebSocket operation
|
||||||
|
|
||||||
1. Указывает URL WebSocket upstream.
|
1. Указывает URL WebSocket upstream.
|
||||||
|
|||||||
Reference in New Issue
Block a user