use batpak::coordinate::Coordinate;
use serde::ser::SerializeStruct;
use serde::Serialize;
use crate::error::HostError;
use crate::schema::SchemaRole;
const MAX_SUBSCRIPTION_ID_BYTES: usize = 128;
const MAX_PROJECTION_ID_BYTES: usize = 128;
pub const SUBSCRIPTION_WIRE_REQUIRES: &str = "NETBAT/2-streaming";
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct SubscriptionId(String);
impl SubscriptionId {
pub fn new(id: impl Into<String>) -> Result<Self, HostError> {
let id = id.into();
validate_subscription_id(&id).map_err(|detail| HostError::SubscriptionInvalidId {
id: id.clone(),
detail: detail.to_owned(),
})?;
Ok(Self(id))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SubscriptionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct EventCategory(u8);
impl EventCategory {
pub fn new(category: u8) -> Result<Self, HostError> {
if category >= 16 {
return Err(HostError::SubscriptionReservedCategory { category });
}
if category == 0 {
return Err(HostError::SubscriptionReservedCategory { category });
}
if category == 0xD {
return Err(HostError::SubscriptionReservedCategory { category });
}
Ok(Self(category))
}
#[must_use]
pub fn get(self) -> u8 {
self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ProjectionId(String);
impl ProjectionId {
pub fn new(id: impl Into<String>) -> Result<Self, HostError> {
let id = id.into();
validate_component_id(&id, MAX_PROJECTION_ID_BYTES, "projection id").map_err(|detail| {
HostError::SubscriptionInvalidProjectionId {
id: id.clone(),
detail: detail.to_owned(),
}
})?;
Ok(Self(id))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ProjectionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct ReceiptFilter {
pub receipt_kind: String,
}
impl ReceiptFilter {
#[must_use]
pub fn new(receipt_kind: impl Into<String>) -> Self {
Self {
receipt_kind: receipt_kind.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct OperationStatusSelector {
pub operation: String,
}
impl OperationStatusSelector {
#[must_use]
pub fn new(operation: impl Into<String>) -> Self {
Self {
operation: operation.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SubscriptionSource {
EventCategory(EventCategory),
EntityStream(Coordinate),
Projection(ProjectionId),
ReceiptStream(ReceiptFilter),
OperationStatus(OperationStatusSelector),
}
impl SubscriptionSource {
#[must_use]
pub fn required_payload_role(&self) -> SchemaRole {
match self {
Self::EventCategory(_) | Self::EntityStream(_) => SchemaRole::EventPayload,
Self::ReceiptStream(_) => SchemaRole::ReceiptPayload,
Self::Projection(_) | Self::OperationStatus(_) => SchemaRole::SubscriptionPayload,
}
}
}
impl Serialize for SubscriptionSource {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::EventCategory(category) => {
let mut state = serializer.serialize_struct("SubscriptionSource", 2)?;
state.serialize_field("kind", "event-category")?;
state.serialize_field("category", &category.get())?;
state.end()
}
Self::EntityStream(coordinate) => {
let mut state = serializer.serialize_struct("SubscriptionSource", 2)?;
state.serialize_field("kind", "entity-stream")?;
state.serialize_field("coordinate", coordinate)?;
state.end()
}
Self::Projection(projection) => {
let mut state = serializer.serialize_struct("SubscriptionSource", 2)?;
state.serialize_field("kind", "projection")?;
state.serialize_field("projection_id", projection.as_str())?;
state.end()
}
Self::ReceiptStream(filter) => {
let mut state = serializer.serialize_struct("SubscriptionSource", 2)?;
state.serialize_field("kind", "receipt-stream")?;
state.serialize_field("receipt_kind", &filter.receipt_kind)?;
state.end()
}
Self::OperationStatus(selector) => {
let mut state = serializer.serialize_struct("SubscriptionSource", 2)?;
state.serialize_field("kind", "operation-status")?;
state.serialize_field("operation", &selector.operation)?;
state.end()
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SubscriptionDelivery {
CursorAtLeastOnce,
}
impl SubscriptionDelivery {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::CursorAtLeastOnce => "cursor-at-least-once",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum BackpressurePolicy {
BoundedQueue {
capacity: u32,
},
}
impl BackpressurePolicy {
#[must_use]
pub fn kind(self) -> &'static str {
match self {
Self::BoundedQueue { .. } => "bounded-queue",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct SubscriptionDescriptor {
id: SubscriptionId,
source: SubscriptionSource,
payload_schema_ref: String,
delivery: SubscriptionDelivery,
backpressure: BackpressurePolicy,
}
impl SubscriptionDescriptor {
pub fn new(
id: SubscriptionId,
source: SubscriptionSource,
payload_schema_ref: impl Into<String>,
delivery: SubscriptionDelivery,
backpressure: BackpressurePolicy,
) -> Self {
Self {
id,
source,
payload_schema_ref: payload_schema_ref.into(),
delivery,
backpressure,
}
}
#[must_use]
pub fn id(&self) -> &SubscriptionId {
&self.id
}
#[must_use]
pub fn source(&self) -> &SubscriptionSource {
&self.source
}
#[must_use]
pub fn payload_schema_ref(&self) -> &str {
&self.payload_schema_ref
}
#[must_use]
pub fn delivery(&self) -> SubscriptionDelivery {
self.delivery
}
#[must_use]
pub fn backpressure(&self) -> BackpressurePolicy {
self.backpressure
}
#[must_use]
pub fn required_payload_role(&self) -> SchemaRole {
self.source.required_payload_role()
}
}
pub(crate) fn validate_subscription_id(id: &str) -> Result<(), &'static str> {
if id.is_empty() {
return Err("empty subscription id");
}
if id.len() > MAX_SUBSCRIPTION_ID_BYTES {
return Err("subscription id longer than 128 bytes");
}
if id.starts_with('.') || id.ends_with('.') {
return Err("subscription id has a leading or trailing '.'");
}
if id.contains("..") {
return Err("subscription id has a doubled '.'");
}
let Some(dot_v) = id.rfind(".v") else {
return Err("subscription id must contain a .v version suffix");
};
let name = &id[..dot_v];
let version = &id[dot_v + 2..];
if name.is_empty() {
return Err("subscription id name prefix is empty");
}
if !name
.bytes()
.next()
.is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
{
return Err("subscription id must start with [a-z0-9]");
}
validate_component_id(name, MAX_SUBSCRIPTION_ID_BYTES, "subscription id name")?;
if version.is_empty() {
return Err("subscription id missing version digits after .v");
}
let Some(first) = version.chars().next() else {
return Err("subscription id missing version digits after .v");
};
if !first.is_ascii_digit() || first == '0' {
return Err("subscription id version must start with 1-9");
}
if !version.chars().all(|c| c.is_ascii_digit()) {
return Err("subscription id version must be digits only");
}
Ok(())
}
fn validate_component_id(id: &str, max_len: usize, label: &str) -> Result<(), &'static str> {
if id.len() > max_len {
return Err("component id longer than maximum");
}
if !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
{
return Err("component id has characters outside [a-z0-9._-]");
}
let _ = label;
Ok(())
}
#[cfg(test)]
#[path = "subscription_tests.rs"]
mod subscription_tests;