oa_gateway_owp/session.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866
//! One WebSocket session: INIT, then PUB / SUB / UNSUB until close.
//!
//! Binary frames are refused and the socket is closed. A parse error on
//! a text frame is `-ERR Illegal-Argument` and the session stays up.
//! A failed INIT closes it.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use oa_gateway_adapter::tls::MaybeTlsStream;
use oa_gateway_adapter::AdapterError;
use oa_gateway_agra::{unwrap as unwrap_ma, wrapper_kind, xml_root_local_name};
use oa_gateway_core::{
AdapterId, ContentType, Delivery, Engine, Envelope, RouteKey, SubId, DEFAULT_CHANNEL_CAPACITY,
};
use oa_gateway_uci::validate::{summarize, Mode as ValidateMode};
use oa_gateway_uci::Schema;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::Instant;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::WebSocketStream;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use crate::codec::{
parse_client, type_hint_from_json, ClientOp, Identifiers, InfoPayload, InitPayload, OwpError,
ServerOp,
};
use crate::config::OwpConfig;
use crate::convert::{toward_xml, violations_of, xml_to_oms_json};
/// Protocol version this server speaks. INIT must list it.
const OWP_VERSION: &str = "1.0";
/// One accepted WebSocket and the engine handle it publishes through.
///
/// `conn_id` is unique per accept on this adapter. Engine subscription
/// ids are `{conn_id}:{sid}` so two connections can reuse a client sid.
pub(crate) struct Session {
pub(crate) adapter_id: AdapterId,
pub(crate) config: OwpConfig,
pub(crate) schema: Option<Arc<Schema>>,
pub(crate) conn_id: u64,
pub(crate) ws: WebSocketStream<MaybeTlsStream<TcpStream>>,
pub(crate) engine: Arc<Engine>,
pub(crate) shutdown: CancellationToken,
}
enum State {
/// First client frame must be INIT.
AwaitingInit,
/// Handshake succeeded. `verbose` defaults to true when INIT omits it.
Active { verbose: bool, service_id: String },
}
/// One client SUB: the engine key and the task that forwards MSG frames.
struct LiveSub {
engine_sub: SubId,
forwarder: JoinHandle<()>,
}
/// Whether an unroutable publish is worth reporting yet.
enum Report {
/// Not seen on this connection before.
New,
/// As [`Report::New`], and the last one this connection will report.
Final,
/// Already reported, or reporting has stopped.
Skip,
}
/// Routes this connection has already been warned about.
///
/// A client that publishes into thin air, or sends a payload the schema does not
/// permit, rarely does it once, so reporting every message would bury the log.
/// Reporting also stops past [`SeenRoutes::CAP`] distinct routes, so a client
/// cycling through topics cannot turn a warning into unbounded memory or log
/// volume. One tracker per kind of warning, so a noisy route of one kind does not
/// hide the other.
#[derive(Default)]
struct SeenRoutes {
seen: HashSet<RouteKey>,
}
/// Unroutable publishes and invalid payloads, tracked separately.
#[derive(Default)]
struct RouteWarnings {
unroutable: SeenRoutes,
invalid: SeenRoutes,
}
impl SeenRoutes {
/// Distinct routes named in the log before this tracker goes quiet.
const CAP: usize = 64;
/// Records `route` if it is new and under the cap.
fn report(&mut self, route: &RouteKey) -> Report {
if self.seen.len() >= Self::CAP || self.seen.contains(route) {
return Report::Skip;
}
self.seen.insert(route.clone());
if self.seen.len() == Self::CAP {
Report::Final
} else {
Report::New
}
}
}
/// End the WebSocket after an `-ERR` that the spec treats as fatal.
enum Fatal {
Close,
}
/// Runs `session` until shutdown, close, or a fatal protocol error.
///
/// On the way out every live SUB is aborted and unsubscribed. This does
/// not call [`Engine::drop_adapter`]: other connections on the same
/// adapter must keep their subscriptions.
///
/// # Errors
///
/// Does not fail. Handshake already succeeded; I/O errors end the loop
/// as [`Ok`].
pub(crate) async fn run(mut session: Session) -> Result<(), AdapterError> {
let (out_tx, mut out_rx) = mpsc::channel::<ServerOp>(DEFAULT_CHANNEL_CAPACITY);
let mut state = State::AwaitingInit;
let mut subs: HashMap<String, LiveSub> = HashMap::new();
let mut warnings = RouteWarnings::default();
let started = Instant::now();
let mut last_frame = started;
loop {
// While awaiting INIT the deadline is fixed from the handshake, so a
// peer cannot keep the slot by dribbling junk frames without ever
// completing INIT. Once active it slides forward on every frame in
// either direction, so a live publisher or subscriber is never closed.
let deadline = match state {
State::AwaitingInit => session.config.init_timeout.map(|d| started + d),
State::Active { .. } => session.config.idle_timeout.map(|d| last_frame + d),
};
tokio::select! {
() = session.shutdown.cancelled() => break,
() = deadline_elapsed(deadline) => {
let reason = match state {
State::AwaitingInit => "no INIT before the init timeout",
State::Active { .. } => "no frames before the idle timeout",
};
debug!(
adapter = %session.adapter_id,
conn_id = session.conn_id,
reason,
"closing owp session on timeout"
);
let _ = session
.ws
.send(Message::Close(Some(CloseFrame {
code: CloseCode::Policy,
reason: reason.into(),
})))
.await;
break;
}
outgoing = out_rx.recv() => {
let Some(op) = outgoing else { break };
last_frame = Instant::now();
if session.ws.send(Message::Text(op.to_string().into())).await.is_err() {
break;
}
}
incoming = session.ws.next() => {
let Some(frame) = incoming else { break };
let Ok(msg) = frame else { break };
last_frame = Instant::now();
match msg {
Message::Text(text) => {
if handle_text(
&session,
&mut state,
&mut subs,
&mut warnings,
&out_tx,
text.as_str(),
)
.await
.is_err()
{
break;
}
}
Message::Ping(data) => {
let _ = session.ws.send(Message::Pong(data)).await;
}
Message::Pong(_) | Message::Frame(_) => {}
Message::Binary(_) => {
let _ = out_tx
.send(err_op(OwpError::IllegalOperation, Some("binary frames are not allowed")))
.await;
break;
}
Message::Close(_) => break,
}
}
}
}
for (_, live) in subs.drain() {
live.forwarder.abort();
let _ = session
.engine
.unsubscribe(session.adapter_id.clone(), live.engine_sub)
.await;
}
Ok(())
}
/// Dispatches one client text frame.
///
/// Unknown or malformed frames stay on the connection. INIT in the
/// wrong state, or a rejected INIT, returns [`Fatal::Close`].
///
/// `SUB` `group` is accepted by the codec and ignored here.
async fn handle_text(
session: &Session,
state: &mut State,
subs: &mut HashMap<String, LiveSub>,
warnings: &mut RouteWarnings,
out_tx: &mpsc::Sender<ServerOp>,
text: &str,
) -> Result<(), Fatal> {
let op = match parse_client(text) {
Ok(op) => op,
Err(err) => {
send(
out_tx,
err_op(OwpError::IllegalArgument, Some(err.to_string().as_str())),
)
.await;
return Ok(());
}
};
match state {
State::AwaitingInit => {
if let ClientOp::Init(init) = op {
handle_init(session, state, out_tx, init).await
} else {
send(
out_tx,
err_op(
OwpError::IllegalState,
Some("INIT must be the first operation"),
),
)
.await;
Err(Fatal::Close)
}
}
State::Active {
verbose,
service_id,
} => match op {
ClientOp::Init(_) => {
send(
out_tx,
err_op(OwpError::IllegalState, Some("duplicate INIT")),
)
.await;
Err(Fatal::Close)
}
ClientOp::Pub { topic, payload } => {
handle_pub(
session, warnings, out_tx, *verbose, service_id, topic, payload,
)
.await
}
ClientOp::Sub {
sid,
message_name,
topic,
group: _,
} => handle_sub(session, subs, out_tx, *verbose, sid, message_name, topic).await,
ClientOp::Unsub { sid } => handle_unsub(session, subs, out_tx, *verbose, sid).await,
},
}
}
/// Negotiates INIT. Success sends INFO (and +OK when verbose).
async fn handle_init(
session: &Session,
state: &mut State,
out_tx: &mpsc::Sender<ServerOp>,
init: InitPayload,
) -> Result<(), Fatal> {
match negotiate(&session.config, &init) {
Ok(()) => {
let verbose = init.verbose.unwrap_or(true);
if verbose {
send(out_tx, ServerOp::Ok).await;
}
send(out_tx, ServerOp::Info(info_payload(&session.config, &init))).await;
*state = State::Active {
verbose,
service_id: init.service_id,
};
Ok(())
}
Err(error) => {
send(out_tx, err_op(error, None)).await;
Err(Fatal::Close)
}
}
}
/// Publishes onto the engine. A bad payload is `-ERR Invalid-Message`
/// and the session stays up.
async fn handle_pub(
session: &Session,
warnings: &mut RouteWarnings,
out_tx: &mpsc::Sender<ServerOp>,
verbose: bool,
service_id: &str,
topic: String,
payload: String,
) -> Result<(), Fatal> {
match publish_owp(session, warnings, service_id, topic, payload).await {
Ok(()) => {
if verbose {
send(out_tx, ServerOp::Ok).await;
}
}
Err(err) => {
send(out_tx, err_op(OwpError::InvalidMessage, Some(&err))).await;
}
}
Ok(())
}
/// Subscribes the engine and spawns a forwarder that writes MSG frames.
///
/// Duplicate `sid` or the per-connection cap is `-ERR` without closing.
/// The protocol has no resource-limit code; the cap uses Illegal-State.
async fn handle_sub(
session: &Session,
subs: &mut HashMap<String, LiveSub>,
out_tx: &mpsc::Sender<ServerOp>,
verbose: bool,
sid: String,
message_name: String,
topic: String,
) -> Result<(), Fatal> {
if subs.contains_key(&sid) {
send(
out_tx,
err_op(OwpError::IllegalArgument, Some("duplicate sid")),
)
.await;
return Ok(());
}
if subs.len() >= session.config.max_subscriptions {
// Each subscription costs a channel, a task, and an engine index
// entry keyed by client-supplied strings, so the count is bounded
// per connection. The protocol has no resource-limit code, and
// Illegal-State is the closest of the ones it defines.
warn!(
adapter = %session.adapter_id,
conn_id = session.conn_id,
limit = session.config.max_subscriptions,
"subscription limit reached"
);
send(
out_tx,
err_op(OwpError::IllegalState, Some("subscription limit reached")),
)
.await;
return Ok(());
}
let engine_sub = SubId::new(format!("{}:{sid}", session.conn_id));
let (tx, mut rx) = mpsc::channel::<Delivery>(DEFAULT_CHANNEL_CAPACITY);
if session
.engine
.subscribe(
session.adapter_id.clone(),
engine_sub.clone(),
RouteKey::typed(topic, message_name),
tx,
)
.await
.is_err()
{
send(
out_tx,
err_op(OwpError::InternalError, Some("subscribe failed")),
)
.await;
return Ok(());
}
let forward_tx = out_tx.clone();
let local_sid = sid.clone();
let xml_baseline = session.config.xml_baseline;
let schema = session.schema.clone();
let adapter_id = session.adapter_id.clone();
let validate = session.config.validate;
let forwarder = tokio::spawn(async move {
// The client is told about every dropped delivery, since each is
// a message it will not receive; the log is told once, since the
// cause is the same for every payload on this route.
let mut logged = false;
let mut logged_invalid = false;
let mut logged_conversion_invalid = false;
while let Some(delivery) = rx.recv().await {
let Ok(raw) = String::from_utf8(delivery.envelope.payload.to_vec()) else {
warn!("dropping non-utf8 payload destined for OWP");
continue;
};
// What arrived off the bus, before any conversion of ours,
// so a violation is attributed to the producer.
let violations = violations_of(raw.as_bytes(), schema.as_deref(), validate);
match handle_violations(
&violations,
validate,
&adapter_id,
&local_sid,
&forward_tx,
&mut logged_invalid,
ViolationWording::PRODUCER,
)
.await
{
Verdict::Drop => continue,
Verdict::Closed => break,
Verdict::Forward => {}
}
let payload = if xml_baseline {
match xml_to_oms_json(&raw, schema.as_deref()) {
Ok(json) => {
// Conversion is a no-op when the bus payload was not XML
// to begin with (xml_to_oms_json returns it unchanged),
// in which case re-checking here would just repeat the
// producer check above and misattribute its violation to
// conversion. Only worth checking when bytes may have
// actually changed: conversion is best-effort, and a bug
// in it can produce JSON that no longer follows the
// schema even though the bus payload did.
if oa_gateway_uci::looks_like_xml(raw.as_bytes()) {
let violations =
violations_of(json.as_bytes(), schema.as_deref(), validate);
match handle_violations(
&violations,
validate,
&adapter_id,
&local_sid,
&forward_tx,
&mut logged_conversion_invalid,
ViolationWording::CONVERSION,
)
.await
{
Verdict::Drop => continue,
Verdict::Closed => break,
Verdict::Forward => {}
}
}
json
}
Err(err) => {
if !logged {
logged = true;
warn!(
adapter = %adapter_id,
sid = %local_sid,
error = %err,
"dropping a delivery that will not convert to JSON; \
later failures on this subscription are not logged"
);
}
// Forwarding the XML instead would hand the client
// a format it did not subscribe for, and it has no
// way to tell that from an ordinary payload.
let details =
format!("delivery on {local_sid} could not be converted: {err}");
if forward_tx
.send(err_op(OwpError::InvalidMessage, Some(&details)))
.await
.is_err()
{
break;
}
continue;
}
}
} else {
raw
};
if forward_tx
.send(ServerOp::Msg {
sid: local_sid.clone(),
payload,
})
.await
.is_err()
{
break;
}
}
});
subs.insert(
sid,
LiveSub {
engine_sub,
forwarder,
},
);
if verbose {
send(out_tx, ServerOp::Ok).await;
}
Ok(())
}
/// Aborts the forwarder and unsubscribes. Unknown `sid` is `-ERR`.
async fn handle_unsub(
session: &Session,
subs: &mut HashMap<String, LiveSub>,
out_tx: &mpsc::Sender<ServerOp>,
verbose: bool,
sid: String,
) -> Result<(), Fatal> {
if let Some(live) = subs.remove(&sid) {
live.forwarder.abort();
let _ = session
.engine
.unsubscribe(session.adapter_id.clone(), live.engine_sub)
.await;
if verbose {
send(out_tx, ServerOp::Ok).await;
}
} else {
send(
out_tx,
err_op(OwpError::IllegalArgument, Some("unknown sid")),
)
.await;
}
Ok(())
}
/// Maps one PUB onto one or two engine envelopes and publishes them.
///
/// A-GRA unwrap, XML baseline conversion, and validation all run before
/// any publish, so a wrapper and its inner are all-or-nothing. Each
/// envelope is stamped with `owp.service_id` and `owp.conn_id`.
///
/// # Errors
///
/// Returns a message for unwrap, type-hint, conversion, or reject-mode
/// validation failures. Unroutable publishes are warned, not errors.
async fn publish_owp(
session: &Session,
warnings: &mut RouteWarnings,
service_id: &str,
topic: String,
payload: String,
) -> Result<(), String> {
let stamp = |mut env: Envelope| {
env.headers
.insert("owp.service_id".into(), service_id.to_owned());
env.headers
.insert("owp.conn_id".into(), session.conn_id.to_string());
env
};
let mut outgoing = Vec::new();
if session.config.unwrap_ma_payloads && wrapper_kind(payload.as_bytes()).is_some() {
let peeled = unwrap_ma(&topic, payload.as_bytes()).map_err(|e| e.to_string())?;
outgoing.push(peeled.wrapper);
outgoing.push(peeled.inner);
} else {
let hint = if oa_gateway_uci::looks_like_xml(payload.as_bytes()) {
// The name is in the document, so no schema is needed to read it,
// and this is how the STOMP edge names an XML payload too. The topic
// used to stand in when the schema could not parse the payload,
// which routed the message under a key no subscriber of that type
// would match and reported +OK regardless.
xml_root_local_name(&payload)
.ok_or_else(|| "XML payload has no element to take a type from".to_string())?
} else {
type_hint_from_json(&payload).map_err(|e| e.to_string())?
};
let ct = if oa_gateway_uci::looks_like_xml(payload.as_bytes()) {
ContentType::xml()
} else {
ContentType::json()
};
outgoing.push(
Envelope::new(RouteKey::typed(topic, hint), payload.into_bytes()).with_content_type(ct),
);
}
// Convert and check everything before publishing any of it: an A-GRA wrapper
// and the message inside it are one publish as far as the client is
// concerned, and half of one is worse than neither.
let mut ready = Vec::with_capacity(outgoing.len());
for env in outgoing {
let env = if session.config.xml_baseline {
let schema = session.schema.as_deref().ok_or_else(|| {
"owp.xml_baseline is enabled but no UCI schema is loaded".to_string()
})?;
toward_xml(env, schema)?
} else {
env
};
let violations = violations_of(
&env.payload,
session.schema.as_deref(),
session.config.validate,
);
if !violations.is_empty() {
let summary = summarize(&violations);
if session.config.validate == ValidateMode::Reject {
return Err(format!("payload does not follow the UCI schema: {summary}"));
}
match warnings.invalid.report(&env.route) {
Report::Skip => {}
report => {
warn!(
adapter = %session.adapter_id,
service = %service_id,
route = %env.route,
violations = %summary,
"published payload does not follow the UCI schema; carrying it anyway"
);
cap_notice(report, &session.adapter_id, service_id);
}
}
}
ready.push(stamp(env));
}
for env in ready {
let route = env.route.clone();
// A publish that matches nothing is legal pub/sub, but it is far more
// often a topic the gateway was never configured to carry — a STOMP
// topics list without this entry, say — and the client is told "+OK"
// either way. Say so here rather than let the message vanish.
let outcome = session.engine.publish(env).await;
if outcome.dropped > 0 {
warn!(
adapter = %session.adapter_id,
service = %service_id,
route = %route,
matched = outcome.matched,
delivered = outcome.delivered,
dropped = outcome.dropped,
"engine dropped deliveries on this publish"
);
}
if outcome.matched == 0 {
match warnings.unroutable.report(&route) {
Report::Skip => {}
report => {
warn!(
adapter = %session.adapter_id,
service = %service_id,
route = %route,
"nothing is subscribed to this route, so the publish went nowhere"
);
cap_notice(report, &session.adapter_id, service_id);
}
}
}
}
Ok(())
}
/// Say when a tracker has stopped naming routes, so a quiet log is not mistaken
/// for a quiet client.
fn cap_notice(report: Report, adapter: &AdapterId, service_id: &str) {
if matches!(report, Report::Final) {
warn!(
adapter = %adapter,
service = %service_id,
"reached the reporting limit for this connection; further routes of this \
kind will not be named"
);
}
}
/// Checks INIT.versions contains [`OWP_VERSION`] and INIT.schema matches
/// when [`OwpConfig::schema`] is set.
///
/// # Errors
///
/// Returns [`OwpError::UnsupportedVersion`] or
/// [`OwpError::UnsupportedSchema`].
fn negotiate(config: &OwpConfig, init: &InitPayload) -> Result<(), OwpError> {
if !init.versions.iter().any(|v| v == OWP_VERSION) {
return Err(OwpError::UnsupportedVersion);
}
if let Some(expected) = &config.schema {
if &init.schema != expected {
return Err(OwpError::UnsupportedSchema);
}
}
Ok(())
}
/// Builds INFO. The service UUID is v5 of the client's `service_id`.
fn info_payload(config: &OwpConfig, init: &InitPayload) -> InfoPayload {
let service = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, init.service_id.as_bytes());
InfoPayload {
version: OWP_VERSION.into(),
server_id: config.server_id.clone(),
uuids: Identifiers {
system: config.system_uuid.clone(),
service: service.to_string(),
subsystem: None,
},
system_label: config.system_label.clone(),
}
}
/// What the caller of [`handle_violations`] should do with the delivery.
enum Verdict {
/// No violations, or `Warn` mode: forward the payload as usual.
Forward,
/// `Reject` mode found violations: the caller should drop this delivery
/// (`continue` its loop) after the error has already been sent.
Drop,
/// The client's channel is closed: the caller should stop the forwarder.
Closed,
}
/// The wording that differs between a violation found in what the producer
/// put on the bus and one found in what `xml_baseline` conversion produced.
struct ViolationWording {
/// Noun phrase completing "delivery on {sid} ...: {summary}".
detail: &'static str,
reject_log: &'static str,
warn_log: &'static str,
}
impl ViolationWording {
const PRODUCER: Self = Self {
detail: "does not follow the UCI schema",
reject_log: "dropping a delivery that does not follow the UCI schema; later ones on \
this subscription are not logged",
warn_log: "delivered payload does not follow the UCI schema; forwarding it anyway, \
and later ones on this subscription are not logged",
};
const CONVERSION: Self = Self {
detail: "converted to a payload that does not follow the UCI schema",
reject_log: "dropping a delivery that converted to a payload not following the UCI \
schema; later ones on this subscription are not logged",
warn_log: "converted payload does not follow the UCI schema; forwarding it anyway, \
and later ones on this subscription are not logged",
};
}
/// Applies `validate`'s policy to `violations`: in `Reject` mode, sends a
/// `-ERR` and reports [`Verdict::Drop`]; in `Warn` mode, only logs. Either
/// way the warning is logged once per subscription, via `logged`.
async fn handle_violations(
violations: &[oa_gateway_uci::validate::Violation],
validate: ValidateMode,
adapter_id: &impl std::fmt::Display,
local_sid: &str,
forward_tx: &mpsc::Sender<ServerOp>,
logged: &mut bool,
wording: ViolationWording,
) -> Verdict {
if violations.is_empty() {
return Verdict::Forward;
}
let summary = summarize(violations);
if validate == ValidateMode::Reject {
if !*logged {
*logged = true;
warn!(adapter = %adapter_id, sid = %local_sid, violations = %summary, "{}", wording.reject_log);
}
let details = format!("delivery on {local_sid} {}: {summary}", wording.detail);
if forward_tx
.send(err_op(OwpError::InvalidMessage, Some(&details)))
.await
.is_err()
{
return Verdict::Closed;
}
return Verdict::Drop;
}
if !*logged {
*logged = true;
warn!(adapter = %adapter_id, sid = %local_sid, violations = %summary, "{}", wording.warn_log);
}
Verdict::Forward
}
/// Builds a `-ERR` frame. `details` is the rest of the line when set.
fn err_op(error: OwpError, details: Option<&str>) -> ServerOp {
ServerOp::Err {
error,
details: details.map(str::to_owned),
}
}
/// Queues `op` for the writer. A full or closed channel is ignored so
/// a slow client cannot stall the read loop.
async fn send(tx: &mpsc::Sender<ServerOp>, op: ServerOp) {
let _ = tx.send(op).await;
}
/// Resolves once `deadline` passes, or never when it is `None` (the timeout
/// disabled). Used as one arm of the session `select!`.
async fn deadline_elapsed(deadline: Option<Instant>) {
match deadline {
Some(at) => tokio::time::sleep_until(at).await,
None => std::future::pending().await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_route_is_reported_once() {
let mut unroutable = SeenRoutes::default();
let route = RouteKey::typed("SystemStatus", "SystemStatus");
assert!(matches!(unroutable.report(&route), Report::New));
assert!(matches!(unroutable.report(&route), Report::Skip));
assert!(matches!(unroutable.report(&route), Report::Skip));
// A different type on the same topic is a different route.
let other = RouteKey::typed("SystemStatus", "PositionReport");
assert!(matches!(unroutable.report(&other), Report::New));
}
#[test]
fn reporting_stops_at_the_cap() {
let mut unroutable = SeenRoutes::default();
for i in 0..SeenRoutes::CAP - 1 {
let route = RouteKey::typed(format!("topic-{i}"), "Ping");
assert!(matches!(unroutable.report(&route), Report::New));
}
let last = RouteKey::typed("topic-last", "Ping");
assert!(matches!(unroutable.report(&last), Report::Final));
// Nothing further is reported or remembered, however many routes arrive.
for i in 0..1000 {
let route = RouteKey::typed(format!("flood-{i}"), "Ping");
assert!(matches!(unroutable.report(&route), Report::Skip));
}
assert_eq!(unroutable.seen.len(), SeenRoutes::CAP);
}
}