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            return oembed_url_builder(
66                &format!("{}/api/v0/cms/gutenberg/oembed/vimeo", base_url.unwrap()),
67                &format!(
68                    "url={}",
69                    utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
70                ),
71            );
72        }
73        if host.ends_with("menti.com") || host.ends_with("mentimeter.com") {
74            return oembed_url_builder(
75                &format!(
76                    "{}/api/v0/cms/gutenberg/oembed/mentimeter",
77                    base_url.unwrap()
78                ),
79                &format!(
80                    "url={}",
81                    utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
82                ),
83            );
84        }
85        if host.ends_with("thinglink.com") {
86            return oembed_url_builder(
87                &format!(
88                    "{}/api/v0/cms/gutenberg/oembed/thinglink",
89                    base_url.unwrap()
90                ),
91                &format!(
92                    "url={}",
93                    utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
94                ),
95            );
96        }
97        if host.ends_with("imgur.com") {
98            return oembed_url_builder(
99                "https://api.imgur.com/oembed",
100                &format!("url={}&maxwidth=780", url),
101            );
102        }
103        if host.ends_with("reddit.com") {
104            return oembed_url_builder(
105                "https://www.reddit.com/oembed",
106                &format!("url={}&format=json", url),
107            );
108        }
109        if host.ends_with("slideshare.net") {
110            return oembed_url_builder(
111                "https://www.slideshare.net/api/oembed/2",
112                &format!("url={}&format=json", url),
113            );
114        }
115        if host.ends_with("ted.com") {
116            return oembed_url_builder(
117                "https://www.ted.com/services/v1/oembed.json",
118                &format!("url={}", url),
119            );
120        }
121        if host.ends_with("tumblr.com") {
122            // Old tumblr api, v2 is latest, but WP uses 1.0
123            return oembed_url_builder(
124                "https://www.tumblr.com/oembed/1.0",
125                &format!("url={}&format=json", url),
126            );
127        }
128        Err(UtilError::new(
129            UtilErrorType::Other,
130            "Link not supported for embedding.".to_string(),
131            None,
132        ))
133    } else {
134        Err(UtilError::new(
135            UtilErrorType::Other,
136            "Failed to parse host from URL.".to_string(),
137            None,
138        ))
139    }
140}
141
142pub fn mentimeter_oembed_response_builder(
143    url: String,
144    base_url: String,
145) -> UtilResult<OEmbedResponse> {
146    let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
147        UtilError::new(
148            UtilErrorType::UrlParse,
149            "Failed to parse url".to_string(),
150            Some(e.into()),
151        )
152    })?;
153    // Get the height and title params
154    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
155    // We want to remove the query params so that the iframe src url doesn't have them
156    parsed_url.set_query(None);
157    let decoded_title = percent_decode_str(
158        params
159            .get("title")
160            .unwrap_or(&"Mentimeter%20embed".to_string()),
161    )
162    .decode_utf8()
163    .expect("Decoding title or default value for menti embed failed")
164    .to_string();
165
166    let response = OEmbedResponse {
167        author_name: "Mooc.fi".to_string(),
168        author_url: base_url,
169        html: format!(
170            "<iframe src={} style='width: 99%;' height={:?} title={:?}></iframe>",
171            parsed_url,
172            params.get("height").unwrap_or(&"500".to_string()),
173            decoded_title
174        ),
175        provider_name: "mentimeter".to_string(),
176        provider_url: parsed_url
177            .host_str()
178            .unwrap_or("https://www.mentimeter.com")
179            .to_string(),
180        title: decoded_title,
181        version: "1.0".to_string(),
182    };
183    Ok(response)
184}
185
186pub fn thinglink_oembed_response_builder(
187    url: String,
188    base_url: String,
189) -> UtilResult<OEmbedResponse> {
190    let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
191        UtilError::new(
192            UtilErrorType::UrlParse,
193            "Failed to parse url".to_string(),
194            Some(e.into()),
195        )
196    })?;
197    // Get the height and title params
198    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
199    // We want to remove the query params so that the iframe src url doesn't have them
200    parsed_url.set_query(None);
201    let decoded_title = percent_decode_str(
202        params
203            .get("title")
204            .unwrap_or(&"Thinlink%20embed".to_string()),
205    )
206    .decode_utf8()
207    .expect("Decoding title or default value for thinglink embed failed")
208    .to_string();
209
210    let response = OEmbedResponse {
211        author_name: "Mooc.fi".to_string(),
212        author_url: base_url,
213        html: format!(
214            "<iframe sandbox=\"allow-scripts allow-same-origin\" src=\"{}\" style=\"width: 99%;\" height={:?} title=\"{:?}\"></iframe>",
215            parsed_url,
216            params.get("height").unwrap_or(&"500".to_string()),
217            decoded_title
218        ),
219        provider_name: "thinglink".to_string(),
220        provider_url: parsed_url
221            .host_str()
222            .unwrap_or("https://www.thinglink.com/")
223            .to_string(),
224        title: decoded_title,
225        version: "1.0".to_string(),
226    };
227    Ok(response)
228}
229
230pub fn vimeo_oembed_response_builder(url: String, base_url: String) -> UtilResult<OEmbedResponse> {
231    let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
232        UtilError::new(
233            UtilErrorType::UrlParse,
234            "Failed to parse url".to_string(),
235            Some(e.into()),
236        )
237    })?;
238    // Get the height and title params
239    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
240    // We want to remove the query params so that the iframe src url doesn't have them
241    parsed_url.set_query(None);
242    let decoded_title =
243        percent_decode_str(params.get("title").unwrap_or(&"Vimeo%20embed".to_string()))
244            .decode_utf8()
245            .expect("Decoding title or default value for vimeo embed failed")
246            .to_string();
247
248    let path = parsed_url.path();
249    let video_id = extract_numerical_id(path).ok_or_else(|| {
250        UtilError::new(
251            UtilErrorType::Other,
252            "Could not find video id from url".to_string(),
253            None,
254        )
255    })?;
256
257    let mut iframe_url = Url::parse("https://player.vimeo.com").map_err(|e| {
258        UtilError::new(
259            UtilErrorType::UrlParse,
260            "Failed to parse url".to_string(),
261            Some(e.into()),
262        )
263    })?;
264    iframe_url.set_path(&format!("/video/{}", video_id));
265    iframe_url.set_query(Some("app_id=122963"));
266
267    let response = OEmbedResponse {
268        author_name: "Mooc.fi".to_string(),
269        author_url: base_url,
270        html: format!(
271            "<iframe sandbox=\"allow-scripts allow-same-origin\" frameborder=\"0\" src=\"{}\" style=\"width: 99%;\" height={:?} title=\"{:?}\"></iframe>",
272            iframe_url,
273            params.get("height").unwrap_or(&"500".to_string()),
274            decoded_title
275        ),
276        provider_name: "vimeo".to_string(),
277        provider_url: parsed_url
278            .host_str()
279            .unwrap_or("https://www.vimeo.com/")
280            .to_string(),
281        title: decoded_title,
282        version: "1.0".to_string(),
283    };
284    Ok(response)
285}
286
287fn oembed_url_builder(url: &str, query_params: &str) -> UtilResult<Url> {
288    let mut endpoint_url = Url::parse(url)?;
289    endpoint_url.set_query(Some(query_params));
290    Ok(endpoint_url)
291}
292
293fn extract_numerical_id(path: &str) -> Option<String> {
294    path.split('/')
295        .map(|o| o.trim())
296        .find(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
297        .map(|s| s.to_string())
298}
299
300#[cfg(test)]
301mod tests {
302    use url::Url;
303
304    use super::*;
305    #[test]
306    fn works_with_youtu_be() {
307        assert_eq!(
308            url_to_oembed_endpoint("https://youtu.be/dQw4w9WgXcQ".to_string(), None).unwrap(),
309            Url::parse(
310                "https://www.youtube.com/oembed?url=https://youtu.be/dQw4w9WgXcQ&format=json&maxwidth=780&maxheight=440"
311            )
312            .unwrap()
313        )
314    }
315    #[test]
316    fn works_with_youtube_com() {
317        assert_eq!(
318            url_to_oembed_endpoint("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string(), None)
319                .unwrap(),
320            Url::parse("https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&format=json&maxwidth=780&maxheight=440").unwrap()
321        )
322    }
323    #[test]
324    fn works_with_youtube_com_playlist() {
325        assert_eq!(
326            url_to_oembed_endpoint(
327                "https://www.youtube.com/playlist?list=gYLqDMMh1GEbHf-Q1".to_string(), None
328            )
329            .unwrap(),
330            Url::parse("https://www.youtube.com/oembed?url=https://www.youtube.com/playlist?list=gYLqDMMh1GEbHf-Q1&format=json&maxwidth=780&maxheight=440").unwrap()
331        )
332    }
333    #[test]
334    fn works_with_open_spotify_com() {
335        assert_eq!(url_to_oembed_endpoint(
336            "http://open.spotify.com/track/298gs9ATwKlQR".to_string(), None
337        )
338        .unwrap(),
339        Url::parse("https://embed.spotify.com/oembed?url=http://open.spotify.com/track/298gs9ATwKlQR&format=json&height=335&width=780").unwrap())
340    }
341    #[test]
342    fn works_with_flic_kr_com() {
343        assert_eq!(
344            url_to_oembed_endpoint("https://flic.kr/p/2jJ".to_string(), None).unwrap(),
345            Url::parse(
346                "https://www.flickr.com/services/oembed?url=https://flic.kr/p/2jJ&format=json&maxwidth=780&maxheight=780"
347            ).unwrap()
348        )
349    }
350
351    #[test]
352    fn works_with_thinglink() {
353        assert_eq!(
354            url_to_oembed_endpoint(
355                "https://www.thinglink.com/card/1205257932048957445".to_string(),
356                Some("http://project-331.local".to_string())
357            )
358            .unwrap()
359            .to_string(),
360            "http://project-331.local/api/v0/cms/gutenberg/oembed/thinglink?url=https%3A%2F%2Fwww%2Ethinglink%2Ecom%2Fcard%2F1205257932048957445"
361        )
362    }
363
364    #[test]
365    fn works_with_vimeo() {
366        assert_eq!(
367            url_to_oembed_endpoint(
368                "https://vimeo.com/275255674".to_string(),
369                Some("http://project-331.local".to_string())
370            )
371            .unwrap()
372            .to_string(),
373            "http://project-331.local/api/v0/cms/gutenberg/oembed/vimeo?url=https%3A%2F%2Fvimeo%2Ecom%2F275255674"
374        )
375    }
376}