golink/
lib.rs

1//! # Golink
2//!
3//! The Golink crate is an engine for resolving URLs for link shortening services.
4//! You provide a link to expand and a function for mapping short URLs to long URLs,
5//! and this crate will:
6//!
7//! - **Normalize your input to ignore case and hyphenation**: `http://go/My-Service`
8//! and `http://go/myservice` are treated as the same input into your mapping function
9//!
10//! - **Append secondary paths to your resolved URL**: if your mapping function returns
11//! `http://example.com` for the given shortlink `foo`, then a request to `http://go/foo/bar/baz`
12//! will resolve to `http://example.com/foo/bar/baz`
13//!
14//! - **Apply templating, when applicable**: Using a simple templating language, your long URLs
15//! can powerfully place remaining path segments in your URL ad-hoc and provide a fallback
16//! value when there are no remaining path segments. For example, if your mapping function
17//! returns for the given shortlink `prs` the following URL:
18//!
19//!     ```text
20//!     https://github.com/pulls?q=is:open+is:pr+review-requested:{{ if path }}{ path }{{ else }}@me{{ endif }}+archived:false
21//!     ```
22//!
23//!     then a request to `http://go/prs` returns the URL to all Github PRs to which
24//!     you are assigned:
25//!
26//!     ```text
27//!     https://github.com/pulls?q=is:open+is:pr+review-requested:@me+archived:false
28//!     ```
29//!
30//!     and a request to `http://go/prs/jameslittle230` returns the URL to all
31//!     Github PRs to which I ([@jameslittle230](https://github.com/jameslittle230))
32//!     am assigned:
33//!
34//!     ```text
35//!     https://github.com/pulls?q=is:open+is:pr+review-requested:jameslittle230+archived:false
36//!     ```
37//!
38//! This resolver performs all the functionality described in [Tailscale's Golink
39//! project](https://tailscale.com/blog/golink/)
40//!
41//! This crate doesn't provide a web service or an interface for creating shortened links;
42//! it only provides an algorithm for resolving short URLs to long URLs.
43//!
44//! ## Usage
45//!
46//! The Golink crate doesn't care how you store or retrieve long URLs given a short URL;
47//! you can store them in memory, in a database, or on disk, as long as they are retrievable
48//! from within a closure you pass into the `resolve()` function:
49//!
50//! ```rust
51//! fn lookup(input: &str) -> Option<String> {
52//!     if input == "foo" {
53//!         return Some("http://example.com".to_string());
54//!     }
55//!     None
56//! }
57//!
58//! let resolved = golink::resolve("/foo", &lookup);
59//!  //         or golink::resolve("foo", &lookup);
60//!  //         or golink::resolve("https://example.com/foo", &lookup);
61//!
62//! match resolved {
63//!    Ok(golink::GolinkResolution::RedirectRequest(url, shortname)) => {
64//!        // Redirect to `url`
65//!        // If you collect analytics, then increment the click count for shortname
66//!    }
67//!
68//!    Ok(golink::GolinkResolution::MetadataRequest(key)) => {
69//!        // `key` is the original shortlink.
70//!        // Return JSON that displays metadata/analytics about `key`
71//!    }
72//!
73//!    Err(e) => {
74//!        // Return a 400 error to the user, with a message based on `e`
75//!    }
76//! }
77//! ```
78
79use itertools::Itertools;
80use serde::Serialize;
81use thiserror::Error;
82use tinytemplate::TinyTemplate;
83use url::{ParseError, Url};
84
85#[derive(Debug, Serialize)]
86struct ExpandEnvironment {
87    path: String,
88}
89
90fn expand(input: &str, environment: ExpandEnvironment) -> Result<String, GolinkError> {
91    let mut tt = TinyTemplate::new();
92    tt.add_template("url_input", input)?;
93    let rendered = tt.render("url_input", &environment)?;
94
95    // If rendering didn't result in a different output, assume there is no render
96    // syntax in our long value and instead append the incoming remainder path onto the
97    // expanded URL's path
98    if input == rendered {
99        if let Some(mut url) = Url::parse(input).ok() {
100            if !environment.path.is_empty() {
101                url.set_path(&vec![url.path().trim_end_matches('/'), &environment.path].join("/"));
102            }
103
104            return Ok(url.to_string());
105        } else {
106            return Ok(format!("{rendered}/{}", environment.path));
107        }
108    }
109    return Ok(rendered);
110}
111
112#[derive(Error, Debug, Clone, PartialEq, Eq)]
113pub enum GolinkError {
114    #[error("String could not be parsed as URL")]
115    UrlParseError(#[from] ParseError),
116
117    #[error("Could not pull path segments from the input value")]
118    InvalidInputUrl,
119
120    #[error("No first path segment")]
121    NoFirstPathSegment,
122
123    #[error("Could not parse template correctly")]
124    ImproperTemplate(String),
125
126    #[error("Key {0} not found in lookup function")]
127    NotFound(String),
128}
129
130impl From<tinytemplate::error::Error> for GolinkError {
131    fn from(tt_error: tinytemplate::error::Error) -> Self {
132        GolinkError::ImproperTemplate(tt_error.to_string())
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum GolinkResolution {
138    MetadataRequest(String),
139    RedirectRequest(String, String),
140}
141
142pub fn resolve(
143    input: &str,
144    lookup: &dyn Fn(&str) -> Option<String>,
145) -> Result<GolinkResolution, GolinkError> {
146    let url = Url::parse(input).or_else(|_| Url::parse("https://go/")?.join(input))?;
147    let mut segments = url.path_segments().ok_or(GolinkError::InvalidInputUrl)?;
148    let short = segments
149        .next()
150        .ok_or(GolinkError::NoFirstPathSegment)?
151        .to_ascii_lowercase()
152        .replace('-', "")
153        .replace("%20", "");
154
155    if short.is_empty() {
156        return Err(GolinkError::NoFirstPathSegment);
157    }
158
159    if {
160        let this = &url.path().chars().last();
161        let f = |char| char == &'+';
162        matches!(this, Some(x) if f(x))
163    } {
164        return Ok(GolinkResolution::MetadataRequest(
165            short.trim_end_matches('+').to_owned(),
166        ));
167    }
168
169    let remainder = segments.join("/");
170
171    let lookup_value = lookup(&short).ok_or_else(|| GolinkError::NotFound(short.clone()))?;
172
173    let expansion = expand(&lookup_value, ExpandEnvironment { path: remainder })?;
174
175    Ok(GolinkResolution::RedirectRequest(expansion, short))
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use pretty_assertions::assert_eq;
182
183    fn lookup(input: &str) -> Option<String> {
184        if input == "test" {
185            return Some("http://example.com/".to_string());
186        }
187        if input == "test2" {
188            return Some("http://example.com/test.html?a=b&c[]=d".to_string());
189        }
190        if input == "prs" {
191            return Some("https://github.com/pulls?q=is:open+is:pr+review-requested:{{ if path }}{ path }{{ else }}@me{{ endif }}+archived:false".to_string());
192        }
193        if input == "abcd" {
194            return Some("efgh".to_string());
195        }
196        None
197    }
198
199    #[test]
200    fn it_works() {
201        let computed = resolve("/test", &lookup);
202        assert_eq!(
203            computed,
204            Ok(GolinkResolution::RedirectRequest(
205                "http://example.com/".to_string(),
206                "test".to_string()
207            ))
208        )
209    }
210
211    #[test]
212    fn it_works_with_url() {
213        let computed = resolve("https://jil.im/test", &lookup);
214        assert_eq!(
215            computed,
216            Ok(GolinkResolution::RedirectRequest(
217                "http://example.com/".to_string(),
218                "test".to_string()
219            ))
220        )
221    }
222
223    #[test]
224    fn it_works_with_no_leading_slash() {
225        let computed = resolve("test", &lookup);
226        assert_eq!(
227            computed,
228            Ok(GolinkResolution::RedirectRequest(
229                "http://example.com/".to_string(),
230                "test".to_string()
231            ))
232        )
233    }
234
235    #[test]
236    fn it_works_for_complex_url() {
237        let computed = resolve("/test2", &lookup);
238        assert_eq!(
239            computed,
240            Ok(GolinkResolution::RedirectRequest(
241                "http://example.com/test.html?a=b&c[]=d".to_string(),
242                "test2".to_string()
243            ))
244        )
245    }
246
247    #[test]
248    fn it_ignores_case() {
249        let computed = resolve("/TEST", &lookup);
250        assert_eq!(
251            computed,
252            Ok(GolinkResolution::RedirectRequest(
253                "http://example.com/".to_string(),
254                "test".to_string()
255            ))
256        )
257    }
258
259    #[test]
260    fn it_ignores_hyphens() {
261        let computed = resolve("/t-est", &lookup);
262        assert_eq!(
263            computed,
264            Ok(GolinkResolution::RedirectRequest(
265                "http://example.com/".to_string(),
266                "test".to_string()
267            ))
268        )
269    }
270
271    #[test]
272    fn it_ignores_whitespace() {
273        let computed = resolve("/t est", &lookup);
274        assert_eq!(
275            computed,
276            Ok(GolinkResolution::RedirectRequest(
277                "http://example.com/".to_string(),
278                "test".to_string()
279            ))
280        )
281    }
282
283    #[test]
284    fn it_returns_metadata_request() {
285        let computed = resolve("/test+", &lookup);
286        assert_eq!(
287            computed,
288            Ok(GolinkResolution::MetadataRequest("test".to_string()))
289        )
290    }
291
292    #[test]
293    fn it_returns_correct_metadata_request_with_hyphens() {
294        let computed = resolve("/tEs-t+", &lookup);
295        assert_eq!(
296            computed,
297            Ok(GolinkResolution::MetadataRequest("test".to_string()))
298        )
299    }
300
301    #[test]
302    fn it_does_not_append_remaining_path_segments_with_invalid_resolved_url() {
303        let computed = resolve("/abcd/a/b/c", &lookup);
304        assert_eq!(
305            computed,
306            Ok(GolinkResolution::RedirectRequest(
307                "efgh/a/b/c".to_string(),
308                "abcd".to_string()
309            ))
310        )
311    }
312
313    #[test]
314    fn it_appends_remaining_path_segments() {
315        let computed = resolve("/test/a/b/c", &lookup);
316        assert_eq!(
317            computed,
318            Ok(GolinkResolution::RedirectRequest(
319                "http://example.com/a/b/c".to_string(),
320                "test".to_string()
321            ))
322        )
323    }
324
325    #[test]
326    fn it_appends_remaining_path_segments_for_maps_url() {
327        let computed = resolve("/test2/a/b/c", &lookup);
328        assert_eq!(
329            computed,
330            Ok(GolinkResolution::RedirectRequest(
331                "http://example.com/test.html/a/b/c?a=b&c[]=d".to_string(),
332                "test2".to_string()
333            ))
334        )
335    }
336
337    #[test]
338    fn it_uses_path_in_template() {
339        let computed = resolve("/prs/jameslittle230", &lookup);
340        assert_eq!(
341            computed,
342            Ok(GolinkResolution::RedirectRequest(
343                "https://github.com/pulls?q=is:open+is:pr+review-requested:jameslittle230+archived:false".to_string(),
344                "prs".to_string()
345            ))
346        )
347    }
348
349    #[test]
350    fn it_uses_fallback_in_template() {
351        let computed = resolve("/prs", &lookup);
352        assert_eq!(
353            computed,
354            Ok(GolinkResolution::RedirectRequest(
355                "https://github.com/pulls?q=is:open+is:pr+review-requested:@me+archived:false"
356                    .to_string(),
357                "prs".to_string()
358            ))
359        )
360    }
361
362    #[test]
363    fn it_uses_fallback_in_template_with_trailing_slash() {
364        let computed = resolve("/prs/", &lookup);
365        assert_eq!(
366            computed,
367            Ok(GolinkResolution::RedirectRequest(
368                "https://github.com/pulls?q=is:open+is:pr+review-requested:@me+archived:false"
369                    .to_string(),
370                "prs".to_string()
371            ))
372        )
373    }
374
375    #[test]
376    fn it_allows_the_long_url_to_not_be_a_valid_url() {
377        let computed = resolve("/abcd", &lookup);
378        assert_eq!(
379            computed,
380            Ok(GolinkResolution::RedirectRequest(
381                "efgh".to_string(),
382                "abcd".to_string()
383            ))
384        )
385    }
386
387    #[test]
388    fn it_fails_with_invalid_input_url() {
389        let computed = resolve("a:3gb", &lookup);
390        assert_eq!(computed, Err(GolinkError::InvalidInputUrl))
391    }
392
393    #[test]
394    fn it_fails_with_empty_string() {
395        let computed = resolve("", &lookup);
396        assert_eq!(computed, Err(GolinkError::NoFirstPathSegment))
397    }
398
399    #[test]
400    fn it_fails_with_whitespace_only_string() {
401        let computed = resolve("  \n", &lookup);
402        assert_eq!(computed, Err(GolinkError::NoFirstPathSegment))
403    }
404}