use crate::ids::{EventId, RequestId, RoomId, TxnId, UserId};
use serde::{Deserialize, Serialize};
use std::fmt;
pub(crate) fn percent_encode_segment(segment: &str) -> String {
let mut out = String::with_capacity(segment.len());
for byte in segment.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char);
}
_ => {
out.push('%');
out.push_str(&format!("{byte:02X}"));
}
}
}
out
}
pub(crate) fn percent_decode_segment(segment: &str) -> Option<String> {
let bytes = segment.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
let hex = segment.get(i + 1..i + 3)?;
let byte = u8::from_str_radix(hex, 16).ok()?;
out.push(byte);
i += 3;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8(out).ok()
}
fn substitute(template: &str, params: &[(&str, &str)]) -> String {
let mut path = template.to_string();
for (placeholder, value) in params {
path = path.replace(placeholder, &percent_encode_segment(value));
}
path
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HttpMethod {
Get,
Post,
Put,
}
impl HttpMethod {
pub fn as_str(self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
}
}
}
impl fmt::Display for HttpMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum OutgoingRequestKind {
Sync,
KeysUpload,
KeysQuery,
KeysClaim,
KeysChanges,
SendToDevice,
SigningKeysUpload,
SignaturesUpload,
RoomSend,
RoomRedact,
RoomState,
RoomMessages,
RoomMembers,
CreateRoom,
JoinRoom,
LeaveRoom,
Invite,
Kick,
Typing,
Receipt,
ReadMarkers,
AccountData,
RoomAccountData,
Profile,
UserDirectorySearch,
PublicRooms,
BackupVersion,
BackupKeys,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OutgoingRequest {
pub id: RequestId,
pub method: HttpMethod,
pub path: String,
pub query: Vec<(String, String)>,
pub body: Option<serde_json::Value>,
pub kind: OutgoingRequestKind,
}
const SYNC_FILTER: &str = r#"{"room":{"include_leave":true}}"#;
impl OutgoingRequest {
pub fn sync(id: RequestId, since: Option<&str>, timeout_ms: Option<u64>) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/sync";
let mut query = vec![("filter".to_string(), SYNC_FILTER.to_string())];
if let Some(since) = since {
query.push(("since".to_string(), since.to_string()));
}
if let Some(timeout_ms) = timeout_ms {
query.push(("timeout".to_string(), timeout_ms.to_string()));
}
Self {
id,
method: HttpMethod::Get,
path: PATH_TEMPLATE.to_string(),
query,
body: None,
kind: OutgoingRequestKind::Sync,
}
}
pub fn keys_upload(id: RequestId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/upload";
Self {
id,
method: HttpMethod::Post,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::KeysUpload,
}
}
pub fn keys_query(id: RequestId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/query";
Self {
id,
method: HttpMethod::Post,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::KeysQuery,
}
}
pub fn keys_claim(id: RequestId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/claim";
Self {
id,
method: HttpMethod::Post,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::KeysClaim,
}
}
pub fn keys_changes(id: RequestId, from: &str, to: &str) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/changes";
Self {
id,
method: HttpMethod::Get,
path: PATH_TEMPLATE.to_string(),
query: vec![("from".to_string(), from.to_string()), ("to".to_string(), to.to_string())],
body: None,
kind: OutgoingRequestKind::KeysChanges,
}
}
pub fn send_to_device(
id: RequestId,
event_type: &str,
txn_id: &TxnId,
body: serde_json::Value,
) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/sendToDevice/{event_type}/{txn_id}";
let path = substitute(
PATH_TEMPLATE,
&[("{event_type}", event_type), ("{txn_id}", txn_id.as_str())],
);
Self {
id,
method: HttpMethod::Put,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::SendToDevice,
}
}
pub fn signing_keys_upload(id: RequestId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/device_signing/upload";
Self {
id,
method: HttpMethod::Post,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::SigningKeysUpload,
}
}
pub fn signatures_upload(id: RequestId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/signatures/upload";
Self {
id,
method: HttpMethod::Post,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::SignaturesUpload,
}
}
pub fn room_send(
id: RequestId,
room_id: &RoomId,
event_type: &str,
txn_id: &TxnId,
body: serde_json::Value,
) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/send/{event_type}/{txn_id}";
let path = substitute(
PATH_TEMPLATE,
&[
("{room_id}", room_id.as_str()),
("{event_type}", event_type),
("{txn_id}", txn_id.as_str()),
],
);
Self {
id,
method: HttpMethod::Put,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::RoomSend,
}
}
pub fn room_redact(
id: RequestId,
room_id: &RoomId,
event_id: &EventId,
txn_id: &TxnId,
body: serde_json::Value,
) -> Self {
const PATH_TEMPLATE: &str =
"/_matrix/client/v3/rooms/{room_id}/redact/{event_id}/{txn_id}";
let path = substitute(
PATH_TEMPLATE,
&[
("{room_id}", room_id.as_str()),
("{event_id}", event_id.as_str()),
("{txn_id}", txn_id.as_str()),
],
);
Self {
id,
method: HttpMethod::Put,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::RoomRedact,
}
}
pub fn room_state(
id: RequestId,
room_id: &RoomId,
event_type: &str,
state_key: &str,
body: serde_json::Value,
) -> Self {
const PATH_TEMPLATE: &str =
"/_matrix/client/v3/rooms/{room_id}/state/{event_type}/{state_key}";
let path = substitute(
PATH_TEMPLATE,
&[
("{room_id}", room_id.as_str()),
("{event_type}", event_type),
("{state_key}", state_key),
],
);
Self {
id,
method: HttpMethod::Put,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::RoomState,
}
}
pub fn room_messages(
id: RequestId,
room_id: &RoomId,
from: &str,
dir: &str,
limit: Option<u32>,
) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/messages";
let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
let mut query = vec![("from".to_string(), from.to_string()), ("dir".to_string(), dir.to_string())];
if let Some(limit) = limit {
query.push(("limit".to_string(), limit.to_string()));
}
Self {
id,
method: HttpMethod::Get,
path,
query,
body: None,
kind: OutgoingRequestKind::RoomMessages,
}
}
pub fn room_messages_latest(id: RequestId, room_id: &RoomId, limit: u32) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/messages";
let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
Self {
id,
method: HttpMethod::Get,
path,
query: vec![
("dir".to_string(), "b".to_string()),
("limit".to_string(), limit.to_string()),
],
body: None,
kind: OutgoingRequestKind::RoomMessages,
}
}
pub fn room_members(id: RequestId, room_id: &RoomId) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/members";
let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
Self {
id,
method: HttpMethod::Get,
path,
query: Vec::new(),
body: None,
kind: OutgoingRequestKind::RoomMembers,
}
}
pub fn create_room(id: RequestId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/createRoom";
Self {
id,
method: HttpMethod::Post,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::CreateRoom,
}
}
pub fn join_room(id: RequestId, room_id: &str) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/join";
let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id)]);
Self {
id,
method: HttpMethod::Post,
path,
query: Vec::new(),
body: None,
kind: OutgoingRequestKind::JoinRoom,
}
}
pub fn leave_room(id: RequestId, room_id: &RoomId) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/leave";
let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
Self {
id,
method: HttpMethod::Post,
path,
query: Vec::new(),
body: None,
kind: OutgoingRequestKind::LeaveRoom,
}
}
pub fn invite(id: RequestId, room_id: &RoomId, user_id: &UserId) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/invite";
let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
Self {
id,
method: HttpMethod::Post,
path,
query: Vec::new(),
body: Some(serde_json::json!({ "user_id": user_id.as_str() })),
kind: OutgoingRequestKind::Invite,
}
}
pub fn kick(id: RequestId, room_id: &RoomId, user_id: &UserId, reason: Option<&str>) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/kick";
let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
let mut body = serde_json::json!({ "user_id": user_id.as_str() });
if let Some(reason) = reason {
body["reason"] = serde_json::Value::String(reason.to_string());
}
Self {
id,
method: HttpMethod::Post,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::Kick,
}
}
pub fn typing(
id: RequestId,
room_id: &RoomId,
user_id: &UserId,
typing: bool,
timeout_ms: Option<u64>,
) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/typing/{user_id}";
let path = substitute(
PATH_TEMPLATE,
&[("{room_id}", room_id.as_str()), ("{user_id}", user_id.as_str())],
);
let mut body = serde_json::json!({ "typing": typing });
if let Some(timeout_ms) = timeout_ms {
body["timeout"] = serde_json::Value::from(timeout_ms);
}
Self {
id,
method: HttpMethod::Put,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::Typing,
}
}
pub fn receipt(id: RequestId, room_id: &RoomId, receipt_type: &str, event_id: &EventId) -> Self {
const PATH_TEMPLATE: &str =
"/_matrix/client/v3/rooms/{room_id}/receipt/{receipt_type}/{event_id}";
let path = substitute(
PATH_TEMPLATE,
&[
("{room_id}", room_id.as_str()),
("{receipt_type}", receipt_type),
("{event_id}", event_id.as_str()),
],
);
Self {
id,
method: HttpMethod::Post,
path,
query: Vec::new(),
body: Some(serde_json::json!({})),
kind: OutgoingRequestKind::Receipt,
}
}
pub fn read_markers(id: RequestId, room_id: &RoomId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/read_markers";
let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
Self {
id,
method: HttpMethod::Post,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::ReadMarkers,
}
}
pub fn account_data(
id: RequestId,
user_id: &UserId,
event_type: &str,
body: serde_json::Value,
) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/user/{user_id}/account_data/{event_type}";
let path = substitute(
PATH_TEMPLATE,
&[("{user_id}", user_id.as_str()), ("{event_type}", event_type)],
);
Self {
id,
method: HttpMethod::Put,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::AccountData,
}
}
pub fn room_account_data(
id: RequestId,
user_id: &UserId,
room_id: &RoomId,
event_type: &str,
body: serde_json::Value,
) -> Self {
const PATH_TEMPLATE: &str =
"/_matrix/client/v3/user/{user_id}/rooms/{room_id}/account_data/{event_type}";
let path = substitute(
PATH_TEMPLATE,
&[
("{user_id}", user_id.as_str()),
("{room_id}", room_id.as_str()),
("{event_type}", event_type),
],
);
Self {
id,
method: HttpMethod::Put,
path,
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::RoomAccountData,
}
}
pub fn profile(id: RequestId, user_id: &UserId) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/profile/{user_id}";
let path = substitute(PATH_TEMPLATE, &[("{user_id}", user_id.as_str())]);
Self {
id,
method: HttpMethod::Get,
path,
query: Vec::new(),
body: None,
kind: OutgoingRequestKind::Profile,
}
}
pub fn user_directory_search(id: RequestId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/user_directory/search";
Self {
id,
method: HttpMethod::Post,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::UserDirectorySearch,
}
}
pub fn public_rooms(id: RequestId, body: serde_json::Value) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/publicRooms";
Self {
id,
method: HttpMethod::Post,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: Some(body),
kind: OutgoingRequestKind::PublicRooms,
}
}
pub fn backup_version(id: RequestId) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/room_keys/version";
Self {
id,
method: HttpMethod::Get,
path: PATH_TEMPLATE.to_string(),
query: Vec::new(),
body: None,
kind: OutgoingRequestKind::BackupVersion,
}
}
pub fn backup_keys(id: RequestId, room_id: &RoomId, session_id: &str, version: &str) -> Self {
const PATH_TEMPLATE: &str = "/_matrix/client/v3/room_keys/keys/{room_id}/{session_id}";
let path = substitute(
PATH_TEMPLATE,
&[("{room_id}", room_id.as_str()), ("{session_id}", session_id)],
);
Self {
id,
method: HttpMethod::Get,
path,
query: vec![("version".to_string(), version.to_string())],
body: None,
kind: OutgoingRequestKind::BackupKeys,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpResponseDescriptor {
pub status: u16,
pub body: Vec<u8>,
}
impl HttpResponseDescriptor {
pub fn matrix_error(&self) -> Option<(String, String)> {
let value: serde_json::Value = serde_json::from_slice(&self.body).ok()?;
let errcode = value.get("errcode")?.as_str()?.to_string();
let error = value
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
Some((errcode, error))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id() -> RequestId {
RequestId::next(0)
}
#[test]
fn percent_encode_segment_escapes_matrix_sigils() {
assert_eq!(percent_encode_segment("!abc:example.org"), "%21abc%3Aexample.org");
assert_eq!(percent_encode_segment("safe-._~123"), "safe-._~123");
}
#[test]
fn percent_decode_segment_reverses_percent_encode_segment() {
for raw in ["!abc:example.org", "safe-._~123", "curve/25519+key=", ""] {
let encoded = percent_encode_segment(raw);
assert_eq!(percent_decode_segment(&encoded).as_deref(), Some(raw));
}
}
#[test]
fn percent_decode_segment_rejects_a_malformed_escape() {
assert_eq!(percent_decode_segment("%2"), None);
assert_eq!(percent_decode_segment("%zz"), None);
}
#[test]
fn room_send_percent_encodes_the_room_id_in_the_path() {
let room_id = RoomId::parse("!abc:example.org").expect("valid room id");
let txn_id = TxnId::new(0);
let req = OutgoingRequest::room_send(
id(),
&room_id,
"m.room.message",
&txn_id,
serde_json::json!({ "body": "hi" }),
);
assert_eq!(
req.path,
format!(
"/_matrix/client/v3/rooms/%21abc%3Aexample.org/send/m.room.message/{}",
txn_id.as_str()
)
);
assert_eq!(req.method, HttpMethod::Put);
assert_eq!(req.kind, OutgoingRequestKind::RoomSend);
}
#[test]
fn send_to_device_percent_encodes_the_txn_id_in_the_path() {
let txn_id = TxnId::new(0);
let req = OutgoingRequest::send_to_device(
id(),
"m.room.encrypted",
&txn_id,
serde_json::json!({ "messages": {} }),
);
assert!(req.path.starts_with("/_matrix/client/v3/sendToDevice/m.room.encrypted/"));
assert!(!req.path.contains(':'), "txn id's own characters carry no raw ':'");
}
#[test]
fn sync_carries_since_and_timeout_as_query_params_only_when_present() {
let filter = ("filter".to_string(), SYNC_FILTER.to_string());
let bare = OutgoingRequest::sync(id(), None, None);
assert_eq!(bare.query, vec![filter.clone()]);
let full = OutgoingRequest::sync(id(), Some("s123"), Some(30_000));
assert_eq!(
full.query,
vec![filter, ("since".to_string(), "s123".to_string()), ("timeout".to_string(), "30000".to_string())]
);
}
#[test]
fn sync_filter_asks_for_left_rooms() {
let parsed: serde_json::Value = serde_json::from_str(SYNC_FILTER).expect("the filter is JSON");
assert_eq!(parsed["room"]["include_leave"], serde_json::Value::Bool(true));
}
#[test]
fn outgoing_request_kind_round_trips_serde() {
for kind in [
OutgoingRequestKind::Sync,
OutgoingRequestKind::KeysUpload,
OutgoingRequestKind::RoomSend,
OutgoingRequestKind::BackupKeys,
] {
let json = serde_json::to_string(&kind).expect("serialize");
let back: OutgoingRequestKind = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, kind);
}
}
#[test]
fn outgoing_request_round_trips_serde() {
let room_id = RoomId::parse("!abc:example.org").expect("valid room id");
let req = OutgoingRequest::room_members(id(), &room_id);
let json = serde_json::to_string(&req).expect("serialize");
let back: OutgoingRequest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, req);
}
#[test]
fn matrix_error_parses_errcode_and_error() {
let resp = HttpResponseDescriptor {
status: 403,
body: br#"{"errcode":"M_FORBIDDEN","error":"Guest access is forbidden"}"#.to_vec(),
};
assert_eq!(
resp.matrix_error(),
Some(("M_FORBIDDEN".to_string(), "Guest access is forbidden".to_string()))
);
}
#[test]
fn matrix_error_defaults_a_missing_error_field_to_empty() {
let resp = HttpResponseDescriptor {
status: 429,
body: br#"{"errcode":"M_LIMIT_EXCEEDED"}"#.to_vec(),
};
assert_eq!(resp.matrix_error(), Some(("M_LIMIT_EXCEEDED".to_string(), String::new())));
}
#[test]
fn matrix_error_is_none_for_a_non_json_body() {
let resp = HttpResponseDescriptor { status: 200, body: b"not json".to_vec() };
assert_eq!(resp.matrix_error(), None);
}
#[test]
fn matrix_error_is_none_when_errcode_is_absent() {
let resp = HttpResponseDescriptor {
status: 200,
body: br#"{"next_batch":"s1"}"#.to_vec(),
};
assert_eq!(resp.matrix_error(), None);
}
}