headless_lms_utils/
url_to_oembed_endpoint.rs

1use std::collections::HashMap;
2
3use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
4use url::Url;
5
6use serde::{Deserialize, Serialize};
7
8#[cfg(feature = "ts_rs")]
9use ts_rs::TS;
10
11use crate::prelude::*;
12
13#[derive(Deserialize, Serialize)]
14#[cfg_attr(feature = "ts_rs", derive(TS))]
15pub struct OEmbedResponse {
16    pub author_name: String,
17    pub author_url: String,
18    pub html: String,
19    pub provider_name: String,
20    pub provider_url: String,
21    pub title: String,
22    pub version: String,
23}
24
25#[derive(Deserialize, Debug)]
26pub struct OEmbedRequest {
27    pub url: String,
28}
29
30// https://github.com/WordPress/wordpress-develop/blob/master/src/wp-includes/class-wp-oembed.php
31pub fn url_to_oembed_endpoint(url: String, base_url: Option<String>) -> UtilResult<Url> {
32    let parsed_url = Url::parse(url.as_str())?;
33    if let Some(host) = parsed_url.host_str() {
34        if host.ends_with("youtu.be") || host.ends_with("youtube.com") {
35            return oembed_url_builder(
36                "https://www.youtube.com/oembed",
37                &format!("url={}&format=json&maxwidth=780&maxheight=440", url),
38            );
39        }
40        if host.ends_with("twitter.com") {
41            return oembed_url_builder(
42                "https://publish.twitter.com/oembed",
43                &format!("url={}&maxwidth=780&maxheight=440", url),
44            );
45        }
46        if host.ends_with("soundcloud.com") {
47            return oembed_url_builder(
48                "https://soundcloud.com/oembed",
49                &format!("url={}&format=json&maxwidth=780&maxheight=440", url),
50            );
51        }
52        if host.ends_with("open.spotify.com") || host.ends_with("play.spotify.com") {
53            return oembed_url_builder(
54                "https://embed.spotify.com/oembed",
55                &format!("url={}&format=json&height=335&width=780", url),
56            );
57        }
58        if host.ends_with("flickr.com") || host.ends_with("flic.kr") {
59            return oembed_url_builder(
60                "https://www.flickr.com/services/oembed",
61                &format!("url={}&format=json&maxwidth=780&maxheight=780", url),
62            );
63        }
64        if host.ends_with("vimeo.com") {
65            let base = base_url.as_ref().ok_or_else(|| {
66                UtilError::new(
67                    UtilErrorType::Other,
68                    "base_url is required for vimeo oembed".to_string(),
69                    None,
70                )
71            })?;
72            return oembed_url_builder(
73                &format!("{}/api/v0/cms/gutenberg/oembed/vimeo", base),
74                &format!(
75                    "url={}",
76                    utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
77                ),
78            );
79        }
80        if host.ends_with("menti.com") || host.ends_with("mentimeter.com") {
81            let base = base_url.as_ref().ok_or_else(|| {
82                UtilError::new(
83                    UtilErrorType::Other,
84                    "base_url is required for mentimeter oembed".to_string(),
85                    None,
86                )
87            })?;
88            return oembed_url_builder(
89                &format!("{}/api/v0/cms/gutenberg/oembed/mentimeter", base),
90                &format!(
91                    "url={}",
92                    utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
93                ),
94            );
95        }
96        if host.ends_with("thinglink.com") {
97            let base = base_url.as_ref().ok_or_else(|| {
98                UtilError::new(
99                    UtilErrorType::Other,
100                    "base_url is required for thinglink oembed".to_string(),
101                    None,
102                )
103            })?;
104            return oembed_url_builder(
105                &format!("{}/api/v0/cms/gutenberg/oembed/thinglink", base),
106                &format!(
107                    "url={}",
108                    utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
109                ),
110            );
111        }
112        if host.ends_with("imgur.com") {
113            return oembed_url_builder(
114                "https://api.imgur.com/oembed",
115                &format!("url={}&maxwidth=780", url),
116            );
117        }
118        if host.ends_with("reddit.com") {
119            return oembed_url_builder(
120                "https://www.reddit.com/oembed",
121                &format!("url={}&format=json", url),
122            );
123        }
124        if host.ends_with("slideshare.net") {
125            return oembed_url_builder(
126                "https://www.slideshare.net/api/oembed/2",
127                &format!("url={}&format=json", url),
128            );
129        }
130        if host.ends_with("ted.com") {
131            return oembed_url_builder(
132                "https://www.ted.com/services/v1/oembed.json",
133                &format!("url={}", url),
134            );
135        }
136        if host.ends_with("tumblr.com") {
137            // Old tumblr api, v2 is latest, but WP uses 1.0
138            return oembed_url_builder(
139                "https://www.tumblr.com/oembed/1.0",
140                &format!("url={}&format=json", url),
141            );
142        }
143        Err(UtilError::new(
144            UtilErrorType::Other,
145            "Link not supported for embedding.".to_string(),
146            None,
147        ))
148    } else {
149        Err(UtilError::new(
150            UtilErrorType::Other,
151            "Failed to parse host from URL.".to_string(),
152            None,
153        ))
154    }
155}
156
157pub fn mentimeter_oembed_response_builder(
158    url: String,
159    base_url: String,
160) -> UtilResult<OEmbedResponse> {
161    let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
162        UtilError::new(
163            UtilErrorType::UrlParse,
164            "Failed to parse url".to_string(),
165            Some(e.into()),
166        )
167    })?;
168    // Get the height and title params
169    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
170    // We want to remove the query params so that the iframe src url doesn't have them
171    parsed_url.set_query(None);
172    let decoded_title = percent_decode_str(
173        params
174            .get("title")
175            .unwrap_or(&"Mentimeter%20embed".to_string()),
176    )
177    .decode_utf8()
178    .map_err(|_| {
179        UtilError::new(
180            UtilErrorType::Other,
181            "Failed to decode title parameter as UTF-8 for mentimeter embed".to_string(),
182            None,
183        )
184    })?
185    .to_string();
186
187    let response = OEmbedResponse {
188        author_name: "Mooc.fi".to_string(),
189        author_url: base_url,
190        html: format!(
191            "<iframe src={} style='width: 99%;' height={:?} title={:?}></iframe>",
192            parsed_url,
193            params.get("height").unwrap_or(&"500".to_string()),
194            decoded_title
195        ),
196        provider_name: "mentimeter".to_string(),
197        provider_url: parsed_url
198            .host_str()
199            .unwrap_or("https://www.mentimeter.com")
200            .to_string(),
201        title: decoded_title,
202        version: "1.0".to_string(),
203    };
204    Ok(response)
205}
206
207pub fn thinglink_oembed_response_builder(
208    url: String,
209    base_url: String,
210) -> UtilResult<OEmbedResponse> {
211    let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
212        UtilError::new(
213            UtilErrorType::UrlParse,
214            "Failed to parse url".to_string(),
215            Some(e.into()),
216        )
217    })?;
218    // Get the height and title params
219    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
220    // We want to remove the query params so that the iframe src url doesn't have them
221    parsed_url.set_query(None);
222    let decoded_title = percent_decode_str(
223        params
224            .get("title")
225            .unwrap_or(&"Thinlink%20embed".to_string()),
226    )
227    .decode_utf8()
228    .map_err(|_| {
229        UtilError::new(
230            UtilErrorType::Other,
231            "Failed to decode title parameter as UTF-8 for thinglink embed".to_string(),
232            None,
233        )
234    })?
235    .to_string();
236
237    let response = OEmbedResponse {
238        author_name: "Mooc.fi".to_string(),
239        author_url: base_url,
240        html: format!(
241            "<iframe sandbox=\"allow-scripts allow-same-origin\" src=\"{}\" style=\"width: 99%;\" height={:?} title=\"{:?}\"></iframe>",
242            parsed_url,
243            params.get("height").unwrap_or(&"500".to_string()),
244            decoded_title
245        ),
246        provider_name: "thinglink".to_string(),
247        provider_url: parsed_url
248            .host_str()
249            .unwrap_or("https://www.thinglink.com/")
250            .to_string(),
251        title: decoded_title,
252        version: "1.0".to_string(),
253    };
254    Ok(response)
255}
256
257pub fn vimeo_oembed_response_builder(url: String, base_url: String) -> UtilResult<OEmbedResponse> {
258    let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
259        UtilError::new(
260            UtilErrorType::UrlParse,
261            "Failed to parse url".to_string(),
262            Some(e.into()),
263        )
264    })?;
265    // Get the height and title params
266    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
267    // We want to remove the query params so that the iframe src url doesn't have them
268    parsed_url.set_query(None);
269    let decoded_title =
270        percent_decode_str(params.get("title").unwrap_or(&"Vimeo%20embed".to_string()))
271            .decode_utf8()
272            .map_err(|_| {
273                UtilError::new(
274                    UtilErrorType::Other,
275                    "Failed to decode title parameter as UTF-8 for vimeo embed".to_string(),
276                    None,
277                )
278            })?
279            .to_string();
280
281    let path = parsed_url.path();
282    let video_id = extract_numerical_id(path).ok_or_else(|| {
283        UtilError::new(
284            UtilErrorType::Other,
285            "Could not find video id from url".to_string(),
286            None,
287        )
288    })?;
289
290    let mut iframe_url = Url::parse("https://player.vimeo.com").map_err(|e| {
291        UtilError::new(
292            UtilErrorType::UrlParse,
293            "Failed to parse url".to_string(),
294            Some(e.into()),
295        )
296    })?;
297    iframe_url.set_path(&format!("/video/{}", video_id));
298    iframe_url.set_query(Some("app_id=122963"));
299
300    let response = OEmbedResponse {
301        author_name: "Mooc.fi".to_string(),
302        author_url: base_url,
303        html: format!(
304            "<iframe sandbox=\"allow-scripts allow-same-origin\" frameborder=\"0\" src=\"{}\" style=\"width: 99%;\" height={:?} title=\"{:?}\"></iframe>",
305            iframe_url,
306            params.get("height").unwrap_or(&"500".to_string()),
307            decoded_title
308        ),
309        provider_name: "vimeo".to_string(),
310        provider_url: parsed_url
311            .host_str()
312            .unwrap_or("https://www.vimeo.com/")
313            .to_string(),
314        title: decoded_title,
315        version: "1.0".to_string(),
316    };
317    Ok(response)
318}
319
320fn oembed_url_builder(url: &str, query_params: &str) -> UtilResult<Url> {
321    let mut endpoint_url = Url::parse(url)?;
322    endpoint_url.set_query(Some(query_params));
323    Ok(endpoint_url)
324}
325
326fn extract_numerical_id(path: &str) -> Option<String> {
327    path.split('/')
328        .map(|o| o.trim())
329        .find(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
330        .map(|s| s.to_string())
331}
332
333#[cfg(test)]
334mod tests {
335    use url::Url;
336
337    use super::*;
338    #[test]
339    fn works_with_youtu_be() {
340        assert_eq!(
341            url_to_oembed_endpoint("https://youtu.be/dQw4w9WgXcQ".to_string(), None).unwrap(),
342            Url::parse(
343                "https://www.youtube.com/oembed?url=https://youtu.be/dQw4w9WgXcQ&format=json&maxwidth=780&maxheight=440"
344            )
345            .unwrap()
346        )
347    }
348    #[test]
349    fn works_with_youtube_com() {
350        assert_eq!(
351            url_to_oembed_endpoint("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string(), None)
352                .unwrap(),
353            Url::parse("https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&format=json&maxwidth=780&maxheight=440").unwrap()
354        )
355    }
356    #[test]
357    fn works_with_youtube_com_playlist() {
358        assert_eq!(
359            url_to_oembed_endpoint(
360                "https://www.youtube.com/playlist?list=gYLqDMMh1GEbHf-Q1".to_string(), None
361            )
362            .unwrap(),
363            Url::parse("https://www.youtube.com/oembed?url=https://www.youtube.com/playlist?list=gYLqDMMh1GEbHf-Q1&format=json&maxwidth=780&maxheight=440").unwrap()
364        )
365    }
366    #[test]
367    fn works_with_open_spotify_com() {
368        assert_eq!(url_to_oembed_endpoint(
369            "http://open.spotify.com/track/298gs9ATwKlQR".to_string(), None
370        )
371        .unwrap(),
372        Url::parse("https://embed.spotify.com/oembed?url=http://open.spotify.com/track/298gs9ATwKlQR&format=json&height=335&width=780").unwrap())
373    }
374    #[test]
375    fn works_with_flic_kr_com() {
376        assert_eq!(
377            url_to_oembed_endpoint("https://flic.kr/p/2jJ".to_string(), None).unwrap(),
378            Url::parse(
379                "https://www.flickr.com/services/oembed?url=https://flic.kr/p/2jJ&format=json&maxwidth=780&maxheight=780"
380            ).unwrap()
381        )
382    }
383
384    #[test]
385    fn works_with_thinglink() {
386        assert_eq!(
387            url_to_oembed_endpoint(
388                "https://www.thinglink.com/card/1205257932048957445".to_string(),
389                Some("http://project-331.local".to_string())
390            )
391            .unwrap()
392            .to_string(),
393            "http://project-331.local/api/v0/cms/gutenberg/oembed/thinglink?url=https%3A%2F%2Fwww%2Ethinglink%2Ecom%2Fcard%2F1205257932048957445"
394        )
395    }
396
397    #[test]
398    fn works_with_vimeo() {
399        assert_eq!(
400            url_to_oembed_endpoint(
401                "https://vimeo.com/275255674".to_string(),
402                Some("http://project-331.local".to_string())
403            )
404            .unwrap()
405            .to_string(),
406            "http://project-331.local/api/v0/cms/gutenberg/oembed/vimeo?url=https%3A%2F%2Fvimeo%2Ecom%2F275255674"
407        )
408    }
409}