use std::fmt;
use std::num::NonZeroU32;
use crate::protocol::ProtocolError;
use crate::protocol::escaping::encode_form_value;
#[allow(dead_code)]
pub(crate) const PROTOCOL_VERSION: &str = "TLCP-2.5.0";
#[allow(dead_code)]
pub(crate) const WS_SUBPROTOCOL: &str = "TLCP-2.5.0.lightstreamer.com";
#[allow(dead_code)]
pub(crate) const WS_PATH: &str = "/lightstreamer";
pub(crate) const CRLF: &str = "\r\n";
pub(crate) const CLIENT_IDENTIFIER: &str = "mgQkwtwdysogQz2BJ4Ji%20kOj2Bg";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub(crate) enum TransportKind {
Http,
#[allow(dead_code)]
HttpBatched,
WebSocket,
}
impl TransportKind {
#[must_use]
#[inline]
const fn requires_session(self) -> bool {
matches!(self, Self::Http)
}
#[must_use]
#[inline]
const fn honors_ack(self) -> bool {
matches!(self, Self::WebSocket)
}
}
#[cold]
#[inline(never)]
fn invalid(request: &'static str, reason: impl Into<String>) -> ProtocolError {
ProtocolError::Request {
request,
reason: reason.into(),
}
}
#[derive(Debug, Default)]
struct ParameterLine {
buf: String,
}
impl ParameterLine {
#[must_use]
#[inline]
fn new() -> Self {
Self { buf: String::new() }
}
fn verbatim(&mut self, name: &str, value: &str) {
if !self.buf.is_empty() {
self.buf.push('&');
}
self.buf.push_str(name);
self.buf.push('=');
self.buf.push_str(value);
}
fn text(&mut self, name: &str, value: &str) {
let encoded = encode_form_value(value);
self.verbatim(name, encoded.as_ref());
}
fn boolean(&mut self, name: &str, value: bool) {
self.verbatim(name, if value { "true" } else { "false" });
}
fn number<T: fmt::Display>(&mut self, name: &str, value: T) {
self.verbatim(name, &value.to_string());
}
#[must_use]
#[inline]
fn finish(self) -> String {
self.buf
}
}
pub(crate) trait TlcpRequest {
const NAME: &'static str;
const PATH: &'static str;
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError>;
#[allow(dead_code)]
#[must_use]
fn http_target() -> String
where
Self: Sized,
{
format!("{}?LS_protocol={PROTOCOL_VERSION}", Self::PATH)
}
#[allow(dead_code)]
#[must_use]
fn http_target_with_session(session: &str) -> String
where
Self: Sized,
{
format!(
"{}?LS_protocol={PROTOCOL_VERSION}&LS_session={}",
Self::PATH,
encode_form_value(session)
)
}
#[allow(dead_code)]
fn http_body(&self) -> Result<String, ProtocolError> {
self.encode_parameters(TransportKind::Http)
}
fn ws_message(&self) -> Result<String, ProtocolError> {
let parameters = self.encode_parameters(TransportKind::WebSocket)?;
Ok(format!("{}{CRLF}{parameters}", Self::NAME))
}
}
pub(crate) fn encode_http_batch(
request_name: &'static str,
lines: &[String],
) -> Result<String, ProtocolError> {
if lines.is_empty() {
return Err(invalid(request_name, "a request batch must not be empty"));
}
Ok(lines.join(CRLF))
}
pub(crate) fn encode_ws_batch(
request_name: &'static str,
lines: &[String],
) -> Result<String, ProtocolError> {
let body = encode_http_batch(request_name, lines)?;
Ok(format!("{request_name}{CRLF}{body}"))
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct RequestId(String);
impl RequestId {
#[must_use = "builders do nothing unless the result is used"]
pub(crate) fn try_new(id: impl Into<String>) -> Result<Self, ProtocolError> {
let id = id.into();
if id.is_empty() {
return Err(invalid("control", "LS_reqId must not be empty"));
}
if !id.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(invalid(
"control",
format!("LS_reqId must be letters and digits only, got {id:?}"),
));
}
Ok(Self(id))
}
#[must_use]
pub(crate) fn from_progressive(n: u64) -> Self {
Self(n.to_string())
}
#[must_use]
#[inline]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RequestId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct DecimalNumber(String);
impl DecimalNumber {
#[must_use = "builders do nothing unless the result is used"]
pub(crate) fn try_new(text: impl Into<String>) -> Result<Self, ProtocolError> {
let text = text.into();
let mut parts = text.split('.');
let integral = parts.next().unwrap_or_default();
let fractional = parts.next();
let well_formed = parts.next().is_none()
&& !integral.is_empty()
&& integral.bytes().all(|b| b.is_ascii_digit())
&& fractional.is_none_or(|f| !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()));
if !well_formed {
return Err(invalid(
"control",
format!("expected a decimal number with a dot separator, got {text:?}"),
));
}
Ok(Self(text))
}
#[must_use]
#[inline]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DecimalNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct SequenceName(String);
impl SequenceName {
pub(crate) const RESERVED: &'static str = "UNORDERED_MESSAGES";
#[must_use = "builders do nothing unless the result is used"]
pub(crate) fn try_new(name: impl Into<String>) -> Result<Self, ProtocolError> {
let name = name.into();
if name.is_empty() {
return Err(invalid("msg", "LS_sequence must not be empty"));
}
if name == Self::RESERVED {
return Err(invalid(
"msg",
format!(
"LS_sequence {:?} is reserved by the protocol",
Self::RESERVED
),
));
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(invalid(
"msg",
format!("LS_sequence must be alphanumeric or underscore, got {name:?}"),
));
}
Ok(Self(name))
}
#[must_use]
#[inline]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct CauseCode(i32);
impl CauseCode {
#[allow(dead_code)]
#[must_use = "builders do nothing unless the result is used"]
pub(crate) fn try_new(code: i32) -> Result<Self, ProtocolError> {
if code > 0 {
return Err(invalid(
CONTROL_NAME,
format!("LS_cause_code must be zero or negative, got {code}"),
));
}
Ok(Self(code))
}
#[must_use]
#[inline]
pub(crate) const fn get(self) -> i32 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct CauseMessage(String);
impl CauseMessage {
#[allow(dead_code)]
pub(crate) const RECOMMENDED_MAX_LEN: usize = 35;
#[allow(dead_code)]
#[must_use = "builders do nothing unless the result is used"]
pub(crate) fn try_new(message: impl Into<String>) -> Result<Self, ProtocolError> {
let message = message.into();
if message.contains(['\r', '\n']) {
return Err(invalid(
"control",
"LS_cause_message must not contain CR or LF: multiline text is not allowed",
));
}
Ok(Self(message))
}
#[must_use]
#[inline]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct DestroyCause {
pub(crate) code: CauseCode,
pub(crate) message: Option<CauseMessage>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub(crate) enum SubscriptionMode {
Raw,
Merge,
Distinct,
Command,
}
impl SubscriptionMode {
#[must_use]
#[inline]
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Raw => "RAW",
Self::Merge => "MERGE",
Self::Distinct => "DISTINCT",
Self::Command => "COMMAND",
}
}
#[must_use]
#[inline]
const fn is_filtered(self) -> bool {
!matches!(self, Self::Raw)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub(crate) enum ControlOperation {
Add,
Delete,
Reconf,
#[allow(dead_code)]
Constrain,
ForceRebind,
Destroy,
}
impl ControlOperation {
#[must_use]
#[inline]
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Add => "add",
Self::Delete => "delete",
Self::Reconf => "reconf",
Self::Constrain => "constrain",
Self::ForceRebind => "force_rebind",
Self::Destroy => "destroy",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum RequestedMaxFrequency {
Unlimited,
Unfiltered,
Limited(DecimalNumber),
}
impl RequestedMaxFrequency {
#[must_use]
fn as_str(&self) -> &str {
match self {
Self::Unlimited => "unlimited",
Self::Unfiltered => "unfiltered",
Self::Limited(n) => n.as_str(),
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum MaxFrequencyLimit {
Unlimited,
Limited(DecimalNumber),
}
impl MaxFrequencyLimit {
#[must_use]
fn as_str(&self) -> &str {
match self {
Self::Unlimited => "unlimited",
Self::Limited(n) => n.as_str(),
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum MaxBandwidth {
Unlimited,
Limited(DecimalNumber),
}
impl MaxBandwidth {
#[allow(dead_code)]
#[must_use]
fn as_str(&self) -> &str {
match self {
Self::Unlimited => "unlimited",
Self::Limited(n) => n.as_str(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum RequestedBufferSize {
Unlimited,
Events(NonZeroU32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum Snapshot {
Off,
On,
Length(NonZeroU32),
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum TimeToLive {
Unknown,
Unlimited,
Millis(u64),
}
impl fmt::Display for TimeToLive {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unknown => f.write_str("unknown"),
Self::Unlimited => f.write_str("unlimited"),
Self::Millis(ms) => write!(f, "{ms}"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum ConnectionMode {
Streaming {
inactivity_millis: Option<u64>,
keepalive_millis: Option<u64>,
send_sync: Option<bool>,
},
#[allow(dead_code)]
Polling {
polling_millis: u64,
idle_millis: Option<u64>,
},
}
impl Default for ConnectionMode {
fn default() -> Self {
Self::Streaming {
inactivity_millis: None,
keepalive_millis: None,
send_sync: None,
}
}
}
impl ConnectionMode {
fn encode_into(&self, params: &mut ParameterLine) {
match self {
Self::Streaming {
inactivity_millis,
keepalive_millis,
send_sync,
} => {
if let Some(ms) = inactivity_millis {
params.number("LS_inactivity_millis", ms);
}
if let Some(ms) = keepalive_millis {
params.number("LS_keepalive_millis", ms);
}
if let Some(send) = send_sync {
params.boolean("LS_send_sync", *send);
}
}
Self::Polling {
polling_millis,
idle_millis,
} => {
params.boolean("LS_polling", true);
params.number("LS_polling_millis", polling_millis);
if let Some(ms) = idle_millis {
params.number("LS_idle_millis", ms);
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct CreateSession {
pub(crate) user: Option<String>,
pub(crate) password: Option<String>,
pub(crate) adapter_set: Option<String>,
pub(crate) requested_max_bandwidth: Option<DecimalNumber>,
pub(crate) content_length: Option<u64>,
pub(crate) connection: ConnectionMode,
pub(crate) reduce_head: Option<bool>,
pub(crate) ttl: Option<TimeToLive>,
}
impl TlcpRequest for CreateSession {
const NAME: &'static str = "create_session";
const PATH: &'static str = "/lightstreamer/create_session.txt";
fn encode_parameters(&self, _transport: TransportKind) -> Result<String, ProtocolError> {
let mut params = ParameterLine::new();
if let Some(user) = &self.user {
params.text("LS_user", user);
}
if let Some(password) = &self.password {
params.text("LS_password", password);
}
if let Some(adapter_set) = &self.adapter_set {
params.text("LS_adapter_set", adapter_set);
}
params.verbatim("LS_cid", CLIENT_IDENTIFIER);
if let Some(bandwidth) = &self.requested_max_bandwidth {
params.verbatim("LS_requested_max_bandwidth", bandwidth.as_str());
}
if let Some(length) = self.content_length {
params.number("LS_content_length", length);
}
let diffs = crate::protocol::diff::supported_diffs();
if !diffs.is_empty() {
params.verbatim("LS_supported_diffs", diffs);
}
self.connection.encode_into(&mut params);
if let Some(reduce) = self.reduce_head {
params.boolean("LS_reduce_head", reduce);
}
if let Some(ttl) = self.ttl {
params.verbatim("LS_ttl_millis", &ttl.to_string());
}
Ok(params.finish())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct BindSession {
pub(crate) session: Option<String>,
pub(crate) recovery_from: Option<u64>,
pub(crate) content_length: Option<u64>,
pub(crate) connection: ConnectionMode,
pub(crate) reduce_head: Option<bool>,
}
impl TlcpRequest for BindSession {
const NAME: &'static str = "bind_session";
const PATH: &'static str = "/lightstreamer/bind_session.txt";
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
let mut params = ParameterLine::new();
match (&self.session, transport) {
(Some(session), _) => params.text("LS_session", session),
(None, TransportKind::WebSocket) => {}
(None, TransportKind::Http | TransportKind::HttpBatched) => {
return Err(invalid(
Self::NAME,
"LS_session is required with HTTP transport",
));
}
}
if let Some(progressive) = self.recovery_from {
params.number("LS_recovery_from", progressive);
}
if let Some(length) = self.content_length {
params.number("LS_content_length", length);
}
self.connection.encode_into(&mut params);
if let Some(reduce) = self.reduce_head {
params.boolean("LS_reduce_head", reduce);
}
Ok(params.finish())
}
}
const CONTROL_NAME: &str = "control";
const CONTROL_PATH: &str = "/lightstreamer/control.txt";
fn encode_control_header(
params: &mut ParameterLine,
session: Option<&str>,
request_id: &RequestId,
operation: ControlOperation,
transport: TransportKind,
) -> Result<(), ProtocolError> {
match session {
Some(session) => params.text("LS_session", session),
None if transport.requires_session() => {
return Err(invalid(
CONTROL_NAME,
"LS_session is required with HTTP transport unless it is supplied on the query string of a batch",
));
}
None => {}
}
params.verbatim("LS_reqId", request_id.as_str());
params.verbatim("LS_op", operation.as_str());
Ok(())
}
fn encode_ack(params: &mut ParameterLine, ack: Option<bool>, transport: TransportKind) {
if !transport.honors_ack() {
return;
}
if let Some(ack) = ack {
params.boolean("LS_ack", ack);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Subscribe {
pub(crate) session: Option<String>,
pub(crate) request_id: RequestId,
pub(crate) subscription_id: NonZeroU32,
pub(crate) group: String,
pub(crate) schema: String,
pub(crate) mode: SubscriptionMode,
pub(crate) data_adapter: Option<String>,
pub(crate) selector: Option<String>,
pub(crate) requested_buffer_size: Option<RequestedBufferSize>,
pub(crate) requested_max_frequency: Option<RequestedMaxFrequency>,
pub(crate) snapshot: Option<Snapshot>,
pub(crate) ack: Option<bool>,
}
impl Subscribe {
pub(crate) const OPERATION: ControlOperation = ControlOperation::Add;
fn validate(&self) -> Result<(), ProtocolError> {
if matches!(self.snapshot, Some(Snapshot::Length(_)))
&& self.mode != SubscriptionMode::Distinct
{
return Err(invalid(
CONTROL_NAME,
format!(
"LS_snapshot as a number of events is admitted only with LS_mode=DISTINCT, got {}",
self.mode.as_str()
),
));
}
if matches!(self.snapshot, Some(Snapshot::On)) && !self.mode.is_filtered() {
return Err(invalid(
CONTROL_NAME,
"LS_snapshot=true is ignored by the server with LS_mode=RAW; omit it or choose another mode",
));
}
if matches!(
self.requested_max_frequency,
Some(RequestedMaxFrequency::Unfiltered | RequestedMaxFrequency::Limited(_))
) && !self.mode.is_filtered()
{
return Err(invalid(
CONTROL_NAME,
"LS_requested_max_frequency is ignored by the server with LS_mode=RAW; omit it or choose another mode",
));
}
if self.requested_buffer_size.is_some() {
if !matches!(
self.mode,
SubscriptionMode::Merge | SubscriptionMode::Distinct
) {
return Err(invalid(
CONTROL_NAME,
format!(
"LS_requested_buffer_size is ignored by the server unless LS_mode is MERGE or DISTINCT, got {}",
self.mode.as_str()
),
));
}
if matches!(
self.requested_max_frequency,
Some(RequestedMaxFrequency::Unfiltered)
) {
return Err(invalid(
CONTROL_NAME,
"LS_requested_buffer_size is ignored by the server when LS_requested_max_frequency is unfiltered; set one or the other",
));
}
}
Ok(())
}
}
impl TlcpRequest for Subscribe {
const NAME: &'static str = CONTROL_NAME;
const PATH: &'static str = CONTROL_PATH;
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
self.validate()?;
let mut params = ParameterLine::new();
encode_control_header(
&mut params,
self.session.as_deref(),
&self.request_id,
Self::OPERATION,
transport,
)?;
params.number("LS_subId", self.subscription_id);
params.text("LS_group", &self.group);
params.text("LS_schema", &self.schema);
if let Some(adapter) = &self.data_adapter {
params.text("LS_data_adapter", adapter);
}
params.verbatim("LS_mode", self.mode.as_str());
if let Some(selector) = &self.selector {
params.text("LS_selector", selector);
}
match &self.requested_buffer_size {
Some(RequestedBufferSize::Unlimited) => {
params.verbatim("LS_requested_buffer_size", "unlimited");
}
Some(RequestedBufferSize::Events(n)) => {
params.number("LS_requested_buffer_size", n);
}
None => {}
}
if let Some(frequency) = &self.requested_max_frequency {
params.verbatim("LS_requested_max_frequency", frequency.as_str());
}
match &self.snapshot {
Some(Snapshot::Off) => params.boolean("LS_snapshot", false),
Some(Snapshot::On) => params.boolean("LS_snapshot", true),
Some(Snapshot::Length(n)) => params.number("LS_snapshot", n),
None => {}
}
encode_ack(&mut params, self.ack, transport);
Ok(params.finish())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Unsubscribe {
pub(crate) session: Option<String>,
pub(crate) request_id: RequestId,
pub(crate) subscription_id: NonZeroU32,
pub(crate) ack: Option<bool>,
}
impl Unsubscribe {
pub(crate) const OPERATION: ControlOperation = ControlOperation::Delete;
}
impl TlcpRequest for Unsubscribe {
const NAME: &'static str = CONTROL_NAME;
const PATH: &'static str = CONTROL_PATH;
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
let mut params = ParameterLine::new();
encode_control_header(
&mut params,
self.session.as_deref(),
&self.request_id,
Self::OPERATION,
transport,
)?;
params.number("LS_subId", self.subscription_id);
encode_ack(&mut params, self.ack, transport);
Ok(params.finish())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReconfigureSubscription {
pub(crate) session: Option<String>,
pub(crate) request_id: RequestId,
pub(crate) subscription_id: NonZeroU32,
pub(crate) requested_max_frequency: Option<MaxFrequencyLimit>,
}
impl ReconfigureSubscription {
pub(crate) const OPERATION: ControlOperation = ControlOperation::Reconf;
}
impl TlcpRequest for ReconfigureSubscription {
const NAME: &'static str = CONTROL_NAME;
const PATH: &'static str = CONTROL_PATH;
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
let mut params = ParameterLine::new();
encode_control_header(
&mut params,
self.session.as_deref(),
&self.request_id,
Self::OPERATION,
transport,
)?;
params.number("LS_subId", self.subscription_id);
if let Some(frequency) = &self.requested_max_frequency {
params.verbatim("LS_requested_max_frequency", frequency.as_str());
}
Ok(params.finish())
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConstrainSession {
pub(crate) session: Option<String>,
pub(crate) request_id: RequestId,
pub(crate) requested_max_bandwidth: Option<MaxBandwidth>,
}
impl ConstrainSession {
#[allow(dead_code)]
pub(crate) const OPERATION: ControlOperation = ControlOperation::Constrain;
}
impl TlcpRequest for ConstrainSession {
const NAME: &'static str = CONTROL_NAME;
const PATH: &'static str = CONTROL_PATH;
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
let mut params = ParameterLine::new();
encode_control_header(
&mut params,
self.session.as_deref(),
&self.request_id,
Self::OPERATION,
transport,
)?;
if let Some(bandwidth) = &self.requested_max_bandwidth {
params.verbatim("LS_requested_max_bandwidth", bandwidth.as_str());
}
Ok(params.finish())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ForceRebind {
pub(crate) session: Option<String>,
pub(crate) request_id: RequestId,
pub(crate) polling_millis: Option<u64>,
pub(crate) close_socket: Option<bool>,
}
impl ForceRebind {
pub(crate) const OPERATION: ControlOperation = ControlOperation::ForceRebind;
}
impl TlcpRequest for ForceRebind {
const NAME: &'static str = CONTROL_NAME;
const PATH: &'static str = CONTROL_PATH;
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
let mut params = ParameterLine::new();
encode_control_header(
&mut params,
self.session.as_deref(),
&self.request_id,
Self::OPERATION,
transport,
)?;
if let Some(ms) = self.polling_millis {
params.number("LS_polling_millis", ms);
}
if let Some(close) = self.close_socket {
params.boolean("LS_close_socket", close);
}
Ok(params.finish())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DestroySession {
pub(crate) session: Option<String>,
pub(crate) request_id: RequestId,
pub(crate) cause: Option<DestroyCause>,
pub(crate) close_socket: Option<bool>,
}
impl DestroySession {
pub(crate) const OPERATION: ControlOperation = ControlOperation::Destroy;
}
impl TlcpRequest for DestroySession {
const NAME: &'static str = CONTROL_NAME;
const PATH: &'static str = CONTROL_PATH;
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
let mut params = ParameterLine::new();
encode_control_header(
&mut params,
self.session.as_deref(),
&self.request_id,
Self::OPERATION,
transport,
)?;
if let Some(cause) = &self.cause {
params.number("LS_cause_code", cause.code.get());
if let Some(message) = &cause.message {
params.text("LS_cause_message", message.as_str());
}
}
if let Some(close) = self.close_socket {
params.boolean("LS_close_socket", close);
}
Ok(params.finish())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MessageSend {
pub(crate) session: Option<String>,
pub(crate) request_id: RequestId,
pub(crate) message: String,
pub(crate) sequence: Option<SequenceName>,
pub(crate) msg_prog: Option<NonZeroU32>,
pub(crate) max_wait_millis: Option<u64>,
pub(crate) ack: Option<bool>,
pub(crate) outcome: Option<bool>,
}
impl MessageSend {
fn validate(&self) -> Result<(), ProtocolError> {
let outcome_requested = self.outcome != Some(false);
if self.msg_prog.is_none() && (self.sequence.is_some() || outcome_requested) {
let cause = if self.sequence.is_some() {
"LS_sequence is specified"
} else {
"LS_outcome is not explicitly false"
};
return Err(invalid(
Self::NAME,
format!("LS_msg_prog is mandatory when {cause}"),
));
}
if self.max_wait_millis.is_some() && self.sequence.is_none() {
return Err(invalid(
Self::NAME,
"LS_max_wait is ignored by the server unless LS_sequence is specified",
));
}
Ok(())
}
}
impl TlcpRequest for MessageSend {
const NAME: &'static str = "msg";
const PATH: &'static str = "/lightstreamer/msg.txt";
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
self.validate()?;
let mut params = ParameterLine::new();
match &self.session {
Some(session) => params.text("LS_session", session),
None if transport.requires_session() => {
return Err(invalid(
Self::NAME,
"LS_session is required with HTTP transport",
));
}
None => {}
}
params.verbatim("LS_reqId", self.request_id.as_str());
if let Some(sequence) = &self.sequence {
params.verbatim("LS_sequence", sequence.as_str());
}
if let Some(prog) = self.msg_prog {
params.number("LS_msg_prog", prog);
}
if let Some(ms) = self.max_wait_millis {
params.number("LS_max_wait", ms);
}
encode_ack(&mut params, self.ack, transport);
if let Some(outcome) = self.outcome {
params.boolean("LS_outcome", outcome);
}
params.text("LS_message", &self.message);
Ok(params.finish())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct Heartbeat {
pub(crate) session: Option<String>,
}
impl TlcpRequest for Heartbeat {
const NAME: &'static str = "heartbeat";
const PATH: &'static str = "/lightstreamer/heartbeat.txt";
fn encode_parameters(&self, transport: TransportKind) -> Result<String, ProtocolError> {
let mut params = ParameterLine::new();
match &self.session {
Some(session) => params.text("LS_session", session),
None if transport.requires_session() => {
return Err(invalid(
Self::NAME,
"LS_session is required with HTTP transport",
));
}
None => {}
}
Ok(params.finish())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct WsOk;
impl WsOk {
pub(crate) const NAME: &'static str = "wsok";
#[must_use]
#[inline]
pub(crate) const fn ws_message() -> &'static str {
Self::NAME
}
}
#[cfg(test)]
mod tests {
use super::*;
const CONTROL_SESSION: &str = "Sd9fce58fb5dbbebfT2255126";
const BIND_SESSION: &str = "S73d162c183916f0dT2729905";
fn sub_id(n: u32) -> NonZeroU32 {
match NonZeroU32::new(n) {
Some(id) => id,
None => panic!("a subscription ID is 1-based and never zero"),
}
}
fn req_id(n: u64) -> RequestId {
RequestId::from_progressive(n)
}
fn param<'a>(line: &'a str, name: &str) -> Option<&'a str> {
line.split('&')
.find_map(|pair| pair.strip_prefix(name)?.strip_prefix('='))
}
fn required_param<'a>(line: &'a str, name: &str) -> &'a str {
match param(line, name) {
Some(value) => value,
None => panic!("`{name}` is missing from {line:?}"),
}
}
fn has_escape(value: &str, escape: &str) -> bool {
value.to_ascii_uppercase().contains(escape)
}
const RESERVED: [char; 5] = ['\r', '\n', '&', '=', '+'];
const CREATE_SESSION_EXAMPLE: &str = "LS_user=user&LS_password=password&LS_adapter_set=DEMO&LS_cid=mgQkwtwdysogQz2BJ4Ji%20kOj2Bg";
fn spec_create_session() -> CreateSession {
CreateSession {
user: Some("user".to_owned()),
password: Some("password".to_owned()),
adapter_set: Some("DEMO".to_owned()),
..CreateSession::default()
}
}
fn supported_diffs_tail() -> String {
let diffs = crate::protocol::diff::supported_diffs();
if diffs.is_empty() {
String::new()
} else {
format!("&LS_supported_diffs={diffs}")
}
}
#[test]
fn test_create_session_http_body_matches_spec_example_p24() -> Result<(), ProtocolError> {
let body = spec_create_session().http_body()?;
let expected = format!("{CREATE_SESSION_EXAMPLE}{}", supported_diffs_tail());
assert_eq!(body, expected);
assert!(body.starts_with(CREATE_SESSION_EXAMPLE));
Ok(())
}
#[test]
fn test_create_session_ws_message_matches_spec_example_p24() -> Result<(), ProtocolError> {
let message = spec_create_session().ws_message()?;
let expected = format!(
"create_session\r\n{CREATE_SESSION_EXAMPLE}{}",
supported_diffs_tail()
);
assert_eq!(message, expected);
Ok(())
}
#[test]
fn test_create_session_http_target_carries_the_protocol_parameter() {
assert_eq!(
CreateSession::http_target(),
"/lightstreamer/create_session.txt?LS_protocol=TLCP-2.5.0"
);
}
#[test]
fn test_create_session_omits_every_optional_parameter_when_none() -> Result<(), ProtocolError> {
let body = CreateSession::default().http_body()?;
let expected = format!("LS_cid={CLIENT_IDENTIFIER}{}", supported_diffs_tail());
assert_eq!(body, expected);
assert!(!body.contains("LS_user"));
assert!(!body.contains("LS_polling"));
assert!(!body.contains("LS_ttl_millis"));
Ok(())
}
#[test]
fn test_create_session_supported_diffs_is_derived_not_literal() -> Result<(), ProtocolError> {
let body = CreateSession::default().http_body()?;
let diffs = crate::protocol::diff::supported_diffs();
if diffs.is_empty() {
assert!(!body.contains("LS_supported_diffs"));
} else {
assert!(body.contains(&format!("LS_supported_diffs={diffs}")));
assert!(!body.contains("LS_supported_diffs=&"));
assert!(!body.ends_with("LS_supported_diffs="));
assert!(diffs.split(',').all(|tag| !tag.is_empty()));
assert!(!diffs.contains(' '));
}
Ok(())
}
#[test]
fn test_create_session_streaming_group_encoded() -> Result<(), ProtocolError> {
let request = CreateSession {
connection: ConnectionMode::Streaming {
inactivity_millis: Some(5000),
keepalive_millis: Some(3000),
send_sync: Some(false),
},
..CreateSession::default()
};
let body = request.http_body()?;
assert!(body.contains("LS_inactivity_millis=5000"));
assert!(body.contains("LS_keepalive_millis=3000"));
assert!(body.contains("LS_send_sync=false"));
assert!(!body.contains("LS_polling"));
Ok(())
}
#[test]
fn test_create_session_polling_group_encoded_and_excludes_streaming_group()
-> Result<(), ProtocolError> {
let request = CreateSession {
connection: ConnectionMode::Polling {
polling_millis: 2000,
idle_millis: Some(15000),
},
..CreateSession::default()
};
let body = request.http_body()?;
assert!(body.contains("LS_polling=true"));
assert!(body.contains("LS_polling_millis=2000"));
assert!(body.contains("LS_idle_millis=15000"));
assert!(!body.contains("LS_inactivity_millis"));
assert!(!body.contains("LS_keepalive_millis"));
assert!(!body.contains("LS_send_sync"));
Ok(())
}
#[test]
fn test_create_session_polling_millis_is_not_optional() -> Result<(), ProtocolError> {
let request = CreateSession {
connection: ConnectionMode::Polling {
polling_millis: 0,
idle_millis: None,
},
..CreateSession::default()
};
assert!(request.http_body()?.contains("LS_polling_millis=0"));
Ok(())
}
#[test]
fn test_create_session_ttl_millis_special_values() -> Result<(), ProtocolError> {
for (ttl, expected) in [
(TimeToLive::Unknown, "LS_ttl_millis=unknown"),
(TimeToLive::Unlimited, "LS_ttl_millis=unlimited"),
(TimeToLive::Millis(750), "LS_ttl_millis=750"),
] {
let request = CreateSession {
ttl: Some(ttl),
..CreateSession::default()
};
assert!(request.http_body()?.contains(expected));
}
Ok(())
}
#[test]
fn test_create_session_bandwidth_content_length_and_reduce_head() -> Result<(), ProtocolError> {
let request = CreateSession {
requested_max_bandwidth: Some(DecimalNumber::try_new("50.0")?),
content_length: Some(4000),
reduce_head: Some(true),
..CreateSession::default()
};
let body = request.http_body()?;
assert!(body.contains("LS_requested_max_bandwidth=50.0"));
assert!(body.contains("LS_content_length=4000"));
assert!(body.contains("LS_reduce_head=true"));
Ok(())
}
#[test]
fn test_create_session_escapes_reserved_characters_in_credentials() -> Result<(), ProtocolError>
{
let request = CreateSession {
user: Some("a&b=c".to_owned()),
password: Some("p+w%d".to_owned()),
..CreateSession::default()
};
let body = request.http_body()?;
let user = required_param(&body, "LS_user");
assert_ne!(user, "a&b=c");
assert!(!user.contains(RESERVED));
assert!(has_escape(user, "%26"));
assert!(has_escape(user, "%3D"));
let password = required_param(&body, "LS_password");
assert!(!password.contains(RESERVED));
assert!(has_escape(password, "%2B"));
assert!(has_escape(password, "%25"));
Ok(())
}
#[test]
fn test_bind_session_http_body_matches_spec_example_p26() -> Result<(), ProtocolError> {
let request = BindSession {
session: Some(BIND_SESSION.to_owned()),
..BindSession::default()
};
assert_eq!(request.http_body()?, "LS_session=S73d162c183916f0dT2729905");
Ok(())
}
#[test]
fn test_bind_session_ws_message_matches_spec_example_p26() -> Result<(), ProtocolError> {
let request = BindSession {
session: Some(BIND_SESSION.to_owned()),
..BindSession::default()
};
assert_eq!(
request.ws_message()?,
"bind_session\r\nLS_session=S73d162c183916f0dT2729905"
);
Ok(())
}
#[test]
fn test_bind_session_ws_may_omit_session() -> Result<(), ProtocolError> {
let message = BindSession::default().ws_message()?;
assert_eq!(message, "bind_session\r\n");
Ok(())
}
#[test]
fn test_bind_session_http_without_session_is_error() {
assert!(matches!(
BindSession::default().http_body(),
Err(ProtocolError::Request {
request: "bind_session",
..
})
));
}
#[test]
fn test_bind_session_recovery_and_content_length() -> Result<(), ProtocolError> {
let request = BindSession {
session: Some(BIND_SESSION.to_owned()),
recovery_from: Some(1234),
content_length: Some(50_000),
reduce_head: Some(true),
..BindSession::default()
};
assert_eq!(
request.http_body()?,
"LS_session=S73d162c183916f0dT2729905&LS_recovery_from=1234\
&LS_content_length=50000&LS_reduce_head=true"
);
Ok(())
}
#[test]
fn test_bind_session_polling_group() -> Result<(), ProtocolError> {
let request = BindSession {
session: Some(BIND_SESSION.to_owned()),
connection: ConnectionMode::Polling {
polling_millis: 5000,
idle_millis: None,
},
..BindSession::default()
};
let body = request.http_body()?;
assert!(body.contains("LS_polling=true&LS_polling_millis=5000"));
assert!(!body.contains("LS_idle_millis"));
Ok(())
}
fn spec_subscribe(session: Option<&str>) -> Subscribe {
Subscribe {
session: session.map(str::to_owned),
request_id: req_id(1),
subscription_id: sub_id(1),
group: "item1".to_owned(),
schema: "last_price".to_owned(),
mode: SubscriptionMode::Merge,
data_adapter: Some("QUOTE_ADAPTER".to_owned()),
selector: None,
requested_buffer_size: None,
requested_max_frequency: None,
snapshot: None,
ack: None,
}
}
#[test]
fn test_subscribe_http_body_matches_spec_example_p31() -> Result<(), ProtocolError> {
let request = spec_subscribe(Some(CONTROL_SESSION));
assert_eq!(
request.http_body()?,
"LS_session=Sd9fce58fb5dbbebfT2255126&LS_reqId=1&LS_op=add&LS_subId=1\
&LS_group=item1&LS_schema=last_price&LS_data_adapter=QUOTE_ADAPTER&LS_mode=MERGE"
);
Ok(())
}
#[test]
fn test_subscribe_ws_message_matches_spec_example_p31() -> Result<(), ProtocolError> {
let request = spec_subscribe(None);
assert_eq!(
request.ws_message()?,
"control\r\nLS_reqId=1&LS_op=add&LS_subId=1&LS_group=item1\
&LS_schema=last_price&LS_data_adapter=QUOTE_ADAPTER&LS_mode=MERGE"
);
Ok(())
}
#[test]
fn test_subscribe_ws_batch_matches_spec_example_p31() -> Result<(), ProtocolError> {
let first = spec_subscribe(None);
let second = Subscribe {
request_id: req_id(2),
subscription_id: sub_id(2),
group: "item2".to_owned(),
..spec_subscribe(None)
};
let lines = vec![
first.encode_parameters(TransportKind::WebSocket)?,
second.encode_parameters(TransportKind::WebSocket)?,
];
assert_eq!(
encode_ws_batch(Subscribe::NAME, &lines)?,
"control\r\n\
LS_reqId=1&LS_op=add&LS_subId=1&LS_group=item1&LS_schema=last_price\
&LS_data_adapter=QUOTE_ADAPTER&LS_mode=MERGE\r\n\
LS_reqId=2&LS_op=add&LS_subId=2&LS_group=item2&LS_schema=last_price\
&LS_data_adapter=QUOTE_ADAPTER&LS_mode=MERGE"
);
Ok(())
}
#[test]
fn test_subscribe_http_batch_uses_query_string_session() -> Result<(), ProtocolError> {
let first = Subscribe {
session: None,
..spec_subscribe(None)
};
let second = Subscribe {
request_id: req_id(2),
subscription_id: sub_id(2),
group: "item2".to_owned(),
..spec_subscribe(None)
};
let lines = vec![
first.encode_parameters(TransportKind::HttpBatched)?,
second.encode_parameters(TransportKind::HttpBatched)?,
];
assert_eq!(
Subscribe::http_target_with_session(CONTROL_SESSION),
"/lightstreamer/control.txt?LS_protocol=TLCP-2.5.0\
&LS_session=Sd9fce58fb5dbbebfT2255126"
);
assert_eq!(
encode_http_batch(Subscribe::NAME, &lines)?,
"LS_reqId=1&LS_op=add&LS_subId=1&LS_group=item1&LS_schema=last_price\
&LS_data_adapter=QUOTE_ADAPTER&LS_mode=MERGE\r\n\
LS_reqId=2&LS_op=add&LS_subId=2&LS_group=item2&LS_schema=last_price\
&LS_data_adapter=QUOTE_ADAPTER&LS_mode=MERGE"
);
Ok(())
}
#[test]
fn test_encode_batch_rejects_an_empty_batch() {
assert!(encode_ws_batch(Subscribe::NAME, &[]).is_err());
assert!(encode_http_batch(Subscribe::NAME, &[]).is_err());
}
#[test]
fn test_subscribe_http_without_session_is_error() {
let request = spec_subscribe(None);
assert!(matches!(
request.http_body(),
Err(ProtocolError::Request {
request: "control",
..
})
));
}
#[test]
fn test_subscribe_all_optional_parameters_encoded() -> Result<(), ProtocolError> {
let request = Subscribe {
selector: Some("sel1".to_owned()),
requested_buffer_size: Some(RequestedBufferSize::Events(sub_id(30))),
requested_max_frequency: Some(RequestedMaxFrequency::Limited(DecimalNumber::try_new(
"2.5",
)?)),
snapshot: Some(Snapshot::On),
ack: Some(false),
..spec_subscribe(None)
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=1&LS_op=add&LS_subId=1&LS_group=item1&LS_schema=last_price\
&LS_data_adapter=QUOTE_ADAPTER&LS_mode=MERGE&LS_selector=sel1\
&LS_requested_buffer_size=30&LS_requested_max_frequency=2.5\
&LS_snapshot=true&LS_ack=false"
);
Ok(())
}
#[test]
fn test_subscribe_unlimited_buffer_size_and_unfiltered_frequency() -> Result<(), ProtocolError>
{
let request = Subscribe {
requested_buffer_size: Some(RequestedBufferSize::Unlimited),
..spec_subscribe(None)
};
assert!(
request
.encode_parameters(TransportKind::WebSocket)?
.contains("LS_requested_buffer_size=unlimited")
);
let request = Subscribe {
requested_max_frequency: Some(RequestedMaxFrequency::Unfiltered),
..spec_subscribe(None)
};
assert!(
request
.encode_parameters(TransportKind::WebSocket)?
.contains("LS_requested_max_frequency=unfiltered")
);
Ok(())
}
#[test]
fn test_subscribe_ack_is_omitted_on_http_and_emitted_on_ws() -> Result<(), ProtocolError> {
let request = Subscribe {
ack: Some(false),
..spec_subscribe(Some(CONTROL_SESSION))
};
assert!(!request.http_body()?.contains("LS_ack"));
assert!(
request
.encode_parameters(TransportKind::WebSocket)?
.contains("LS_ack=false")
);
Ok(())
}
#[test]
fn test_subscribe_snapshot_length_requires_distinct_mode() -> Result<(), ProtocolError> {
let request = Subscribe {
mode: SubscriptionMode::Merge,
snapshot: Some(Snapshot::Length(sub_id(10))),
..spec_subscribe(None)
};
assert!(request.encode_parameters(TransportKind::WebSocket).is_err());
let request = Subscribe {
mode: SubscriptionMode::Distinct,
snapshot: Some(Snapshot::Length(sub_id(10))),
..spec_subscribe(None)
};
assert!(
request
.encode_parameters(TransportKind::WebSocket)?
.contains("LS_snapshot=10")
);
Ok(())
}
#[test]
fn test_subscribe_snapshot_off_is_encoded_explicitly() -> Result<(), ProtocolError> {
let request = Subscribe {
snapshot: Some(Snapshot::Off),
..spec_subscribe(None)
};
assert!(
request
.encode_parameters(TransportKind::WebSocket)?
.contains("LS_snapshot=false")
);
Ok(())
}
#[test]
fn test_subscribe_snapshot_on_with_raw_mode_is_error() {
let request = Subscribe {
mode: SubscriptionMode::Raw,
snapshot: Some(Snapshot::On),
..spec_subscribe(None)
};
assert!(request.encode_parameters(TransportKind::WebSocket).is_err());
}
#[test]
fn test_subscribe_max_frequency_with_raw_mode_is_error() {
let request = Subscribe {
mode: SubscriptionMode::Raw,
requested_max_frequency: Some(RequestedMaxFrequency::Unfiltered),
..spec_subscribe(None)
};
assert!(request.encode_parameters(TransportKind::WebSocket).is_err());
}
#[test]
fn test_subscribe_buffer_size_with_command_mode_is_error() {
let request = Subscribe {
mode: SubscriptionMode::Command,
requested_buffer_size: Some(RequestedBufferSize::Unlimited),
..spec_subscribe(None)
};
assert!(request.encode_parameters(TransportKind::WebSocket).is_err());
}
#[test]
fn test_subscribe_buffer_size_with_unfiltered_frequency_is_error() {
let request = Subscribe {
requested_buffer_size: Some(RequestedBufferSize::Unlimited),
requested_max_frequency: Some(RequestedMaxFrequency::Unfiltered),
..spec_subscribe(None)
};
assert!(request.encode_parameters(TransportKind::WebSocket).is_err());
}
#[test]
fn test_subscribe_escapes_group_and_schema() -> Result<(), ProtocolError> {
let request = Subscribe {
group: "item1&item2".to_owned(),
schema: "f1=f2".to_owned(),
..spec_subscribe(None)
};
let line = request.encode_parameters(TransportKind::WebSocket)?;
let group = required_param(&line, "LS_group");
assert!(!group.contains(RESERVED));
assert!(has_escape(group, "%26"));
let schema = required_param(&line, "LS_schema");
assert!(!schema.contains(RESERVED));
assert!(has_escape(schema, "%3D"));
Ok(())
}
#[test]
fn test_subscription_mode_wire_values() {
assert_eq!(SubscriptionMode::Raw.as_str(), "RAW");
assert_eq!(SubscriptionMode::Merge.as_str(), "MERGE");
assert_eq!(SubscriptionMode::Distinct.as_str(), "DISTINCT");
assert_eq!(SubscriptionMode::Command.as_str(), "COMMAND");
}
#[test]
fn test_control_operation_wire_values() {
assert_eq!(ControlOperation::Add.as_str(), "add");
assert_eq!(ControlOperation::Delete.as_str(), "delete");
assert_eq!(ControlOperation::Reconf.as_str(), "reconf");
assert_eq!(ControlOperation::Constrain.as_str(), "constrain");
assert_eq!(ControlOperation::ForceRebind.as_str(), "force_rebind");
assert_eq!(ControlOperation::Destroy.as_str(), "destroy");
}
#[test]
fn test_unsubscribe_http_body_matches_spec_example_p32() -> Result<(), ProtocolError> {
let request = Unsubscribe {
session: Some(CONTROL_SESSION.to_owned()),
request_id: req_id(2),
subscription_id: sub_id(1),
ack: None,
};
assert_eq!(
request.http_body()?,
"LS_session=Sd9fce58fb5dbbebfT2255126&LS_reqId=2&LS_op=delete&LS_subId=1"
);
Ok(())
}
#[test]
fn test_unsubscribe_ws_message_matches_spec_example_p32() -> Result<(), ProtocolError> {
let request = Unsubscribe {
session: None,
request_id: req_id(2),
subscription_id: sub_id(1),
ack: None,
};
assert_eq!(
request.ws_message()?,
"control\r\nLS_reqId=2&LS_op=delete&LS_subId=1"
);
Ok(())
}
#[test]
fn test_unsubscribe_ack_only_on_ws() -> Result<(), ProtocolError> {
let request = Unsubscribe {
session: Some(CONTROL_SESSION.to_owned()),
request_id: req_id(2),
subscription_id: sub_id(1),
ack: Some(false),
};
assert!(!request.http_body()?.contains("LS_ack"));
assert!(
request
.encode_parameters(TransportKind::WebSocket)?
.ends_with("&LS_ack=false")
);
Ok(())
}
#[test]
fn test_reconf_http_body_matches_spec_example_p33() -> Result<(), ProtocolError> {
let request = ReconfigureSubscription {
session: Some(CONTROL_SESSION.to_owned()),
request_id: req_id(3),
subscription_id: sub_id(1),
requested_max_frequency: Some(MaxFrequencyLimit::Limited(DecimalNumber::try_new(
"2.0",
)?)),
};
assert_eq!(
request.http_body()?,
"LS_session=Sd9fce58fb5dbbebfT2255126&LS_reqId=3&LS_op=reconf&LS_subId=1\
&LS_requested_max_frequency=2.0"
);
Ok(())
}
#[test]
fn test_reconf_ws_message_matches_spec_example_p33() -> Result<(), ProtocolError> {
let request = ReconfigureSubscription {
session: None,
request_id: req_id(3),
subscription_id: sub_id(1),
requested_max_frequency: Some(MaxFrequencyLimit::Limited(DecimalNumber::try_new(
"2.0",
)?)),
};
assert_eq!(
request.ws_message()?,
"control\r\nLS_reqId=3&LS_op=reconf&LS_subId=1&LS_requested_max_frequency=2.0"
);
Ok(())
}
#[test]
fn test_reconf_unlimited_relieves_the_limit_and_none_omits_it() -> Result<(), ProtocolError> {
let request = ReconfigureSubscription {
session: None,
request_id: req_id(3),
subscription_id: sub_id(1),
requested_max_frequency: Some(MaxFrequencyLimit::Unlimited),
};
assert!(
request
.encode_parameters(TransportKind::WebSocket)?
.ends_with("&LS_requested_max_frequency=unlimited")
);
let request = ReconfigureSubscription {
requested_max_frequency: None,
..request
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=3&LS_op=reconf&LS_subId=1"
);
Ok(())
}
#[test]
fn test_constrain_http_body_matches_spec_example_p34() -> Result<(), ProtocolError> {
let request = ConstrainSession {
session: Some(CONTROL_SESSION.to_owned()),
request_id: req_id(4),
requested_max_bandwidth: Some(MaxBandwidth::Limited(DecimalNumber::try_new("50.0")?)),
};
assert_eq!(
request.http_body()?,
"LS_session=Sd9fce58fb5dbbebfT2255126&LS_reqId=4&LS_op=constrain\
&LS_requested_max_bandwidth=50.0"
);
Ok(())
}
#[test]
fn test_constrain_ws_message_matches_spec_example_p34() -> Result<(), ProtocolError> {
let request = ConstrainSession {
session: None,
request_id: req_id(4),
requested_max_bandwidth: Some(MaxBandwidth::Limited(DecimalNumber::try_new("50.0")?)),
};
assert_eq!(
request.ws_message()?,
"control\r\nLS_reqId=4&LS_op=constrain&LS_requested_max_bandwidth=50.0"
);
Ok(())
}
#[test]
fn test_constrain_unlimited_and_omitted_bandwidth() -> Result<(), ProtocolError> {
let request = ConstrainSession {
session: None,
request_id: req_id(4),
requested_max_bandwidth: Some(MaxBandwidth::Unlimited),
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=4&LS_op=constrain&LS_requested_max_bandwidth=unlimited"
);
let request = ConstrainSession {
requested_max_bandwidth: None,
..request
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=4&LS_op=constrain"
);
Ok(())
}
#[test]
fn test_force_rebind_http_body_matches_spec_example_p35() -> Result<(), ProtocolError> {
let request = ForceRebind {
session: Some(CONTROL_SESSION.to_owned()),
request_id: req_id(5),
polling_millis: None,
close_socket: None,
};
assert_eq!(
request.http_body()?,
"LS_session=Sd9fce58fb5dbbebfT2255126&LS_reqId=5&LS_op=force_rebind"
);
Ok(())
}
#[test]
fn test_force_rebind_ws_message_matches_spec_example_p35() -> Result<(), ProtocolError> {
let request = ForceRebind {
session: None,
request_id: req_id(5),
polling_millis: None,
close_socket: None,
};
assert_eq!(
request.ws_message()?,
"control\r\nLS_reqId=5&LS_op=force_rebind"
);
Ok(())
}
#[test]
fn test_force_rebind_optional_parameters() -> Result<(), ProtocolError> {
let request = ForceRebind {
session: None,
request_id: req_id(5),
polling_millis: Some(2000),
close_socket: Some(true),
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=5&LS_op=force_rebind&LS_polling_millis=2000&LS_close_socket=true"
);
Ok(())
}
#[test]
fn test_destroy_http_body_matches_spec_example_p36() -> Result<(), ProtocolError> {
let request = DestroySession {
session: Some(CONTROL_SESSION.to_owned()),
request_id: req_id(6),
cause: None,
close_socket: None,
};
assert_eq!(
request.http_body()?,
"LS_session=Sd9fce58fb5dbbebfT2255126&LS_reqId=6&LS_op=destroy"
);
Ok(())
}
#[test]
fn test_destroy_ws_message_matches_spec_example_p36() -> Result<(), ProtocolError> {
let request = DestroySession {
session: None,
request_id: req_id(6),
cause: None,
close_socket: None,
};
assert_eq!(request.ws_message()?, "control\r\nLS_reqId=6&LS_op=destroy");
Ok(())
}
#[test]
fn test_destroy_cause_code_and_message() -> Result<(), ProtocolError> {
let request = DestroySession {
session: None,
request_id: req_id(6),
cause: Some(DestroyCause {
code: CauseCode::try_new(-7)?,
message: Some(CauseMessage::try_new("bye")?),
}),
close_socket: Some(true),
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=6&LS_op=destroy&LS_cause_code=-7&LS_cause_message=bye\
&LS_close_socket=true"
);
Ok(())
}
#[test]
fn test_destroy_cause_code_without_message_is_legal() -> Result<(), ProtocolError> {
let request = DestroySession {
session: None,
request_id: req_id(6),
cause: Some(DestroyCause {
code: CauseCode::try_new(0)?,
message: None,
}),
close_socket: None,
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=6&LS_op=destroy&LS_cause_code=0"
);
Ok(())
}
#[test]
fn test_cause_code_rejects_a_positive_code() {
assert!(CauseCode::try_new(1).is_err());
assert!(CauseCode::try_new(0).is_ok());
assert!(CauseCode::try_new(-31).is_ok());
}
#[test]
fn test_cause_message_rejects_multiline_text() {
assert!(CauseMessage::try_new("one\r\ntwo").is_err());
assert!(CauseMessage::try_new("one\ntwo").is_err());
assert!(CauseMessage::try_new("one two").is_ok());
}
#[test]
fn test_destroy_escapes_the_cause_message() -> Result<(), ProtocolError> {
let request = DestroySession {
session: None,
request_id: req_id(6),
cause: Some(DestroyCause {
code: CauseCode::try_new(0)?,
message: Some(CauseMessage::try_new("a=b&c")?),
}),
close_socket: None,
};
let line = request.encode_parameters(TransportKind::WebSocket)?;
let message = required_param(&line, "LS_cause_message");
assert!(!message.contains(RESERVED));
assert!(has_escape(message, "%3D"));
assert!(has_escape(message, "%26"));
Ok(())
}
fn spec_message(session: Option<&str>) -> MessageSend {
MessageSend {
session: session.map(str::to_owned),
request_id: req_id(11),
message: "Hello".to_owned(),
sequence: Some(match SequenceName::try_new("CHAT") {
Ok(name) => name,
Err(error) => panic!("`CHAT` is a valid sequence name: {error}"),
}),
msg_prog: NonZeroU32::new(1),
max_wait_millis: None,
ack: None,
outcome: None,
}
}
#[test]
fn test_message_send_http_body_matches_spec_example_p43() -> Result<(), ProtocolError> {
assert_eq!(
spec_message(Some(CONTROL_SESSION)).http_body()?,
"LS_session=Sd9fce58fb5dbbebfT2255126&LS_reqId=11&LS_sequence=CHAT\
&LS_msg_prog=1&LS_message=Hello"
);
Ok(())
}
#[test]
fn test_message_send_ws_message_matches_spec_example_p43() -> Result<(), ProtocolError> {
assert_eq!(
spec_message(None).ws_message()?,
"msg\r\nLS_reqId=11&LS_sequence=CHAT&LS_msg_prog=1&LS_message=Hello"
);
Ok(())
}
#[test]
fn test_message_send_carries_no_op_parameter() -> Result<(), ProtocolError> {
assert!(!spec_message(None).ws_message()?.contains("LS_op="));
assert_eq!(MessageSend::PATH, "/lightstreamer/msg.txt");
Ok(())
}
#[test]
fn test_message_send_requires_msg_prog_with_a_sequence() {
let request = MessageSend {
msg_prog: None,
..spec_message(None)
};
assert!(matches!(
request.encode_parameters(TransportKind::WebSocket),
Err(ProtocolError::Request { request: "msg", .. })
));
}
#[test]
fn test_message_send_requires_msg_prog_when_outcome_is_requested() {
let request = MessageSend {
sequence: None,
msg_prog: None,
..spec_message(None)
};
assert!(request.encode_parameters(TransportKind::WebSocket).is_err());
let request = MessageSend {
outcome: Some(true),
..request
};
assert!(request.encode_parameters(TransportKind::WebSocket).is_err());
}
#[test]
fn test_message_send_fire_and_forget_needs_no_msg_prog() -> Result<(), ProtocolError> {
let request = MessageSend {
sequence: None,
msg_prog: None,
ack: Some(false),
outcome: Some(false),
..spec_message(None)
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=11&LS_ack=false&LS_outcome=false&LS_message=Hello"
);
Ok(())
}
#[test]
fn test_message_send_max_wait_and_ack_and_outcome() -> Result<(), ProtocolError> {
let request = MessageSend {
max_wait_millis: Some(1500),
ack: Some(true),
outcome: Some(true),
..spec_message(None)
};
assert_eq!(
request.encode_parameters(TransportKind::WebSocket)?,
"LS_reqId=11&LS_sequence=CHAT&LS_msg_prog=1&LS_max_wait=1500\
&LS_ack=true&LS_outcome=true&LS_message=Hello"
);
Ok(())
}
#[test]
fn test_message_send_max_wait_without_a_sequence_is_error() {
let request = MessageSend {
sequence: None,
max_wait_millis: Some(1500),
outcome: Some(false),
ack: Some(false),
..spec_message(None)
};
assert!(request.encode_parameters(TransportKind::WebSocket).is_err());
}
#[test]
fn test_message_send_escapes_the_payload() -> Result<(), ProtocolError> {
let request = MessageSend {
message: "a=1&b=2\r\n100%".to_owned(),
..spec_message(None)
};
let line = request.encode_parameters(TransportKind::WebSocket)?;
let message = required_param(&line, "LS_message");
assert!(!message.contains(RESERVED));
for escape in ["%3D", "%26", "%0D", "%0A", "%25"] {
assert!(has_escape(message, escape), "missing {escape}");
}
assert!(!line.contains('\r'));
assert!(!line.contains('\n'));
Ok(())
}
#[test]
fn test_message_send_http_without_session_is_error() {
assert!(spec_message(None).http_body().is_err());
}
#[test]
fn test_sequence_name_rejects_the_reserved_identifier() {
assert!(SequenceName::try_new("UNORDERED_MESSAGES").is_err());
assert!(SequenceName::try_new("CHAT_1").is_ok());
}
#[test]
fn test_sequence_name_rejects_invalid_characters_and_empty() {
assert!(SequenceName::try_new("").is_err());
assert!(SequenceName::try_new("chat room").is_err());
assert!(SequenceName::try_new("chat-room").is_err());
}
#[test]
fn test_heartbeat_http_body_matches_spec_example_p46() -> Result<(), ProtocolError> {
let request = Heartbeat {
session: Some(CONTROL_SESSION.to_owned()),
};
assert_eq!(request.http_body()?, "LS_session=Sd9fce58fb5dbbebfT2255126");
assert_eq!(
Heartbeat::http_target(),
"/lightstreamer/heartbeat.txt?LS_protocol=TLCP-2.5.0"
);
Ok(())
}
#[test]
fn test_heartbeat_ws_message_matches_spec_example_p46() -> Result<(), ProtocolError> {
let request = Heartbeat {
session: Some(CONTROL_SESSION.to_owned()),
};
assert_eq!(
request.ws_message()?,
"heartbeat\r\nLS_session=Sd9fce58fb5dbbebfT2255126"
);
Ok(())
}
#[test]
fn test_heartbeat_ws_without_parameters_keeps_the_empty_parameter_line()
-> Result<(), ProtocolError> {
assert_eq!(Heartbeat::default().ws_message()?, "heartbeat\r\n");
Ok(())
}
#[test]
fn test_heartbeat_carries_no_req_id_and_no_op() -> Result<(), ProtocolError> {
let message = Heartbeat {
session: Some(CONTROL_SESSION.to_owned()),
}
.ws_message()?;
assert!(!message.contains("LS_reqId"));
assert!(!message.contains("LS_op"));
Ok(())
}
#[test]
fn test_heartbeat_http_without_session_is_error() {
assert!(Heartbeat::default().http_body().is_err());
}
#[test]
fn test_wsok_message_matches_spec_example_p47() {
assert_eq!(WsOk::ws_message(), "wsok");
assert!(!WsOk::ws_message().contains('\r'));
}
#[test]
fn test_request_id_rejects_empty_and_non_alphanumeric() -> Result<(), ProtocolError> {
assert!(RequestId::try_new("").is_err());
assert!(RequestId::try_new("1-2").is_err());
assert!(RequestId::try_new("req 1").is_err());
assert_eq!(RequestId::try_new("abc123")?.as_str(), "abc123");
assert_eq!(RequestId::from_progressive(42).as_str(), "42");
Ok(())
}
#[test]
fn test_decimal_number_accepts_the_spec_example_forms() -> Result<(), ProtocolError> {
assert_eq!(DecimalNumber::try_new("2.0")?.as_str(), "2.0");
assert_eq!(DecimalNumber::try_new("50.0")?.as_str(), "50.0");
assert!(DecimalNumber::try_new("50").is_ok());
Ok(())
}
#[test]
fn test_decimal_number_rejects_non_decimal_forms() {
for bad in [
"", ".", ".5", "5.", "1,5", "-1", "+1", "1e3", "1.2.3", "abc",
] {
assert!(
DecimalNumber::try_new(bad).is_err(),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_protocol_and_websocket_constants() {
assert_eq!(PROTOCOL_VERSION, "TLCP-2.5.0");
assert_eq!(WS_SUBPROTOCOL, "TLCP-2.5.0.lightstreamer.com");
assert_eq!(WS_PATH, "/lightstreamer");
assert_eq!(CRLF, "\r\n");
}
#[test]
fn test_every_http_path_matches_the_quick_index() {
assert_eq!(CreateSession::PATH, "/lightstreamer/create_session.txt");
assert_eq!(BindSession::PATH, "/lightstreamer/bind_session.txt");
assert_eq!(Subscribe::PATH, "/lightstreamer/control.txt");
assert_eq!(Unsubscribe::PATH, "/lightstreamer/control.txt");
assert_eq!(ReconfigureSubscription::PATH, "/lightstreamer/control.txt");
assert_eq!(ConstrainSession::PATH, "/lightstreamer/control.txt");
assert_eq!(ForceRebind::PATH, "/lightstreamer/control.txt");
assert_eq!(DestroySession::PATH, "/lightstreamer/control.txt");
assert_eq!(MessageSend::PATH, "/lightstreamer/msg.txt");
assert_eq!(Heartbeat::PATH, "/lightstreamer/heartbeat.txt");
}
#[test]
fn test_every_request_name_matches_the_quick_index() {
assert_eq!(CreateSession::NAME, "create_session");
assert_eq!(BindSession::NAME, "bind_session");
assert_eq!(Subscribe::NAME, "control");
assert_eq!(MessageSend::NAME, "msg");
assert_eq!(Heartbeat::NAME, "heartbeat");
assert_eq!(WsOk::NAME, "wsok");
}
#[test]
fn test_control_operation_constants_per_request_type() {
assert_eq!(Subscribe::OPERATION, ControlOperation::Add);
assert_eq!(Unsubscribe::OPERATION, ControlOperation::Delete);
assert_eq!(ReconfigureSubscription::OPERATION, ControlOperation::Reconf);
assert_eq!(ConstrainSession::OPERATION, ControlOperation::Constrain);
assert_eq!(ForceRebind::OPERATION, ControlOperation::ForceRebind);
assert_eq!(DestroySession::OPERATION, ControlOperation::Destroy);
}
}