twilight_util/link/
webhook.rs

1//! Utilities for parsing webhook URLs.
2//!
3//! The URL is typically provided by the desktop client GUI when configuring a
4//! webhook integration.
5
6use std::{
7    error::Error,
8    fmt::{Display, Formatter, Result as FmtResult},
9    num::NonZero,
10};
11use twilight_model::id::{Id, marker::WebhookMarker};
12
13/// Error when [parsing] a webhook URL.
14///
15/// [parsing]: parse
16#[derive(Debug)]
17pub struct WebhookParseError {
18    kind: WebhookParseErrorType,
19    source: Option<Box<dyn Error + Send + Sync>>,
20}
21
22impl WebhookParseError {
23    /// Immutable reference to the type of error that occurred.
24    #[must_use = "retrieving the type has no effect if left unused"]
25    pub const fn kind(&self) -> &WebhookParseErrorType {
26        &self.kind
27    }
28
29    /// Consume the error, returning the source error if there is any.
30    #[must_use = "consuming the error and retrieving the source has no effect if left unused"]
31    pub fn into_source(self) -> Option<Box<dyn Error + Send + Sync>> {
32        self.source
33    }
34
35    /// Consume the error, returning the owned error type and the source error.
36    #[must_use = "consuming the error into its parts has no effect if left unused"]
37    pub fn into_parts(self) -> (WebhookParseErrorType, Option<Box<dyn Error + Send + Sync>>) {
38        (self.kind, self.source)
39    }
40}
41
42impl Display for WebhookParseError {
43    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
44        match self.kind {
45            WebhookParseErrorType::IdInvalid => f.write_str("url path segment isn't a valid ID"),
46            WebhookParseErrorType::SegmentMissing => {
47                f.write_str("url is missing a required path segment")
48            }
49        }
50    }
51}
52
53impl Error for WebhookParseError {
54    fn source(&self) -> Option<&(dyn Error + 'static)> {
55        self.source
56            .as_ref()
57            .map(|source| &**source as &(dyn Error + 'static))
58    }
59}
60
61/// Type of [`WebhookParseError`] that occurred.
62#[derive(Debug)]
63#[non_exhaustive]
64pub enum WebhookParseErrorType {
65    /// ID segment in the URL path is not an integer.
66    IdInvalid,
67    /// Required segment of the URL path is missing.
68    SegmentMissing,
69}
70
71/// Parse the webhook ID and token from a webhook URL, if it exists in the
72/// string.
73///
74/// # Examples
75///
76/// Parse a webhook URL with a token:
77///
78/// ```
79/// use twilight_model::id::Id;
80/// use twilight_util::link::webhook;
81///
82/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
83/// let url = "https://canary.discord.com/api/webhooks/794590023369752587/tjxHaPHLKp9aEdSwJuLeHhHHGEqIxt1aay4I67FOP9uzsYEWmj0eJmDn-2ZvCYLyOb_K";
84///
85/// let (id, token) = webhook::parse(url)?;
86/// assert_eq!(Id::new(794590023369752587), id);
87/// assert_eq!(
88///     Some("tjxHaPHLKp9aEdSwJuLeHhHHGEqIxt1aay4I67FOP9uzsYEWmj0eJmDn-2ZvCYLyOb_K"),
89///     token,
90/// );
91/// # Ok(()) }
92/// ```
93///
94/// Parse a webhook URL without a token:
95///
96/// ```
97/// use twilight_model::id::Id;
98/// use twilight_util::link::webhook;
99///
100/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
101/// let url = "https://canary.discord.com/api/webhooks/794590023369752587";
102///
103/// let (id, token) = webhook::parse(url)?;
104/// assert_eq!(Id::new(794590023369752587), id);
105/// assert!(token.is_none());
106/// # Ok(()) }
107/// ```
108///
109/// # Errors
110///
111/// Returns [`WebhookParseErrorType::IdInvalid`] error type if the ID segment of
112/// the URL is not a valid integer.
113///
114/// Returns [`WebhookParseErrorType::SegmentMissing`] error type if one of the
115/// required segments is missing. This can be the "api" or "webhooks" standard
116/// segment of the URL or the segment containing the webhook ID.
117pub fn parse(url: &str) -> Result<(Id<WebhookMarker>, Option<&str>), WebhookParseError> {
118    let mut segments = {
119        let mut start = url.split("discord.com/api/webhooks/");
120        let path = start.nth(1).ok_or(WebhookParseError {
121            kind: WebhookParseErrorType::SegmentMissing,
122            source: None,
123        })?;
124
125        path.split('/')
126    };
127
128    let id_segment = segments.next().ok_or(WebhookParseError {
129        kind: WebhookParseErrorType::SegmentMissing,
130        source: None,
131    })?;
132
133    // If we don't have this check it'll return `IdInvalid`, which isn't right.
134    if id_segment.is_empty() {
135        return Err(WebhookParseError {
136            kind: WebhookParseErrorType::SegmentMissing,
137            source: None,
138        });
139    }
140
141    let id = id_segment
142        .parse::<NonZero<u64>>()
143        .map_err(|source| WebhookParseError {
144            kind: WebhookParseErrorType::IdInvalid,
145            source: Some(Box::new(source)),
146        })?;
147    let mut token = segments.next();
148
149    // Don't return an empty token if the segment is empty.
150    if token.is_some_and(str::is_empty) {
151        token = None;
152    }
153
154    Ok((Id::from(id), token))
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{WebhookParseError, WebhookParseErrorType};
160    use static_assertions::assert_impl_all;
161    use std::{error::Error, fmt::Debug};
162    use twilight_model::id::Id;
163
164    assert_impl_all!(WebhookParseErrorType: Debug, Send, Sync);
165    assert_impl_all!(WebhookParseError: Debug, Error, Send, Sync);
166
167    #[test]
168    fn parse_no_token() {
169        assert_eq!(
170            (Id::new(123), None),
171            super::parse("https://discord.com/api/webhooks/123").unwrap(),
172        );
173        // There's a / after the ID signifying another segment, but the token
174        // ends up being None.
175        assert_eq!(
176            (Id::new(123), None),
177            super::parse("https://discord.com/api/webhooks/123").unwrap(),
178        );
179        assert!(
180            super::parse("https://discord.com/api/webhooks/123/")
181                .unwrap()
182                .1
183                .is_none()
184        );
185    }
186
187    #[test]
188    fn parse_with_token() {
189        assert_eq!(
190            super::parse("https://discord.com/api/webhooks/456/token").unwrap(),
191            (Id::new(456), Some("token")),
192        );
193        // The value of the segment(s) after the token are ignored.
194        assert_eq!(
195            super::parse("https://discord.com/api/webhooks/456/token/github").unwrap(),
196            (Id::new(456), Some("token")),
197        );
198        assert_eq!(
199            super::parse("https://discord.com/api/webhooks/456/token/slack").unwrap(),
200            (Id::new(456), Some("token")),
201        );
202        assert_eq!(
203            super::parse("https://discord.com/api/webhooks/456/token/randomsegment").unwrap(),
204            (Id::new(456), Some("token")),
205        );
206        assert_eq!(
207            super::parse("https://discord.com/api/webhooks/456/token/one/two/three").unwrap(),
208            (Id::new(456), Some("token")),
209        );
210    }
211
212    #[test]
213    fn parse_invalid() {
214        // Base URL is improper.
215        assert!(matches!(
216            super::parse("https://discord.com/foo/bar/456")
217                .unwrap_err()
218                .kind(),
219            &WebhookParseErrorType::SegmentMissing,
220        ));
221        // No ID is present.
222        assert!(matches!(
223            super::parse("https://discord.com/api/webhooks/")
224                .unwrap_err()
225                .kind(),
226            &WebhookParseErrorType::SegmentMissing,
227        ));
228        // ID segment isn't an integer.
229        assert!(matches!(
230            super::parse("https://discord.com/api/webhooks/notaninteger")
231                .unwrap_err()
232                .kind(),
233            &WebhookParseErrorType::IdInvalid,
234        ));
235    }
236}