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::NonZeroU64,
10};
11use twilight_model::id::{marker::WebhookMarker, Id};
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::<NonZeroU64>()
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!(super::parse("https://discord.com/api/webhooks/123/")
180            .unwrap()
181            .1
182            .is_none());
183    }
184
185    #[test]
186    fn parse_with_token() {
187        assert_eq!(
188            super::parse("https://discord.com/api/webhooks/456/token").unwrap(),
189            (Id::new(456), Some("token")),
190        );
191        // The value of the segment(s) after the token are ignored.
192        assert_eq!(
193            super::parse("https://discord.com/api/webhooks/456/token/github").unwrap(),
194            (Id::new(456), Some("token")),
195        );
196        assert_eq!(
197            super::parse("https://discord.com/api/webhooks/456/token/slack").unwrap(),
198            (Id::new(456), Some("token")),
199        );
200        assert_eq!(
201            super::parse("https://discord.com/api/webhooks/456/token/randomsegment").unwrap(),
202            (Id::new(456), Some("token")),
203        );
204        assert_eq!(
205            super::parse("https://discord.com/api/webhooks/456/token/one/two/three").unwrap(),
206            (Id::new(456), Some("token")),
207        );
208    }
209
210    #[test]
211    fn parse_invalid() {
212        // Base URL is improper.
213        assert!(matches!(
214            super::parse("https://discord.com/foo/bar/456")
215                .unwrap_err()
216                .kind(),
217            &WebhookParseErrorType::SegmentMissing,
218        ));
219        // No ID is present.
220        assert!(matches!(
221            super::parse("https://discord.com/api/webhooks/")
222                .unwrap_err()
223                .kind(),
224            &WebhookParseErrorType::SegmentMissing,
225        ));
226        // ID segment isn't an integer.
227        assert!(matches!(
228            super::parse("https://discord.com/api/webhooks/notaninteger")
229                .unwrap_err()
230                .kind(),
231            &WebhookParseErrorType::IdInvalid,
232        ));
233    }
234}