Skip to main content

mail4agent_messenger/wire/
requests.rs

1//! [`OutgoingRequest`] / [`HttpResponseDescriptor`] — the data shapes that
2//! cross the network boundary, plus one builder per Matrix Client-Server
3//! API endpoint this crate needs. Every builder's path template lives as a
4//! single `PATH_TEMPLATE` constant scoped to that builder's own function
5//! body — greppable (`grep -n PATH_TEMPLATE`), one hit per endpoint, never
6//! inlined again at a call site (plan §6.8's note, generalized to every
7//! endpoint rather than just the two it called out).
8
9use crate::ids::{EventId, RequestId, RoomId, TxnId, UserId};
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13/// Percent-encodes one path segment. Conservative on purpose: only RFC
14/// 3986's `unreserved` set (`ALPHA / DIGIT / "-" / "." / "_" / "~"`) is
15/// left unescaped; everything else — including Matrix's own sigils (`!`,
16/// `$`, `:`, `@`), which RFC 3986's `pchar` grammar would technically
17/// permit unescaped in a path segment — is escaped. This removes any
18/// ambiguity between "path delimiter" and "literal sigil character" for a
19/// router that naively splits on `/`, `:`, or `!`, exactly the ids this
20/// crate mints into paths.
21pub(crate) fn percent_encode_segment(segment: &str) -> String {
22    let mut out = String::with_capacity(segment.len());
23    for byte in segment.bytes() {
24        match byte {
25            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
26                out.push(byte as char);
27            }
28            _ => {
29                out.push('%');
30                out.push_str(&format!("{byte:02X}"));
31            }
32        }
33    }
34    out
35}
36
37/// Reverses [`percent_encode_segment`]: decodes `%XX` escapes back to their
38/// original byte values and interprets the result as UTF-8. Returns `None`
39/// for a malformed escape (an incomplete or non-hex `%` sequence) or a
40/// decoded byte sequence that is not valid UTF-8. Used by
41/// [`crate::store`]'s record-key parser to recover the dynamic components
42/// (room/user/session/request ids) this module's builders percent-encode
43/// into a storage key.
44pub(crate) fn percent_decode_segment(segment: &str) -> Option<String> {
45    let bytes = segment.as_bytes();
46    let mut out = Vec::with_capacity(bytes.len());
47    let mut i = 0;
48    while i < bytes.len() {
49        match bytes[i] {
50            b'%' => {
51                let hex = segment.get(i + 1..i + 3)?;
52                let byte = u8::from_str_radix(hex, 16).ok()?;
53                out.push(byte);
54                i += 3;
55            }
56            b => {
57                out.push(b);
58                i += 1;
59            }
60        }
61    }
62    String::from_utf8(out).ok()
63}
64
65/// Substitutes every `{placeholder}` token in `template` with the
66/// percent-encoded form of its paired value.
67fn substitute(template: &str, params: &[(&str, &str)]) -> String {
68    let mut path = template.to_string();
69    for (placeholder, value) in params {
70        path = path.replace(placeholder, &percent_encode_segment(value));
71    }
72    path
73}
74
75/// HTTP method for an [`OutgoingRequest`]. The Matrix Client-Server API
76/// only ever uses these three verbs.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
78pub enum HttpMethod {
79    /// `GET`.
80    Get,
81    /// `POST`.
82    Post,
83    /// `PUT`.
84    Put,
85}
86
87impl HttpMethod {
88    /// The method's own uppercase wire form (`"GET"`, `"POST"`, `"PUT"`).
89    pub fn as_str(self) -> &'static str {
90        match self {
91            HttpMethod::Get => "GET",
92            HttpMethod::Post => "POST",
93            HttpMethod::Put => "PUT",
94        }
95    }
96}
97
98impl fmt::Display for HttpMethod {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.write_str(self.as_str())
101    }
102}
103
104/// What kind of Matrix Client-Server API call an [`OutgoingRequest`] is —
105/// a plain tag the core uses to know how to interpret the matching
106/// [`HttpResponseDescriptor`] (e.g. "this one's body is a `SyncResponse`").
107/// The actual path/query/body are already baked into the `OutgoingRequest`
108/// itself; this enum carries no duplicate parameters.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
110pub enum OutgoingRequestKind {
111    /// `GET /sync` — long-poll for new events, device-list changes, and
112    /// one-time-key counts.
113    Sync,
114    /// `POST /keys/upload` — publish device identity keys and one-time
115    /// keys.
116    KeysUpload,
117    /// `POST /keys/query` — fetch other users' device keys.
118    KeysQuery,
119    /// `POST /keys/claim` — claim one-time keys to start new Olm sessions.
120    KeysClaim,
121    /// `GET /keys/changes` — list users whose device lists changed in a
122    /// sync range.
123    KeysChanges,
124    /// `PUT /sendToDevice/{eventType}/{txnId}` — send one or more
125    /// to-device events.
126    SendToDevice,
127    /// `POST /keys/device_signing/upload` — publish cross-signing keys.
128    SigningKeysUpload,
129    /// `POST /keys/signatures/upload` — publish cross-signing signatures.
130    SignaturesUpload,
131    /// `PUT /rooms/{roomId}/send/{eventType}/{txnId}` — send a timeline
132    /// event.
133    RoomSend,
134    /// `PUT /rooms/{roomId}/redact/{eventId}/{txnId}` — redact an event.
135    RoomRedact,
136    /// `PUT /rooms/{roomId}/state/{eventType}/{stateKey}` — set one piece
137    /// of room state.
138    RoomState,
139    /// `GET /rooms/{roomId}/messages` — paginate a room's timeline.
140    RoomMessages,
141    /// `GET /rooms/{roomId}/members` — list a room's members.
142    RoomMembers,
143    /// `POST /createRoom` — create a room.
144    CreateRoom,
145    /// `POST /rooms/{roomId}/join` — join a room by id.
146    JoinRoom,
147    /// `POST /rooms/{roomId}/leave` — leave a room.
148    LeaveRoom,
149    /// `POST /rooms/{roomId}/invite` — invite a user to a room.
150    Invite,
151    /// `POST /rooms/{roomId}/kick` — remove a user from a room.
152    Kick,
153    /// `PUT /rooms/{roomId}/state/{eventType}/{stateKey}` — set a room state event.
154    SetState,
155    /// `PUT /rooms/{roomId}/typing/{userId}` — set/clear a typing
156    /// indicator.
157    Typing,
158    /// `POST /rooms/{roomId}/receipt/{receiptType}/{eventId}` — send a
159    /// read receipt.
160    Receipt,
161    /// `POST /rooms/{roomId}/read_markers` — set the fully-read marker.
162    ReadMarkers,
163    /// `PUT /user/{userId}/account_data/{type}` — set global account data.
164    AccountData,
165    /// `PUT /user/{userId}/rooms/{roomId}/account_data/{type}` — set
166    /// room-scoped account data.
167    RoomAccountData,
168    /// `GET /profile/{userId}` — fetch a user's profile.
169    Profile,
170    /// `POST /user_directory/search` — search the user directory.
171    UserDirectorySearch,
172    /// `POST /publicRooms` — search the public-room directory.
173    PublicRooms,
174    /// `GET /room_keys/version` — fetch the current key backup version.
175    BackupVersion,
176    /// `GET /room_keys/keys/{roomId}/{sessionId}` — fetch one backed-up
177    /// Megolm session.
178    BackupKeys,
179}
180
181/// One HTTP call the core wants a shell to perform. The core never
182/// performs the call itself — see the crate doc's "Contract" section.
183#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
184pub struct OutgoingRequest {
185    /// This crate's own correlation id — the shell passes it back
186    /// unchanged to [`crate::MessengerError`]-surfacing calls (a later
187    /// piece) alongside the response.
188    pub id: RequestId,
189    /// The HTTP method to use.
190    pub method: HttpMethod,
191    /// The request path, already percent-encoded and rooted at
192    /// `/_matrix/client/v3/...` — ready to append to a homeserver base URL
193    /// verbatim.
194    pub path: String,
195    /// Query-string parameters as `(name, value)` pairs, not yet
196    /// percent-encoded (a shell's own HTTP client is expected to encode
197    /// query parameters itself, the same way it encodes any other query
198    /// string it builds).
199    pub query: Vec<(String, String)>,
200    /// The JSON request body, if this call has one.
201    pub body: Option<serde_json::Value>,
202    /// What kind of call this is.
203    pub kind: OutgoingRequestKind,
204}
205
206/// The inline `/sync` filter every sync request carries: `room.include_leave`,
207/// so a room this account left (or was kicked or banned from) arrives in
208/// `rooms.leave` -- the server sends that section only when asked.
209const SYNC_FILTER: &str = r#"{"room":{"include_leave":true}}"#;
210
211impl OutgoingRequest {
212    /// `GET /_matrix/client/v3/sync`. Always carries [`SYNC_FILTER`]: the
213    /// server reports a room this account left, or was kicked or banned from,
214    /// in `rooms.leave` only when the filter asks for it.
215    pub fn sync(id: RequestId, since: Option<&str>, timeout_ms: Option<u64>) -> Self {
216        const PATH_TEMPLATE: &str = "/_matrix/client/v3/sync";
217        let mut query = vec![("filter".to_string(), SYNC_FILTER.to_string())];
218        if let Some(since) = since {
219            query.push(("since".to_string(), since.to_string()));
220        }
221        if let Some(timeout_ms) = timeout_ms {
222            query.push(("timeout".to_string(), timeout_ms.to_string()));
223        }
224        Self {
225            id,
226            method: HttpMethod::Get,
227            path: PATH_TEMPLATE.to_string(),
228            query,
229            body: None,
230            kind: OutgoingRequestKind::Sync,
231        }
232    }
233
234    /// `POST /_matrix/client/v3/keys/upload`.
235    pub fn keys_upload(id: RequestId, body: serde_json::Value) -> Self {
236        const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/upload";
237        Self {
238            id,
239            method: HttpMethod::Post,
240            path: PATH_TEMPLATE.to_string(),
241            query: Vec::new(),
242            body: Some(body),
243            kind: OutgoingRequestKind::KeysUpload,
244        }
245    }
246
247    /// `POST /_matrix/client/v3/keys/query`.
248    pub fn keys_query(id: RequestId, body: serde_json::Value) -> Self {
249        const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/query";
250        Self {
251            id,
252            method: HttpMethod::Post,
253            path: PATH_TEMPLATE.to_string(),
254            query: Vec::new(),
255            body: Some(body),
256            kind: OutgoingRequestKind::KeysQuery,
257        }
258    }
259
260    /// `POST /_matrix/client/v3/keys/claim`.
261    pub fn keys_claim(id: RequestId, body: serde_json::Value) -> Self {
262        const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/claim";
263        Self {
264            id,
265            method: HttpMethod::Post,
266            path: PATH_TEMPLATE.to_string(),
267            query: Vec::new(),
268            body: Some(body),
269            kind: OutgoingRequestKind::KeysClaim,
270        }
271    }
272
273    /// `GET /_matrix/client/v3/keys/changes`.
274    pub fn keys_changes(id: RequestId, from: &str, to: &str) -> Self {
275        const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/changes";
276        Self {
277            id,
278            method: HttpMethod::Get,
279            path: PATH_TEMPLATE.to_string(),
280            query: vec![("from".to_string(), from.to_string()), ("to".to_string(), to.to_string())],
281            body: None,
282            kind: OutgoingRequestKind::KeysChanges,
283        }
284    }
285
286    /// `PUT /_matrix/client/v3/sendToDevice/{eventType}/{txnId}`.
287    pub fn send_to_device(
288        id: RequestId,
289        event_type: &str,
290        txn_id: &TxnId,
291        body: serde_json::Value,
292    ) -> Self {
293        const PATH_TEMPLATE: &str = "/_matrix/client/v3/sendToDevice/{event_type}/{txn_id}";
294        let path = substitute(
295            PATH_TEMPLATE,
296            &[("{event_type}", event_type), ("{txn_id}", txn_id.as_str())],
297        );
298        Self {
299            id,
300            method: HttpMethod::Put,
301            path,
302            query: Vec::new(),
303            body: Some(body),
304            kind: OutgoingRequestKind::SendToDevice,
305        }
306    }
307
308    /// `POST /_matrix/client/v3/keys/device_signing/upload`.
309    pub fn signing_keys_upload(id: RequestId, body: serde_json::Value) -> Self {
310        const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/device_signing/upload";
311        Self {
312            id,
313            method: HttpMethod::Post,
314            path: PATH_TEMPLATE.to_string(),
315            query: Vec::new(),
316            body: Some(body),
317            kind: OutgoingRequestKind::SigningKeysUpload,
318        }
319    }
320
321    /// `POST /_matrix/client/v3/keys/signatures/upload`.
322    pub fn signatures_upload(id: RequestId, body: serde_json::Value) -> Self {
323        const PATH_TEMPLATE: &str = "/_matrix/client/v3/keys/signatures/upload";
324        Self {
325            id,
326            method: HttpMethod::Post,
327            path: PATH_TEMPLATE.to_string(),
328            query: Vec::new(),
329            body: Some(body),
330            kind: OutgoingRequestKind::SignaturesUpload,
331        }
332    }
333
334    /// `PUT /_matrix/client/v3/rooms/{roomId}/send/{eventType}/{txnId}`.
335    pub fn room_send(
336        id: RequestId,
337        room_id: &RoomId,
338        event_type: &str,
339        txn_id: &TxnId,
340        body: serde_json::Value,
341    ) -> Self {
342        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/send/{event_type}/{txn_id}";
343        let path = substitute(
344            PATH_TEMPLATE,
345            &[
346                ("{room_id}", room_id.as_str()),
347                ("{event_type}", event_type),
348                ("{txn_id}", txn_id.as_str()),
349            ],
350        );
351        Self {
352            id,
353            method: HttpMethod::Put,
354            path,
355            query: Vec::new(),
356            body: Some(body),
357            kind: OutgoingRequestKind::RoomSend,
358        }
359    }
360
361    /// `PUT /_matrix/client/v3/rooms/{roomId}/redact/{eventId}/{txnId}`.
362    pub fn room_redact(
363        id: RequestId,
364        room_id: &RoomId,
365        event_id: &EventId,
366        txn_id: &TxnId,
367        body: serde_json::Value,
368    ) -> Self {
369        const PATH_TEMPLATE: &str =
370            "/_matrix/client/v3/rooms/{room_id}/redact/{event_id}/{txn_id}";
371        let path = substitute(
372            PATH_TEMPLATE,
373            &[
374                ("{room_id}", room_id.as_str()),
375                ("{event_id}", event_id.as_str()),
376                ("{txn_id}", txn_id.as_str()),
377            ],
378        );
379        Self {
380            id,
381            method: HttpMethod::Put,
382            path,
383            query: Vec::new(),
384            body: Some(body),
385            kind: OutgoingRequestKind::RoomRedact,
386        }
387    }
388
389    /// `PUT /_matrix/client/v3/rooms/{roomId}/state/{eventType}/{stateKey}`.
390    /// `state_key` may be `""` (the spec's own empty state key), which
391    /// percent-encodes to an empty final path segment.
392    pub fn room_state(
393        id: RequestId,
394        room_id: &RoomId,
395        event_type: &str,
396        state_key: &str,
397        body: serde_json::Value,
398    ) -> Self {
399        const PATH_TEMPLATE: &str =
400            "/_matrix/client/v3/rooms/{room_id}/state/{event_type}/{state_key}";
401        let path = substitute(
402            PATH_TEMPLATE,
403            &[
404                ("{room_id}", room_id.as_str()),
405                ("{event_type}", event_type),
406                ("{state_key}", state_key),
407            ],
408        );
409        Self {
410            id,
411            method: HttpMethod::Put,
412            path,
413            query: Vec::new(),
414            body: Some(body),
415            kind: OutgoingRequestKind::RoomState,
416        }
417    }
418
419    /// `GET /_matrix/client/v3/rooms/{roomId}/messages`.
420    pub fn room_messages(
421        id: RequestId,
422        room_id: &RoomId,
423        from: &str,
424        dir: &str,
425        limit: Option<u32>,
426    ) -> Self {
427        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/messages";
428        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
429        let mut query = vec![("from".to_string(), from.to_string()), ("dir".to_string(), dir.to_string())];
430        if let Some(limit) = limit {
431            query.push(("limit".to_string(), limit.to_string()));
432        }
433        Self {
434            id,
435            method: HttpMethod::Get,
436            path,
437            query,
438            body: None,
439            kind: OutgoingRequestKind::RoomMessages,
440        }
441    }
442
443    /// Latest page, `dir=b`, no `from`. The server starts at the tip.
444    /// An encrypted push carries no ciphertext, and a `/sync` token that
445    /// has already moved on will not return that event again.
446    pub fn room_messages_latest(id: RequestId, room_id: &RoomId, limit: u32) -> Self {
447        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/messages";
448        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
449        Self {
450            id,
451            method: HttpMethod::Get,
452            path,
453            query: vec![
454                ("dir".to_string(), "b".to_string()),
455                ("limit".to_string(), limit.to_string()),
456            ],
457            body: None,
458            kind: OutgoingRequestKind::RoomMessages,
459        }
460    }
461
462    /// `GET /_matrix/client/v3/rooms/{roomId}/members`.
463    pub fn room_members(id: RequestId, room_id: &RoomId) -> Self {
464        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/members";
465        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
466        Self {
467            id,
468            method: HttpMethod::Get,
469            path,
470            query: Vec::new(),
471            body: None,
472            kind: OutgoingRequestKind::RoomMembers,
473        }
474    }
475
476    /// `POST /_matrix/client/v3/createRoom`.
477    pub fn create_room(id: RequestId, body: serde_json::Value) -> Self {
478        const PATH_TEMPLATE: &str = "/_matrix/client/v3/createRoom";
479        Self {
480            id,
481            method: HttpMethod::Post,
482            path: PATH_TEMPLATE.to_string(),
483            query: Vec::new(),
484            body: Some(body),
485            kind: OutgoingRequestKind::CreateRoom,
486        }
487    }
488
489    /// `POST /_matrix/client/v3/rooms/{roomId}/join`. This crate only ever
490    /// joins by room id. It does not speak `/join/{roomIdOrAlias}`.
491    pub fn join_room(id: RequestId, room_id: &str) -> Self {
492        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/join";
493        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id)]);
494        Self {
495            id,
496            method: HttpMethod::Post,
497            path,
498            query: Vec::new(),
499            body: None,
500            kind: OutgoingRequestKind::JoinRoom,
501        }
502    }
503
504    /// `POST /_matrix/client/v3/rooms/{roomId}/leave`.
505    pub fn leave_room(id: RequestId, room_id: &RoomId) -> Self {
506        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/leave";
507        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
508        Self {
509            id,
510            method: HttpMethod::Post,
511            path,
512            query: Vec::new(),
513            body: None,
514            kind: OutgoingRequestKind::LeaveRoom,
515        }
516    }
517
518    /// `POST /_matrix/client/v3/rooms/{roomId}/invite`.
519    pub fn invite(id: RequestId, room_id: &RoomId, user_id: &UserId) -> Self {
520        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/invite";
521        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
522        Self {
523            id,
524            method: HttpMethod::Post,
525            path,
526            query: Vec::new(),
527            body: Some(serde_json::json!({ "user_id": user_id.as_str() })),
528            kind: OutgoingRequestKind::Invite,
529        }
530    }
531
532    /// `POST /_matrix/client/v3/rooms/{roomId}/kick`.
533    pub fn kick(id: RequestId, room_id: &RoomId, user_id: &UserId, reason: Option<&str>) -> Self {
534        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/kick";
535        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
536        let mut body = serde_json::json!({ "user_id": user_id.as_str() });
537        if let Some(reason) = reason {
538            body["reason"] = serde_json::Value::String(reason.to_string());
539        }
540        Self {
541            id,
542            method: HttpMethod::Post,
543            path,
544            query: Vec::new(),
545            body: Some(body),
546            kind: OutgoingRequestKind::Kick,
547        }
548    }
549
550    /// `PUT /_matrix/client/v3/rooms/{roomId}/state/{eventType}/{stateKey}`.
551    pub fn set_state(id: RequestId, room_id: &RoomId, event_type: &str, state_key: &str, content: serde_json::Value) -> Self {
552        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/state/{event_type}/{state_key}";
553        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str()), ("{event_type}", event_type), ("{state_key}", state_key)]);
554        Self { id, method: HttpMethod::Put, path, query: Vec::new(), body: Some(content), kind: OutgoingRequestKind::SetState }
555    }
556
557    /// `PUT /_matrix/client/v3/rooms/{roomId}/typing/{userId}`.
558    pub fn typing(
559        id: RequestId,
560        room_id: &RoomId,
561        user_id: &UserId,
562        typing: bool,
563        timeout_ms: Option<u64>,
564    ) -> Self {
565        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/typing/{user_id}";
566        let path = substitute(
567            PATH_TEMPLATE,
568            &[("{room_id}", room_id.as_str()), ("{user_id}", user_id.as_str())],
569        );
570        let mut body = serde_json::json!({ "typing": typing });
571        if let Some(timeout_ms) = timeout_ms {
572            body["timeout"] = serde_json::Value::from(timeout_ms);
573        }
574        Self {
575            id,
576            method: HttpMethod::Put,
577            path,
578            query: Vec::new(),
579            body: Some(body),
580            kind: OutgoingRequestKind::Typing,
581        }
582    }
583
584    /// `POST /_matrix/client/v3/rooms/{roomId}/receipt/{receiptType}/{eventId}`.
585    pub fn receipt(id: RequestId, room_id: &RoomId, receipt_type: &str, event_id: &EventId) -> Self {
586        const PATH_TEMPLATE: &str =
587            "/_matrix/client/v3/rooms/{room_id}/receipt/{receipt_type}/{event_id}";
588        let path = substitute(
589            PATH_TEMPLATE,
590            &[
591                ("{room_id}", room_id.as_str()),
592                ("{receipt_type}", receipt_type),
593                ("{event_id}", event_id.as_str()),
594            ],
595        );
596        Self {
597            id,
598            method: HttpMethod::Post,
599            path,
600            query: Vec::new(),
601            body: Some(serde_json::json!({})),
602            kind: OutgoingRequestKind::Receipt,
603        }
604    }
605
606    /// `POST /_matrix/client/v3/rooms/{roomId}/read_markers`.
607    pub fn read_markers(id: RequestId, room_id: &RoomId, body: serde_json::Value) -> Self {
608        const PATH_TEMPLATE: &str = "/_matrix/client/v3/rooms/{room_id}/read_markers";
609        let path = substitute(PATH_TEMPLATE, &[("{room_id}", room_id.as_str())]);
610        Self {
611            id,
612            method: HttpMethod::Post,
613            path,
614            query: Vec::new(),
615            body: Some(body),
616            kind: OutgoingRequestKind::ReadMarkers,
617        }
618    }
619
620    /// `PUT /_matrix/client/v3/user/{userId}/account_data/{type}`.
621    pub fn account_data(
622        id: RequestId,
623        user_id: &UserId,
624        event_type: &str,
625        body: serde_json::Value,
626    ) -> Self {
627        const PATH_TEMPLATE: &str = "/_matrix/client/v3/user/{user_id}/account_data/{event_type}";
628        let path = substitute(
629            PATH_TEMPLATE,
630            &[("{user_id}", user_id.as_str()), ("{event_type}", event_type)],
631        );
632        Self {
633            id,
634            method: HttpMethod::Put,
635            path,
636            query: Vec::new(),
637            body: Some(body),
638            kind: OutgoingRequestKind::AccountData,
639        }
640    }
641
642    /// `PUT /_matrix/client/v3/user/{userId}/rooms/{roomId}/account_data/{type}`.
643    pub fn room_account_data(
644        id: RequestId,
645        user_id: &UserId,
646        room_id: &RoomId,
647        event_type: &str,
648        body: serde_json::Value,
649    ) -> Self {
650        const PATH_TEMPLATE: &str =
651            "/_matrix/client/v3/user/{user_id}/rooms/{room_id}/account_data/{event_type}";
652        let path = substitute(
653            PATH_TEMPLATE,
654            &[
655                ("{user_id}", user_id.as_str()),
656                ("{room_id}", room_id.as_str()),
657                ("{event_type}", event_type),
658            ],
659        );
660        Self {
661            id,
662            method: HttpMethod::Put,
663            path,
664            query: Vec::new(),
665            body: Some(body),
666            kind: OutgoingRequestKind::RoomAccountData,
667        }
668    }
669
670    /// `GET /_matrix/client/v3/profile/{userId}`.
671    pub fn profile(id: RequestId, user_id: &UserId) -> Self {
672        const PATH_TEMPLATE: &str = "/_matrix/client/v3/profile/{user_id}";
673        let path = substitute(PATH_TEMPLATE, &[("{user_id}", user_id.as_str())]);
674        Self {
675            id,
676            method: HttpMethod::Get,
677            path,
678            query: Vec::new(),
679            body: None,
680            kind: OutgoingRequestKind::Profile,
681        }
682    }
683
684    /// `POST /_matrix/client/v3/user_directory/search`.
685    pub fn user_directory_search(id: RequestId, body: serde_json::Value) -> Self {
686        const PATH_TEMPLATE: &str = "/_matrix/client/v3/user_directory/search";
687        Self {
688            id,
689            method: HttpMethod::Post,
690            path: PATH_TEMPLATE.to_string(),
691            query: Vec::new(),
692            body: Some(body),
693            kind: OutgoingRequestKind::UserDirectorySearch,
694        }
695    }
696
697    /// `POST /_matrix/client/v3/publicRooms`.
698    pub fn public_rooms(id: RequestId, body: serde_json::Value) -> Self {
699        const PATH_TEMPLATE: &str = "/_matrix/client/v3/publicRooms";
700        Self {
701            id,
702            method: HttpMethod::Post,
703            path: PATH_TEMPLATE.to_string(),
704            query: Vec::new(),
705            body: Some(body),
706            kind: OutgoingRequestKind::PublicRooms,
707        }
708    }
709
710    /// `GET /_matrix/client/v3/room_keys/version` — the current backup
711    /// version. Creating/updating a version is a different endpoint,
712    /// deferred to whichever piece implements key backup (plan §5, M9).
713    pub fn backup_version(id: RequestId) -> Self {
714        const PATH_TEMPLATE: &str = "/_matrix/client/v3/room_keys/version";
715        Self {
716            id,
717            method: HttpMethod::Get,
718            path: PATH_TEMPLATE.to_string(),
719            query: Vec::new(),
720            body: None,
721            kind: OutgoingRequestKind::BackupVersion,
722        }
723    }
724
725    /// `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` — the
726    /// single-session lookup shape the plan's §4.3 UTD-recovery walkthrough
727    /// uses. The bulk (`/room_keys/keys`) and per-room
728    /// (`/room_keys/keys/{roomId}`) variants are deferred to M9, which owns
729    /// key backup end to end (plan §6.8: exact backup paths/queries are
730    /// still open pending the sibling server plan).
731    pub fn backup_keys(id: RequestId, room_id: &RoomId, session_id: &str, version: &str) -> Self {
732        const PATH_TEMPLATE: &str = "/_matrix/client/v3/room_keys/keys/{room_id}/{session_id}";
733        let path = substitute(
734            PATH_TEMPLATE,
735            &[("{room_id}", room_id.as_str()), ("{session_id}", session_id)],
736        );
737        Self {
738            id,
739            method: HttpMethod::Get,
740            path,
741            query: vec![("version".to_string(), version.to_string())],
742            body: None,
743            kind: OutgoingRequestKind::BackupKeys,
744        }
745    }
746}
747
748/// One HTTP response a shell hands back for a prior [`OutgoingRequest`].
749#[derive(Clone, Debug, PartialEq, Eq)]
750pub struct HttpResponseDescriptor {
751    /// The HTTP status code.
752    pub status: u16,
753    /// The raw response body bytes.
754    pub body: Vec<u8>,
755}
756
757impl HttpResponseDescriptor {
758    /// Parses `body` as a Matrix-shaped error response
759    /// (`{"errcode": "...", "error": "..."}`), returning
760    /// `(errcode, error)` if the body is JSON and carries an `errcode`
761    /// string field. `error` defaults to `""` if absent (the spec makes it
762    /// optional). Returns `None` for a non-JSON body or a JSON body with
763    /// no `errcode` field, regardless of `status` — callers that only want
764    /// this on failure responses should check `status` themselves first.
765    pub fn matrix_error(&self) -> Option<(String, String)> {
766        let value: serde_json::Value = serde_json::from_slice(&self.body).ok()?;
767        let errcode = value.get("errcode")?.as_str()?.to_string();
768        let error = value
769            .get("error")
770            .and_then(serde_json::Value::as_str)
771            .unwrap_or_default()
772            .to_string();
773        Some((errcode, error))
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    fn id() -> RequestId {
782        RequestId::next(0)
783    }
784
785    #[test]
786    fn percent_encode_segment_escapes_matrix_sigils() {
787        assert_eq!(percent_encode_segment("!abc:example.org"), "%21abc%3Aexample.org");
788        assert_eq!(percent_encode_segment("safe-._~123"), "safe-._~123");
789    }
790
791    #[test]
792    fn percent_decode_segment_reverses_percent_encode_segment() {
793        for raw in ["!abc:example.org", "safe-._~123", "curve/25519+key=", ""] {
794            let encoded = percent_encode_segment(raw);
795            assert_eq!(percent_decode_segment(&encoded).as_deref(), Some(raw));
796        }
797    }
798
799    #[test]
800    fn percent_decode_segment_rejects_a_malformed_escape() {
801        assert_eq!(percent_decode_segment("%2"), None);
802        assert_eq!(percent_decode_segment("%zz"), None);
803    }
804
805    #[test]
806    fn room_send_percent_encodes_the_room_id_in_the_path() {
807        let room_id = RoomId::parse("!abc:example.org").expect("valid room id");
808        let txn_id = TxnId::new(0);
809        let req = OutgoingRequest::room_send(
810            id(),
811            &room_id,
812            "m.room.message",
813            &txn_id,
814            serde_json::json!({ "body": "hi" }),
815        );
816        assert_eq!(
817            req.path,
818            format!(
819                "/_matrix/client/v3/rooms/%21abc%3Aexample.org/send/m.room.message/{}",
820                txn_id.as_str()
821            )
822        );
823        assert_eq!(req.method, HttpMethod::Put);
824        assert_eq!(req.kind, OutgoingRequestKind::RoomSend);
825    }
826
827    #[test]
828    fn send_to_device_percent_encodes_the_txn_id_in_the_path() {
829        let txn_id = TxnId::new(0);
830        let req = OutgoingRequest::send_to_device(
831            id(),
832            "m.room.encrypted",
833            &txn_id,
834            serde_json::json!({ "messages": {} }),
835        );
836        assert!(req.path.starts_with("/_matrix/client/v3/sendToDevice/m.room.encrypted/"));
837        assert!(!req.path.contains(':'), "txn id's own characters carry no raw ':'");
838    }
839
840    #[test]
841    fn sync_carries_since_and_timeout_as_query_params_only_when_present() {
842        let filter = ("filter".to_string(), SYNC_FILTER.to_string());
843        let bare = OutgoingRequest::sync(id(), None, None);
844        assert_eq!(bare.query, vec![filter.clone()]);
845
846        let full = OutgoingRequest::sync(id(), Some("s123"), Some(30_000));
847        assert_eq!(
848            full.query,
849            vec![filter, ("since".to_string(), "s123".to_string()), ("timeout".to_string(), "30000".to_string())]
850        );
851    }
852
853    #[test]
854    fn sync_filter_asks_for_left_rooms() {
855        let parsed: serde_json::Value = serde_json::from_str(SYNC_FILTER).expect("the filter is JSON");
856        assert_eq!(parsed["room"]["include_leave"], serde_json::Value::Bool(true));
857    }
858
859    #[test]
860    fn outgoing_request_kind_round_trips_serde() {
861        for kind in [
862            OutgoingRequestKind::Sync,
863            OutgoingRequestKind::KeysUpload,
864            OutgoingRequestKind::RoomSend,
865            OutgoingRequestKind::BackupKeys,
866        ] {
867            let json = serde_json::to_string(&kind).expect("serialize");
868            let back: OutgoingRequestKind = serde_json::from_str(&json).expect("deserialize");
869            assert_eq!(back, kind);
870        }
871    }
872
873    #[test]
874    fn outgoing_request_round_trips_serde() {
875        let room_id = RoomId::parse("!abc:example.org").expect("valid room id");
876        let req = OutgoingRequest::room_members(id(), &room_id);
877        let json = serde_json::to_string(&req).expect("serialize");
878        let back: OutgoingRequest = serde_json::from_str(&json).expect("deserialize");
879        assert_eq!(back, req);
880    }
881
882    #[test]
883    fn matrix_error_parses_errcode_and_error() {
884        let resp = HttpResponseDescriptor {
885            status: 403,
886            body: br#"{"errcode":"M_FORBIDDEN","error":"Guest access is forbidden"}"#.to_vec(),
887        };
888        assert_eq!(
889            resp.matrix_error(),
890            Some(("M_FORBIDDEN".to_string(), "Guest access is forbidden".to_string()))
891        );
892    }
893
894    #[test]
895    fn matrix_error_defaults_a_missing_error_field_to_empty() {
896        let resp = HttpResponseDescriptor {
897            status: 429,
898            body: br#"{"errcode":"M_LIMIT_EXCEEDED"}"#.to_vec(),
899        };
900        assert_eq!(resp.matrix_error(), Some(("M_LIMIT_EXCEEDED".to_string(), String::new())));
901    }
902
903    #[test]
904    fn matrix_error_is_none_for_a_non_json_body() {
905        let resp = HttpResponseDescriptor { status: 200, body: b"not json".to_vec() };
906        assert_eq!(resp.matrix_error(), None);
907    }
908
909    #[test]
910    fn matrix_error_is_none_when_errcode_is_absent() {
911        let resp = HttpResponseDescriptor {
912            status: 200,
913            body: br#"{"next_batch":"s1"}"#.to_vec(),
914        };
915        assert_eq!(resp.matrix_error(), None);
916    }
917}