Skip to main content

vta_sdk/protocol/
mod.rs

1//! Client surface for DIDComm protocol management.
2//!
3//! Spec: `docs/05-design-notes/didcomm-protocol-management.md`.
4//!
5//! Phase 3 lands `enable_didcomm` (REST-only by nature — DIDComm is
6//! not yet running at first-enable time). The disable / migrate /
7//! drain-cancel / report calls — and their DIDComm transport
8//! handlers — arrive in Phase 4 verticals.
9//!
10//! Runtime REST service-management wire types (the symmetric
11//! REST-side of the spec §4 surface — `EnableRestRequest`,
12//! `UpdateRestRequest`, `DisableRestRequest`, `RollbackRestRequest`,
13//! `ServiceMutationResponse`) live in [`services`].
14
15pub mod matching;
16pub mod services;
17
18use serde::{Deserialize, Serialize};
19
20#[cfg(feature = "client")]
21use crate::client::VtaClient;
22#[cfg(feature = "client")]
23use crate::error::VtaError;
24#[cfg(feature = "client")]
25use crate::protocols::protocol_management;
26
27/// Request body for `POST /services/didcomm/enable`.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[must_use]
30pub struct EnableDidcommRequest {
31    pub mediator_did: String,
32    /// Skip handshake steps 2-5 (DID resolution always runs).
33    /// Emits a `MediatorHandshakeBypassed` telemetry event when set.
34    #[serde(default)]
35    pub force: bool,
36    /// Trust-ping round-trip timeout (default: 10 seconds).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub handshake_timeout_secs: Option<u64>,
39}
40
41impl EnableDidcommRequest {
42    pub fn new(mediator_did: impl Into<String>) -> Self {
43        Self {
44            mediator_did: mediator_did.into(),
45            force: false,
46            handshake_timeout_secs: None,
47        }
48    }
49
50    pub fn force(mut self, force: bool) -> Self {
51        self.force = force;
52        self
53    }
54
55    pub fn handshake_timeout_secs(mut self, secs: u64) -> Self {
56        self.handshake_timeout_secs = Some(secs);
57        self
58    }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct EnableDidcommResponse {
63    pub new_version_id: String,
64    pub mediator_did: String,
65    pub mediator_endpoint: String,
66    /// The VTA's own DID — subject of the LogEntry this enable
67    /// wrote. Carried so the CLI can print follow-up commands like
68    /// `pnm webvh did-log <vta_did>` for serverless deployments.
69    /// `#[serde(default)]` + elide-when-empty keeps the wire form
70    /// back-compat with older servers.
71    #[serde(default, skip_serializing_if = "String::is_empty")]
72    pub vta_did: String,
73    /// True when the VTA's DID is self-hosted (`server_id =
74    /// "serverless"`). The new LogEntry is local only — operators
75    /// must fetch the updated `did.jsonl` and redeploy.
76    /// `#[serde(default)]` for back-compat.
77    #[serde(default)]
78    pub serverless: bool,
79}
80
81/// Response body for `GET /services/didcomm`.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
84pub struct DidcommStatusResponse {
85    pub enabled: bool,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub mediator_did: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub websocket_status: Option<String>,
90}
91
92/// Body returned by the server on `409 Conflict` from
93/// `POST /services/didcomm/enable` when DIDComm is already active.
94#[derive(Debug, Clone, Deserialize)]
95pub struct EnableDidcommConflictBody {
96    pub error: String,
97    #[serde(default)]
98    pub mediator_did: Option<String>,
99}
100
101/// Request body for `POST /services/didcomm/disable`.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct DisableDidcommRequest {
104    /// Drain TTL in seconds. 0 = immediate teardown (REST only;
105    /// over DIDComm transport, minimum 1h is enforced server-side).
106    pub drain_ttl_secs: u64,
107}
108
109impl DisableDidcommRequest {
110    pub fn new(drain_ttl_secs: u64) -> Self {
111        Self { drain_ttl_secs }
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct DisableDidcommResponse {
117    pub new_version_id: String,
118    pub prior_mediator_did: String,
119    /// `Some(rfc3339)` when the listener entered drain state;
120    /// `None` when it was torn down immediately.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub drains_until: Option<String>,
123    /// The VTA's own DID. See [`EnableDidcommResponse::vta_did`].
124    #[serde(default, skip_serializing_if = "String::is_empty")]
125    pub vta_did: String,
126    /// True when the VTA's DID is self-hosted. See
127    /// [`EnableDidcommResponse::serverless`].
128    #[serde(default)]
129    pub serverless: bool,
130}
131
132#[cfg(feature = "client")]
133impl VtaClient {
134    /// Enable DIDComm on a REST-only VTA. Spec: success criterion #1.
135    ///
136    /// The VTA must be configured with a vta_did, must currently
137    /// have `services.didcomm = false`, and the caller must have
138    /// super-admin role. On success, the VTA publishes a new WebVH
139    /// LogEntry advertising the mediator and registers it as
140    /// active.
141    ///
142    /// **First-enable handshake:** the route runs a transient
143    /// `DIDCommService` round-trip against the candidate mediator
144    /// before publishing — see
145    /// `vta_service::messaging::transient_handshake`. The operation
146    /// itself uses [`AlwaysOkProver`] because the steady-state
147    /// `DIDCommService` doesn't exist yet at first-enable. The
148    /// live-prover path through `update_didcomm` covers the
149    /// steady-state case.
150    pub async fn enable_didcomm(
151        &self,
152        req: EnableDidcommRequest,
153    ) -> Result<EnableDidcommResponse, VtaError> {
154        match &self.transport {
155            crate::client::Transport::Rest {
156                client,
157                base_url,
158                auth,
159            } => {
160                Self::ensure_token_valid(client, base_url, auth).await?;
161                let token = auth.lock().await.token.clone();
162                let req = client
163                    .post(format!("{base_url}/services/didcomm/enable"))
164                    .json(&req);
165                let resp = Self::with_auth_token(req, &token).send().await?;
166                Self::handle_response(resp).await
167            }
168            #[cfg(feature = "session")]
169            crate::client::Transport::DIDComm { .. } => Err(VtaError::UnsupportedTransport(
170                "enable_didcomm is REST-only".into(),
171            )),
172        }
173    }
174
175    /// Read the current DIDComm runtime status. Auth: super-admin (parity with
176    /// `GET /services` / `list_services`, which exposes the same `mediator_did`).
177    pub async fn didcomm_status(&self) -> Result<DidcommStatusResponse, VtaError> {
178        match &self.transport {
179            crate::client::Transport::Rest {
180                client,
181                base_url,
182                auth,
183            } => {
184                crate::client::VtaClient::ensure_token_valid(client, base_url, auth).await?;
185                let token = auth.lock().await.token.clone();
186                let req = client.get(format!("{base_url}/services/didcomm"));
187                let resp = crate::client::VtaClient::with_auth_token(req, &token)
188                    .send()
189                    .await?;
190                crate::client::VtaClient::handle_response(resp).await
191            }
192            #[cfg(feature = "session")]
193            crate::client::Transport::DIDComm { .. } => Err(VtaError::UnsupportedTransport(
194                "didcomm status is REST-only in the SDK".into(),
195            )),
196        }
197    }
198
199    /// Disable DIDComm. Refuses if REST is also disabled
200    /// (`NoProtocolRemaining`). Drain TTL semantics:
201    /// - `0` = immediate teardown (REST transport only).
202    /// - `>= 3600` = drain window over DIDComm transport (server
203    ///   enforces 1h minimum).
204    pub async fn disable_didcomm(
205        &self,
206        req: DisableDidcommRequest,
207    ) -> Result<DisableDidcommResponse, VtaError> {
208        self.rpc(
209            protocol_management::DISABLE_DIDCOMM,
210            serde_json::to_value(&req)?,
211            protocol_management::DISABLE_DIDCOMM_RESULT,
212            30,
213            |c, url| c.post(format!("{url}/services/didcomm/disable")).json(&req),
214        )
215        .await
216    }
217
218    /// Update which DIDComm mediator the VTA's `#vta-didcomm`
219    /// service entry advertises. Runs the pre-promotion handshake
220    /// against the new mediator and places the prior mediator in
221    /// drain state for the requested TTL.
222    ///
223    /// (T2.3 rename — was `migrate_mediator`. Operation is the
224    /// same; the naming aligns with the unified `services
225    /// {kind} {verb}` surface.)
226    pub async fn update_didcomm(
227        &self,
228        req: UpdateDidcommRequest,
229    ) -> Result<UpdateDidcommResponse, VtaError> {
230        self.rpc(
231            protocol_management::UPDATE_DIDCOMM,
232            serde_json::to_value(&req)?,
233            protocol_management::UPDATE_DIDCOMM_RESULT,
234            120,
235            |c, url| c.post(format!("{url}/services/didcomm/update")).json(&req),
236        )
237        .await
238    }
239
240    // ── REST service-management client methods (P1 wire types,
241    //    P5 client surface) ──────────────────────────────────────────
242
243    /// Enable REST advertisement on the VTA's DID document by
244    /// publishing a `#vta-rest` service entry. Spec §3.4.
245    pub async fn enable_rest(
246        &self,
247        req: services::EnableRestRequest,
248    ) -> Result<services::ServiceMutationResponse, VtaError> {
249        self.rpc(
250            protocol_management::ENABLE_REST,
251            serde_json::to_value(&req)?,
252            protocol_management::ENABLE_REST_RESULT,
253            30,
254            |c, url| c.post(format!("{url}/services/rest/enable")).json(&req),
255        )
256        .await
257    }
258
259    /// Update the URL on the existing `#vta-rest` service entry.
260    pub async fn update_rest(
261        &self,
262        req: services::UpdateRestRequest,
263    ) -> Result<services::ServiceMutationResponse, VtaError> {
264        self.rpc(
265            protocol_management::UPDATE_REST,
266            serde_json::to_value(&req)?,
267            protocol_management::UPDATE_REST_RESULT,
268            30,
269            |c, url| c.post(format!("{url}/services/rest/update")).json(&req),
270        )
271        .await
272    }
273
274    /// Remove the `#vta-rest` entry from the VTA's DID document.
275    /// Refused with `LastServiceRefused` when DIDComm is also off.
276    pub async fn disable_rest(
277        &self,
278        req: services::DisableRestRequest,
279    ) -> Result<services::ServiceMutationResponse, VtaError> {
280        self.rpc(
281            protocol_management::DISABLE_REST,
282            serde_json::to_value(&req)?,
283            protocol_management::DISABLE_REST_RESULT,
284            30,
285            |c, url| c.post(format!("{url}/services/rest/disable")).json(&req),
286        )
287        .await
288    }
289
290    // ── Fail-forward rollback client methods (P3 server, P5
291    //    client surface) ──────────────────────────────────────────
292
293    /// Fail-forward the most recent REST mutation by re-applying
294    /// the snapshotted prior state. Spec §3.5a.
295    pub async fn rollback_rest(
296        &self,
297        req: services::RollbackRestRequest,
298    ) -> Result<services::RollbackResponse, VtaError> {
299        self.rpc(
300            protocol_management::ROLLBACK_REST,
301            serde_json::to_value(&req)?,
302            protocol_management::ROLLBACK_REST_RESULT,
303            60,
304            |c, url| c.post(format!("{url}/services/rest/rollback")).json(&req),
305        )
306        .await
307    }
308
309    // ── TSP service-management client methods ──────────────────────
310    //
311    // TSP advertises a **mediator DID** (the VTA's TSP VID), not a
312    // URL — so these mirror the REST methods exactly except for the
313    // request types carrying `mediator_did` and the `/services/tsp/…`
314    // paths. All four go through `self.rpc(…)` (reachable over both
315    // REST and DIDComm); `enable_tsp` is NOT REST-only the way
316    // `enable_didcomm` is.
317
318    /// Enable TSP advertisement on the VTA's DID document by
319    /// publishing a `#tsp` service entry pointing at the mediator
320    /// DID. Spec §3.4.
321    pub async fn enable_tsp(
322        &self,
323        req: services::EnableTspRequest,
324    ) -> Result<services::ServiceMutationResponse, VtaError> {
325        self.rpc(
326            protocol_management::ENABLE_TSP,
327            serde_json::to_value(&req)?,
328            protocol_management::ENABLE_TSP_RESULT,
329            30,
330            |c, url| c.post(format!("{url}/services/tsp/enable")).json(&req),
331        )
332        .await
333    }
334
335    /// Update the mediator DID on the existing `#tsp` service entry.
336    pub async fn update_tsp(
337        &self,
338        req: services::UpdateTspRequest,
339    ) -> Result<services::ServiceMutationResponse, VtaError> {
340        self.rpc(
341            protocol_management::UPDATE_TSP,
342            serde_json::to_value(&req)?,
343            protocol_management::UPDATE_TSP_RESULT,
344            30,
345            |c, url| c.post(format!("{url}/services/tsp/update")).json(&req),
346        )
347        .await
348    }
349
350    /// Remove the `#tsp` entry from the VTA's DID document.
351    /// Refused with `LastServiceRefused` when TSP is the only
352    /// advertised transport.
353    pub async fn disable_tsp(
354        &self,
355        req: services::DisableTspRequest,
356    ) -> Result<services::ServiceMutationResponse, VtaError> {
357        self.rpc(
358            protocol_management::DISABLE_TSP,
359            serde_json::to_value(&req)?,
360            protocol_management::DISABLE_TSP_RESULT,
361            30,
362            |c, url| c.post(format!("{url}/services/tsp/disable")).json(&req),
363        )
364        .await
365    }
366
367    /// Fail-forward the most recent TSP mutation by re-applying the
368    /// snapshotted prior state. Spec §3.5a.
369    pub async fn rollback_tsp(
370        &self,
371        req: services::RollbackTspRequest,
372    ) -> Result<services::RollbackResponse, VtaError> {
373        self.rpc(
374            protocol_management::ROLLBACK_TSP,
375            serde_json::to_value(&req)?,
376            protocol_management::ROLLBACK_TSP_RESULT,
377            60,
378            |c, url| c.post(format!("{url}/services/tsp/rollback")).json(&req),
379        )
380        .await
381    }
382
383    // ── WebAuthn service-management client methods ─────────────────
384
385    /// Enable WebAuthn-RP advertisement on the VTA's DID document by
386    /// publishing a `#vta-webauthn` service entry.
387    pub async fn enable_webauthn(
388        &self,
389        req: services::EnableWebauthnRequest,
390    ) -> Result<services::ServiceMutationResponse, VtaError> {
391        self.rpc(
392            protocol_management::ENABLE_WEBAUTHN,
393            serde_json::to_value(&req)?,
394            protocol_management::ENABLE_WEBAUTHN_RESULT,
395            30,
396            |c, url| c.post(format!("{url}/services/webauthn/enable")).json(&req),
397        )
398        .await
399    }
400
401    /// Update the URL on the existing `#vta-webauthn` entry.
402    pub async fn update_webauthn(
403        &self,
404        req: services::UpdateWebauthnRequest,
405    ) -> Result<services::ServiceMutationResponse, VtaError> {
406        self.rpc(
407            protocol_management::UPDATE_WEBAUTHN,
408            serde_json::to_value(&req)?,
409            protocol_management::UPDATE_WEBAUTHN_RESULT,
410            30,
411            |c, url| c.post(format!("{url}/services/webauthn/update")).json(&req),
412        )
413        .await
414    }
415
416    /// Remove the `#vta-webauthn` entry AND strip passkey VMs from
417    /// every DID this VTA controls (hard-disable semantics).
418    /// Refused with `LastServiceRefused` when removing WebAuthn
419    /// would leave no transport advertised.
420    pub async fn disable_webauthn(
421        &self,
422        req: services::DisableWebauthnRequest,
423    ) -> Result<services::ServiceMutationResponse, VtaError> {
424        // Longer timeout than REST/DIDComm disable because the
425        // passkey-VM cleanup iterates every DID this VTA controls
426        // and publishes a WebVH update per affected DID.
427        self.rpc(
428            protocol_management::DISABLE_WEBAUTHN,
429            serde_json::to_value(&req)?,
430            protocol_management::DISABLE_WEBAUTHN_RESULT,
431            300,
432            |c, url| {
433                c.post(format!("{url}/services/webauthn/disable"))
434                    .json(&req)
435            },
436        )
437        .await
438    }
439
440    /// Fail-forward the most recent WebAuthn mutation by re-applying
441    /// the snapshotted prior state.
442    pub async fn rollback_webauthn(
443        &self,
444        req: services::RollbackWebauthnRequest,
445    ) -> Result<services::RollbackResponse, VtaError> {
446        self.rpc(
447            protocol_management::ROLLBACK_WEBAUTHN,
448            serde_json::to_value(&req)?,
449            protocol_management::ROLLBACK_WEBAUTHN_RESULT,
450            300,
451            |c, url| {
452                c.post(format!("{url}/services/webauthn/rollback"))
453                    .json(&req)
454            },
455        )
456        .await
457    }
458
459    /// Fail-forward the most recent DIDComm mutation. Threads
460    /// `drain_ttl_secs` through to the dispatched forward op for
461    /// the disable / update arms.
462    pub async fn rollback_didcomm(
463        &self,
464        req: services::RollbackDidcommRequest,
465    ) -> Result<services::RollbackResponse, VtaError> {
466        self.rpc(
467            protocol_management::ROLLBACK_DIDCOMM,
468            serde_json::to_value(&req)?,
469            protocol_management::ROLLBACK_DIDCOMM_RESULT,
470            120,
471            |c, url| {
472                c.post(format!("{url}/services/didcomm/rollback"))
473                    .json(&req)
474            },
475        )
476        .await
477    }
478
479    // ── Read-only inspection (P4 server, P5 client surface) ──────
480
481    /// Inspect the VTA's currently-advertised transport services.
482    /// Returns one entry per kind in canonical DIDComm-then-REST
483    /// order.
484    pub async fn list_services(&self) -> Result<services::ServicesListResponse, VtaError> {
485        self.rpc(
486            protocol_management::LIST_SERVICES,
487            serde_json::Value::Null,
488            protocol_management::LIST_SERVICES_RESULT,
489            30,
490            |c, url| c.get(format!("{url}/services")),
491        )
492        .await
493    }
494
495    /// List currently-draining mediators. Empty list is normal.
496    pub async fn list_drain(&self) -> Result<services::DrainListResponse, VtaError> {
497        self.rpc(
498            protocol_management::LIST_DRAIN,
499            serde_json::Value::Null,
500            protocol_management::LIST_DRAIN_RESULT,
501            30,
502            |c, url| c.get(format!("{url}/services/didcomm/drain")),
503        )
504        .await
505    }
506}
507
508/// Request body for `POST /services/didcomm/update`.
509#[derive(Debug, Clone, Serialize, Deserialize)]
510#[must_use]
511pub struct UpdateDidcommRequest {
512    pub new_mediator_did: String,
513    pub drain_ttl_secs: u64,
514    #[serde(default)]
515    pub force: bool,
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub handshake_timeout_secs: Option<u64>,
518    /// Tag the operation as a rollback in telemetry.
519    #[serde(default)]
520    pub rollback: bool,
521}
522
523impl UpdateDidcommRequest {
524    pub fn new(new_mediator_did: impl Into<String>, drain_ttl_secs: u64) -> Self {
525        Self {
526            new_mediator_did: new_mediator_did.into(),
527            drain_ttl_secs,
528            force: false,
529            handshake_timeout_secs: None,
530            rollback: false,
531        }
532    }
533
534    pub fn force(mut self, force: bool) -> Self {
535        self.force = force;
536        self
537    }
538
539    pub fn rollback(mut self, rollback: bool) -> Self {
540        self.rollback = rollback;
541        self
542    }
543
544    pub fn handshake_timeout_secs(mut self, secs: u64) -> Self {
545        self.handshake_timeout_secs = Some(secs);
546        self
547    }
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct UpdateDidcommResponse {
552    pub new_version_id: String,
553    pub prior_mediator_did: String,
554    pub active_mediator_did: String,
555    pub active_mediator_endpoint: String,
556    pub drains_until: String,
557    /// The VTA's own DID. See [`EnableDidcommResponse::vta_did`].
558    #[serde(default, skip_serializing_if = "String::is_empty")]
559    pub vta_did: String,
560    /// True when the VTA's DID is self-hosted. See
561    /// [`EnableDidcommResponse::serverless`].
562    #[serde(default)]
563    pub serverless: bool,
564}
565
566/// Request body for `POST /mediators/drain/cancel`.
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct DrainCancelRequest {
569    pub mediator_did: String,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct DrainCancelResponse {
574    pub mediator_did: String,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct MediatorStats {
579    pub mediator_did: String,
580    pub inbound_count: u64,
581    pub first_seen: String,
582    pub last_seen: String,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct SenderLastSeen {
587    pub sender_did: String,
588    pub last_seen_mediator: String,
589    pub last_seen_at: String,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct MediatorReport {
594    #[serde(default)]
595    pub since: Option<String>,
596    pub until: String,
597    pub mediators: Vec<MediatorStats>,
598    pub senders: Vec<SenderLastSeen>,
599}
600
601#[cfg(feature = "client")]
602impl VtaClient {
603    /// Cancel a drain entry early, dropping the listener for that
604    /// mediator immediately. Refuses if the named DID is the
605    /// active mediator (use `services disable didcomm` instead) or
606    /// not registered at all.
607    pub async fn drain_cancel(
608        &self,
609        req: DrainCancelRequest,
610    ) -> Result<DrainCancelResponse, VtaError> {
611        self.rpc(
612            protocol_management::DRAIN_CANCEL,
613            serde_json::to_value(&req)?,
614            protocol_management::DRAIN_CANCEL_RESULT,
615            30,
616            |c, url| c.post(format!("{url}/mediators/drain/cancel")).json(&req),
617        )
618        .await
619    }
620
621    /// Query the mediator-attribution report. `since`/`until` are
622    /// optional RFC 3339 timestamps. Returns per-mediator inbound
623    /// counts and per-sender last-seen mediator (so operators can
624    /// spot senders still using the prior mediator after a
625    /// migrate).
626    pub async fn mediator_report(
627        &self,
628        since: Option<&str>,
629        until: Option<&str>,
630    ) -> Result<MediatorReport, VtaError> {
631        let since_owned = since.map(str::to_string);
632        let until_owned = until.map(str::to_string);
633        let qs = build_report_query(since_owned.as_deref(), until_owned.as_deref());
634        self.rpc(
635            protocol_management::MEDIATOR_REPORT,
636            serde_json::json!({
637                "since": since_owned,
638                "until": until_owned,
639            }),
640            protocol_management::MEDIATOR_REPORT_RESULT,
641            30,
642            move |c, url| {
643                let url = if qs.is_empty() {
644                    format!("{url}/mediators/report")
645                } else {
646                    format!("{url}/mediators/report?{qs}")
647                };
648                c.get(url)
649            },
650        )
651        .await
652    }
653}
654
655#[cfg(feature = "client")]
656fn build_report_query(since: Option<&str>, until: Option<&str>) -> String {
657    let mut parts: Vec<String> = Vec::new();
658    if let Some(s) = since {
659        parts.push(format!("since={}", url_encode(s)));
660    }
661    if let Some(u) = until {
662        parts.push(format!("until={}", url_encode(u)));
663    }
664    parts.join("&")
665}
666
667#[cfg(feature = "client")]
668fn url_encode(s: &str) -> String {
669    // RFC 3339 timestamps contain `:` and `+`; the latter is the
670    // form-urlencoded representation of a space and would mangle
671    // the timestamp on the server side. Encode the unsafe chars
672    // explicitly.
673    s.chars()
674        .flat_map(|c| match c {
675            ':' => "%3A".chars().collect::<Vec<_>>(),
676            '+' => "%2B".chars().collect::<Vec<_>>(),
677            _ => vec![c],
678        })
679        .collect()
680}