87296632bc
req: sync/004
369 lines
11 KiB
Rust
369 lines
11 KiB
Rust
use hemx_core::{Effect, EffectBatch, IntoEffect};
|
|
use serde::{de, Deserialize, Deserializer, Serialize};
|
|
use std::{error::Error, fmt};
|
|
|
|
pub const PATCH_SCHEMA_VERSION: u16 = 1;
|
|
pub const PATCH_EVENT: &str = "hemx:sync-patch";
|
|
const INTERACTION_ID: &str = "$hemx-interaction";
|
|
pub const BROWSER_RUNTIME: &str = include_str!("../runtime/hemx-sync.js");
|
|
|
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
|
pub struct Channel(String);
|
|
|
|
impl Channel {
|
|
pub fn new(value: impl Into<String>) -> Result<Self, ChannelError> {
|
|
let value = value.into();
|
|
if value.is_empty() {
|
|
return Err(ChannelError::Empty);
|
|
}
|
|
if value.len() > 128 {
|
|
return Err(ChannelError::TooLong);
|
|
}
|
|
if !value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-' | b'.'))
|
|
{
|
|
return Err(ChannelError::InvalidCharacter);
|
|
}
|
|
Ok(Self(value))
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ChannelError {
|
|
Empty,
|
|
TooLong,
|
|
InvalidCharacter,
|
|
}
|
|
|
|
impl fmt::Display for ChannelError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Empty => formatter.write_str("sync channel must not be empty"),
|
|
Self::TooLong => formatter.write_str("sync channel is too long"),
|
|
Self::InvalidCharacter => {
|
|
formatter.write_str("sync channel contains an invalid character")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for ChannelError {}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct Broadcast {
|
|
channel: Channel,
|
|
effect_batch: EffectBatch,
|
|
}
|
|
|
|
impl Broadcast {
|
|
pub fn channel(&self) -> &Channel {
|
|
&self.channel
|
|
}
|
|
|
|
pub fn effect_batch(&self) -> &EffectBatch {
|
|
&self.effect_batch
|
|
}
|
|
|
|
pub fn into_parts(self) -> (Channel, EffectBatch) {
|
|
(self.channel, self.effect_batch)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum PatchValue {
|
|
Boolean(bool),
|
|
Integer(i64),
|
|
String(String),
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FlatPatch {
|
|
// req: sync/003
|
|
schema_version: u16,
|
|
idempotency_key: String,
|
|
operation_id: String,
|
|
key: String,
|
|
value: PatchValue,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct FlatPatchWire {
|
|
schema_version: u16,
|
|
idempotency_key: String,
|
|
operation_id: String,
|
|
key: String,
|
|
value: PatchValue,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for FlatPatch {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
let wire = FlatPatchWire::deserialize(deserializer)?;
|
|
let patch = Self {
|
|
schema_version: wire.schema_version,
|
|
idempotency_key: wire.idempotency_key,
|
|
operation_id: wire.operation_id,
|
|
key: wire.key,
|
|
value: wire.value,
|
|
};
|
|
patch.validate().map_err(de::Error::custom)?;
|
|
Ok(patch)
|
|
}
|
|
}
|
|
|
|
impl FlatPatch {
|
|
pub fn for_interaction(key: impl Into<String>, value: PatchValue) -> Result<Self, PatchError> {
|
|
let key = key.into();
|
|
validate_key(&key)?;
|
|
validate_value(&value)?;
|
|
Ok(Self {
|
|
schema_version: PATCH_SCHEMA_VERSION,
|
|
idempotency_key: INTERACTION_ID.to_owned(),
|
|
operation_id: INTERACTION_ID.to_owned(),
|
|
key,
|
|
value,
|
|
})
|
|
}
|
|
|
|
pub fn new(
|
|
idempotency_key: impl Into<String>,
|
|
operation_id: impl Into<String>,
|
|
key: impl Into<String>,
|
|
value: PatchValue,
|
|
) -> Result<Self, PatchError> {
|
|
let patch = Self {
|
|
schema_version: PATCH_SCHEMA_VERSION,
|
|
idempotency_key: idempotency_key.into(),
|
|
operation_id: operation_id.into(),
|
|
key: key.into(),
|
|
value,
|
|
};
|
|
patch.validate()?;
|
|
Ok(patch)
|
|
}
|
|
|
|
pub fn payload(&self) -> String {
|
|
let value = match &self.value {
|
|
PatchValue::Boolean(value) => value.to_string(),
|
|
PatchValue::Integer(value) => value.to_string(),
|
|
PatchValue::String(value) => json_string(value),
|
|
};
|
|
format!(
|
|
r#"{{"schemaVersion":{},"idempotencyKey":{},"operationId":{},"key":{},"value":{value}}}"#,
|
|
self.schema_version,
|
|
json_string(&self.idempotency_key),
|
|
json_string(&self.operation_id),
|
|
json_string(&self.key),
|
|
)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), PatchError> {
|
|
if self.schema_version != PATCH_SCHEMA_VERSION {
|
|
return Err(PatchError::SchemaVersion(self.schema_version));
|
|
}
|
|
if self.idempotency_key != INTERACTION_ID || self.operation_id != INTERACTION_ID {
|
|
validate_identifier("idempotency_key", &self.idempotency_key, 128)?;
|
|
validate_identifier("operation_id", &self.operation_id, 128)?;
|
|
}
|
|
validate_key(&self.key)?;
|
|
validate_value(&self.value)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum PatchError {
|
|
Empty(&'static str),
|
|
InvalidCharacter(&'static str),
|
|
TooLong(&'static str),
|
|
ReservedKey,
|
|
SchemaVersion(u16),
|
|
ValueTooLong,
|
|
IntegerOutOfRange,
|
|
}
|
|
|
|
impl fmt::Display for PatchError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Empty(field) => write!(formatter, "{field} must not be empty"),
|
|
Self::InvalidCharacter(field) => {
|
|
write!(formatter, "{field} contains an invalid character")
|
|
}
|
|
Self::TooLong(field) => write!(formatter, "{field} is too long"),
|
|
Self::ReservedKey => formatter.write_str("patch key is reserved"),
|
|
Self::SchemaVersion(version) => {
|
|
write!(formatter, "unsupported patch schema version {version}")
|
|
}
|
|
Self::ValueTooLong => formatter.write_str("patch string value is too long"),
|
|
Self::IntegerOutOfRange => {
|
|
formatter.write_str("patch integer value exceeds JavaScript's safe range")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for PatchError {}
|
|
|
|
fn json_string(value: &str) -> String {
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
let mut encoded = String::with_capacity(value.len() + 2);
|
|
encoded.push('"');
|
|
for character in value.chars() {
|
|
match character {
|
|
'"' => encoded.push_str("\\\""),
|
|
'\\' => encoded.push_str("\\\\"),
|
|
'\n' => encoded.push_str("\\n"),
|
|
'\r' => encoded.push_str("\\r"),
|
|
'\t' => encoded.push_str("\\t"),
|
|
character if character <= '\u{1f}' => {
|
|
let byte = character as u8;
|
|
encoded.push_str("\\u00");
|
|
encoded.push(HEX[(byte >> 4) as usize] as char);
|
|
encoded.push(HEX[(byte & 0x0f) as usize] as char);
|
|
}
|
|
character => encoded.push(character),
|
|
}
|
|
}
|
|
encoded.push('"');
|
|
encoded
|
|
}
|
|
|
|
fn validate_value(value: &PatchValue) -> Result<(), PatchError> {
|
|
match value {
|
|
PatchValue::String(value) if value.len() > 4096 => Err(PatchError::ValueTooLong),
|
|
PatchValue::Integer(value) if value.unsigned_abs() > 9_007_199_254_740_991 => {
|
|
Err(PatchError::IntegerOutOfRange)
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn validate_identifier(field: &'static str, value: &str, maximum: usize) -> Result<(), PatchError> {
|
|
if value.is_empty() {
|
|
return Err(PatchError::Empty(field));
|
|
}
|
|
if value.len() > maximum {
|
|
return Err(PatchError::TooLong(field));
|
|
}
|
|
if !value
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-' | b'.'))
|
|
{
|
|
return Err(PatchError::InvalidCharacter(field));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_key(key: &str) -> Result<(), PatchError> {
|
|
if key.is_empty() {
|
|
return Err(PatchError::Empty("key"));
|
|
}
|
|
if key.len() > 64 {
|
|
return Err(PatchError::TooLong("key"));
|
|
}
|
|
if matches!(
|
|
key,
|
|
"schemaVersion" | "idempotencyKey" | "operationId" | "key" | "value"
|
|
) {
|
|
return Err(PatchError::ReservedKey);
|
|
}
|
|
let mut bytes = key.bytes();
|
|
if !bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic())
|
|
|| !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
|
{
|
|
return Err(PatchError::InvalidCharacter("key"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct SyncEffect(Effect); // req: sync/002
|
|
|
|
impl SyncEffect {
|
|
// req: sync/004
|
|
pub fn broadcast(channel: Channel, effect_batch: EffectBatch) -> Broadcast {
|
|
Broadcast {
|
|
channel,
|
|
effect_batch,
|
|
}
|
|
}
|
|
|
|
pub fn send_patch(patch: FlatPatch) -> Self {
|
|
patch.validate().expect("FlatPatch must remain valid");
|
|
Self(Effect::Emit {
|
|
name: PATCH_EVENT.to_owned(),
|
|
payload: patch.payload(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl IntoEffect for SyncEffect {
|
|
fn append_to(self, ops: &mut Vec<Effect>) {
|
|
self.0.append_to(ops);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn broadcast_preserves_typed_channel_and_ordinary_batch() {
|
|
let channel = Channel::new("board:alpha").unwrap();
|
|
let batch = EffectBatch {
|
|
abi_version: 1,
|
|
fingerprint: hemx_core::BuildFingerprint(7),
|
|
ops: vec![],
|
|
};
|
|
let broadcast = SyncEffect::broadcast(channel.clone(), batch.clone());
|
|
assert_eq!(broadcast.into_parts(), (channel, batch));
|
|
assert_eq!(
|
|
Channel::new("board alpha"),
|
|
Err(ChannelError::InvalidCharacter)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_is_flat_and_rejects_reserved_keys() {
|
|
let patch = FlatPatch::new(
|
|
"actor:1",
|
|
"move-card-to-done",
|
|
"cardColumn",
|
|
PatchValue::String("done".to_owned()),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
patch.payload(),
|
|
r#"{"schemaVersion":1,"idempotencyKey":"actor:1","operationId":"move-card-to-done","key":"cardColumn","value":"done"}"#
|
|
);
|
|
assert_eq!(
|
|
FlatPatch::new("actor:1", "move", "value", PatchValue::Integer(1)),
|
|
Err(PatchError::ReservedKey)
|
|
);
|
|
assert_eq!(
|
|
FlatPatch::new(
|
|
"actor:1",
|
|
"move",
|
|
"rank",
|
|
PatchValue::Integer(9_007_199_254_740_992),
|
|
),
|
|
Err(PatchError::IntegerOutOfRange)
|
|
);
|
|
assert!(serde_json::from_str::<FlatPatch>(
|
|
r#"{"schemaVersion":1,"idempotencyKey":"actor:1","operationId":"move","key":"rank","value":9007199254740992}"#,
|
|
)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("safe range"));
|
|
}
|
|
}
|