use itertools::Itertools;
use serde::Serialize;
use thiserror::Error;
use tinytemplate::TinyTemplate;
use url::{ParseError, Url};
#[derive(Debug, Serialize)]
struct ExpandEnvironment {
path: String,
}
fn expand(input: &str, environment: &ExpandEnvironment) -> Result<String, GolinkError> {
let mut tt = TinyTemplate::new();
tt.add_template("url_input", input)?;
let rendered = tt.render("url_input", environment)?;
if input == rendered {
if let Ok(mut url) = Url::parse(input) {
if !environment.path.is_empty() {
let base_path = url.path().trim_end_matches('/');
url.set_path(&format!("{base_path}/{}", environment.path));
}
Ok(url.to_string())
} else if environment.path.is_empty() {
Ok(rendered)
} else {
Ok(format!("{rendered}/{}", environment.path))
}
} else {
Ok(rendered)
}
}
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum GolinkError {
#[error("Invalid input")]
InvalidInput,
#[error("Shortlink '{0}' not found")]
NotFound(String),
#[error("Template error: {0}")]
TemplateError(String),
}
impl From<ParseError> for GolinkError {
fn from(_: ParseError) -> Self {
GolinkError::InvalidInput
}
}
impl From<tinytemplate::error::Error> for GolinkError {
fn from(tt_error: tinytemplate::error::Error) -> Self {
GolinkError::TemplateError(tt_error.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GolinkResolution {
MetadataRequest(String),
RedirectRequest {
url: String,
shortlink: String,
},
}
#[must_use]
pub fn normalize_shortlink(input: &str) -> String {
let first_segment = input
.trim_start_matches('/')
.split('/')
.next()
.unwrap_or("");
normalize_segment(first_segment)
}
fn normalize_segment(segment: &str) -> String {
segment
.to_ascii_lowercase()
.replace('-', "")
.replace("%20", "")
.replace(' ', "")
}
struct ParsedInput {
short: String,
remainder: String,
is_metadata_request: bool,
}
fn parse_input(input: &str) -> Result<ParsedInput, GolinkError> {
let url = Url::parse(input).or_else(|_| Url::parse("https://go/")?.join(input))?;
let mut segments = url.path_segments().ok_or(GolinkError::InvalidInput)?;
let short = normalize_segment(segments.next().ok_or(GolinkError::InvalidInput)?);
if short.is_empty() {
return Err(GolinkError::InvalidInput);
}
let is_metadata_request = url.path().ends_with('+');
let remainder = segments.join("/");
Ok(ParsedInput {
short,
remainder,
is_metadata_request,
})
}
pub fn resolve<F>(input: &str, lookup: F) -> Result<GolinkResolution, GolinkError>
where
F: Fn(&str) -> Option<String>,
{
let parsed = parse_input(input)?;
if parsed.is_metadata_request {
return Ok(GolinkResolution::MetadataRequest(
parsed.short.trim_end_matches('+').to_string(),
));
}
let lookup_value =
lookup(&parsed.short).ok_or_else(|| GolinkError::NotFound(parsed.short.clone()))?;
let expansion = expand(
&lookup_value,
&ExpandEnvironment {
path: parsed.remainder,
},
)?;
Ok(GolinkResolution::RedirectRequest {
url: expansion,
shortlink: parsed.short,
})
}
pub async fn resolve_async<F, Fut>(input: &str, lookup: F) -> Result<GolinkResolution, GolinkError>
where
F: Fn(&str) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let parsed = parse_input(input)?;
if parsed.is_metadata_request {
return Ok(GolinkResolution::MetadataRequest(
parsed.short.trim_end_matches('+').to_string(),
));
}
let lookup_value = lookup(&parsed.short)
.await
.ok_or_else(|| GolinkError::NotFound(parsed.short.clone()))?;
let expansion = expand(
&lookup_value,
&ExpandEnvironment {
path: parsed.remainder,
},
)?;
Ok(GolinkResolution::RedirectRequest {
url: expansion,
shortlink: parsed.short,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn lookup(input: &str) -> Option<String> {
if input == "test" {
return Some("http://example.com/".to_string());
}
if input == "test2" {
return Some("http://example.com/test.html?a=b&c[]=d".to_string());
}
if input == "prs" {
return Some("https://github.com/pulls?q=is:open+is:pr+review-requested:{{ if path }}{ path }{{ else }}@me{{ endif }}+archived:false".to_string());
}
if input == "abcd" {
return Some("efgh".to_string());
}
None
}
#[test]
fn it_works() {
let computed = resolve("/test", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/".to_string(),
shortlink: "test".to_string()
})
)
}
#[test]
fn it_works_with_url() {
let computed = resolve("https://jil.im/test", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/".to_string(),
shortlink: "test".to_string()
})
)
}
#[test]
fn it_works_with_no_leading_slash() {
let computed = resolve("test", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/".to_string(),
shortlink: "test".to_string()
})
)
}
#[test]
fn it_works_for_complex_url() {
let computed = resolve("/test2", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/test.html?a=b&c[]=d".to_string(),
shortlink: "test2".to_string()
})
)
}
#[test]
fn it_ignores_case() {
let computed = resolve("/TEST", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/".to_string(),
shortlink: "test".to_string()
})
)
}
#[test]
fn it_ignores_hyphens() {
let computed = resolve("/t-est", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/".to_string(),
shortlink: "test".to_string()
})
)
}
#[test]
fn it_ignores_whitespace() {
let computed = resolve("/t est", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/".to_string(),
shortlink: "test".to_string()
})
)
}
#[test]
fn it_returns_metadata_request() {
let computed = resolve("/test+", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::MetadataRequest("test".to_string()))
)
}
#[test]
fn it_returns_correct_metadata_request_with_hyphens() {
let computed = resolve("/tEs-t+", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::MetadataRequest("test".to_string()))
)
}
#[test]
fn it_does_not_append_remaining_path_segments_with_invalid_resolved_url() {
let computed = resolve("/abcd/a/b/c", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "efgh/a/b/c".to_string(),
shortlink: "abcd".to_string()
})
)
}
#[test]
fn it_appends_remaining_path_segments() {
let computed = resolve("/test/a/b/c", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/a/b/c".to_string(),
shortlink: "test".to_string()
})
)
}
#[test]
fn it_appends_remaining_path_segments_for_maps_url() {
let computed = resolve("/test2/a/b/c", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/test.html/a/b/c?a=b&c[]=d".to_string(),
shortlink: "test2".to_string()
})
)
}
#[test]
fn it_uses_path_in_template() {
let computed = resolve("/prs/jameslittle230", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "https://github.com/pulls?q=is:open+is:pr+review-requested:jameslittle230+archived:false".to_string(),
shortlink: "prs".to_string()
})
)
}
#[test]
fn it_uses_fallback_in_template() {
let computed = resolve("/prs", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "https://github.com/pulls?q=is:open+is:pr+review-requested:@me+archived:false"
.to_string(),
shortlink: "prs".to_string()
})
)
}
#[test]
fn it_uses_fallback_in_template_with_trailing_slash() {
let computed = resolve("/prs/", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "https://github.com/pulls?q=is:open+is:pr+review-requested:@me+archived:false"
.to_string(),
shortlink: "prs".to_string()
})
)
}
#[test]
fn it_allows_the_long_url_to_not_be_a_valid_url() {
let computed = resolve("/abcd", &lookup);
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "efgh".to_string(),
shortlink: "abcd".to_string()
})
)
}
#[test]
fn normalize_shortlink_extracts_first_segment() {
assert_eq!(normalize_shortlink("foo/bar"), "foo");
assert_eq!(normalize_shortlink("/foo/bar/baz"), "foo");
assert_eq!(normalize_shortlink("My-Service/docs"), "myservice");
assert_eq!(normalize_shortlink("FOO/BAR"), "foo");
assert_eq!(normalize_shortlink("my service/other"), "myservice");
}
#[test]
fn it_fails_with_invalid_input_url() {
let computed = resolve("a:3gb", &lookup);
assert!(matches!(computed, Err(GolinkError::InvalidInput)));
}
#[test]
fn it_fails_with_empty_string() {
let computed = resolve("", &lookup);
assert!(matches!(computed, Err(GolinkError::InvalidInput)));
}
#[test]
fn it_fails_with_whitespace_only_string() {
let computed = resolve(" \n", &lookup);
assert!(matches!(computed, Err(GolinkError::InvalidInput)));
}
#[tokio::test]
async fn async_it_works() {
let computed = resolve_async("/test", |input| {
let input = input.to_string();
async move { lookup(&input) }
})
.await;
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/".to_string(),
shortlink: "test".to_string()
})
)
}
#[tokio::test]
async fn async_it_appends_remaining_path_segments() {
let computed = resolve_async("/test/a/b/c", |input| {
let input = input.to_string();
async move { lookup(&input) }
})
.await;
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "http://example.com/a/b/c".to_string(),
shortlink: "test".to_string()
})
)
}
#[tokio::test]
async fn async_it_uses_path_in_template() {
let computed = resolve_async("/prs/jameslittle230", |input| {
let input = input.to_string();
async move { lookup(&input) }
})
.await;
assert_eq!(
computed,
Ok(GolinkResolution::RedirectRequest {
url: "https://github.com/pulls?q=is:open+is:pr+review-requested:jameslittle230+archived:false".to_string(),
shortlink: "prs".to_string()
})
)
}
#[tokio::test]
async fn async_it_returns_metadata_request() {
let computed = resolve_async("/test+", |input| {
let input = input.to_string();
async move { lookup(&input) }
})
.await;
assert_eq!(
computed,
Ok(GolinkResolution::MetadataRequest("test".to_string()))
)
}
}