Skip to main content

headless_lms_utils/
course_url.rs

1use url::Url;
2
3pub fn build_course_url(base_url: &str, org_slug: &str, course_slug: &str) -> String {
4    format!(
5        "{}/org/{}/courses/{}",
6        base_url.trim_end_matches('/'),
7        org_slug,
8        course_slug,
9    )
10}
11
12pub fn build_courses_base_url(base_url: &str, org_slug: &str) -> String {
13    format!(
14        "{}/org/{}/courses",
15        base_url.trim_end_matches('/'),
16        org_slug,
17    )
18}
19
20/// The public URL of a course material page, or `None` when `base_url` is not a valid URL.
21pub fn course_page_url(
22    base_url: &str,
23    organization_slug: &str,
24    course_slug: &str,
25    page_url_path: &str,
26) -> Option<String> {
27    let mut url = Url::parse(base_url).ok()?;
28    // `set_path` percent-encodes, which the stored path needs: it keeps non-ASCII verbatim.
29    url.set_path(&format!(
30        "/org/{organization_slug}/courses/{course_slug}{page_url_path}"
31    ));
32    Some(url.to_string())
33}
34
35/// A course or page named by a course site URL.
36#[derive(Debug, PartialEq, Eq)]
37pub struct CoursePageRef {
38    pub course_slug: String,
39    /// The page path, percent-encoded as in the URL; `None` for the course URL itself.
40    pub page_url_path: Option<String>,
41}
42
43/// Why [`parse_course_page_url`] didn't name a course.
44#[derive(Debug, PartialEq, Eq)]
45pub enum CourseUrlError {
46    /// The configured `base_url` isn't a URL.
47    InvalidBaseUrl,
48    /// The URL is on another origin than `base_url`.
49    OtherSite,
50    /// The URL isn't `/org/<organization>/courses/<course>/<page path>`.
51    NotACourseUrl,
52}
53
54/// The course and page of a course site URL on `base_url`'s origin; the inverse of
55/// [`course_page_url`]. The organization segment is ignored, as on the course site.
56pub fn parse_course_page_url(base_url: &str, url: &str) -> Result<CoursePageRef, CourseUrlError> {
57    let base = Url::parse(base_url).map_err(|_| CourseUrlError::InvalidBaseUrl)?;
58    let parsed = Url::parse(url).map_err(|_| CourseUrlError::NotACourseUrl)?;
59    if parsed.origin() != base.origin() {
60        return Err(CourseUrlError::OtherSite);
61    }
62    let segments: Vec<&str> = parsed
63        .path_segments()
64        .ok_or(CourseUrlError::NotACourseUrl)?
65        .filter(|segment| !segment.is_empty())
66        .collect();
67    match segments.as_slice() {
68        ["org", _, "courses", course, page @ ..] => Ok(CoursePageRef {
69            course_slug: course.to_string(),
70            page_url_path: (!page.is_empty()).then(|| format!("/{}", page.join("/"))),
71        }),
72        _ => Err(CourseUrlError::NotACourseUrl),
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn course_page_url_percent_encodes_the_stored_path() {
82        assert_eq!(
83            course_page_url(
84                "https://courses.mooc.fi",
85                "uh-cs",
86                "java",
87                "/chapter-1/tehtävä"
88            )
89            .as_deref(),
90            Some("https://courses.mooc.fi/org/uh-cs/courses/java/chapter-1/teht%C3%A4v%C3%A4")
91        );
92    }
93
94    #[test]
95    fn course_page_url_needs_a_valid_base_url() {
96        assert_eq!(course_page_url("not a url", "org", "course", "/"), None);
97    }
98}