Created
January 23, 2025 12:51
-
-
Save ShabbirHasan1/3f10b4a42a067d096e87b3e9abb0768a to your computer and use it in GitHub Desktop.
interview task "url shortener" code review [https://www.reddit.com/r/rust/comments/1i73b6l/interview_task_url_shortener_code_review/]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//! ## Task Description | |
//! | |
//! The goal is to develop a backend service for shortening URLs using CQRS | |
//! (Command Query Responsibility Segregation) and ES (Event Sourcing) | |
//! approaches. The service should support the following features: | |
//! | |
//! ## Functional Requirements | |
//! | |
//! ### Creating a short link with a random slug | |
//! | |
//! The user sends a long URL, and the service returns a shortened URL with a | |
//! random slug. | |
//! | |
//! ### Creating a short link with a predefined slug | |
//! | |
//! The user sends a long URL along with a predefined slug, and the service | |
//! checks if the slug is unique. If it is unique, the service creates the short | |
//! link. | |
//! | |
//! ### Counting the number of redirects for the link | |
//! | |
//! - Every time a user accesses the short link, the click count should | |
//! increment. | |
//! - The click count can be retrieved via an API. | |
//! | |
//! ### CQRS+ES Architecture | |
//! | |
//! CQRS: Commands (creating links, updating click count) are separated from | |
//! queries (retrieving link information). | |
//! | |
//! Event Sourcing: All state changes (link creation, click count update) must be | |
//! recorded as events, which can be replayed to reconstruct the system's state. | |
//! | |
//! ### Technical Requirements | |
//! | |
//! - The service must be built using CQRS and Event Sourcing approaches. | |
//! - The service must be possible to run in Rust Playground (so no database like | |
//! Postgres is allowed) | |
//! - Public API already written for this task must not be changed (any change to | |
//! the public API items must be considered as breaking change). | |
//! - Event Sourcing should be actively utilized for implementing logic, rather | |
//! than existing without a clear purpose. | |
#![allow(unused_variables, dead_code)] | |
use std::{ | |
collections::HashMap, | |
sync::{ | |
mpsc::{sync_channel, Receiver, SyncSender}, | |
Arc, RwLock, | |
}, | |
thread, | |
time::Instant, | |
}; | |
use helpers::UrlShortenerHelper; | |
use log::{SlugActionType, StatLog}; | |
static EMPTY_STRING: &str = ""; | |
static HASH_LENGTH: usize = 10; | |
pub(crate) mod helpers { | |
use super::{ShortenerError, Slug, Url, EMPTY_STRING, HASH_LENGTH}; | |
use rand::distributions::{Alphanumeric, DistString}; | |
use regex::Regex; | |
pub struct UrlShortenerHelper; | |
impl UrlShortenerHelper { | |
pub fn generate_slug() -> Slug { | |
Slug(Alphanumeric.sample_string(&mut rand::thread_rng(), HASH_LENGTH)) | |
} | |
pub fn validate_url(url: &Url) -> Result<(), ShortenerError> { | |
UrlShortenerRegexHelper::get_re_validator() | |
.find(&url.0) | |
.map(|_| ()) | |
.ok_or(ShortenerError::InvalidUrl) | |
} | |
pub fn get_url_path(url: &Url) -> Url { | |
let result = | |
UrlShortenerRegexHelper::get_re_prefix_with_domain().replace(&url.0, EMPTY_STRING); | |
Url(result.into_owned()) | |
} | |
} | |
pub struct UrlShortenerRegexHelper; | |
impl UrlShortenerRegexHelper { | |
pub fn get_re_validator() -> Regex { | |
Regex::new(r"^(?i)((ftp|http|https):\/\/)?(www.)?[a-zA-Z0-9-]+.[a-zA-Z0-9-]{2,3}((\/)?(\w+)?)*((\?)(\w+=\w+))?$").unwrap() | |
} | |
pub fn get_re_prefix_with_domain() -> Regex { | |
Regex::new(r"^(?i)((ht|f)tps?:\/\/)?(www.)?([0-9a-z-]+)(.)([a-z]{2,3})").unwrap() | |
} | |
} | |
} | |
/// All possible errors of the [`UrlShortenerService`]. | |
#[derive(Debug, PartialEq)] | |
pub enum ShortenerError { | |
/// This error occurs when an invalid [`Url`] is provided for shortening. | |
InvalidUrl, | |
/// This error occurs when an attempt is made to use a slug (custom alias) | |
/// that already exists. | |
SlugAlreadyInUse, | |
/// This error occurs when the provided [`Slug`] does not map to any existing | |
/// short link. | |
SlugNotFound, | |
} | |
/// A unique string (or alias) that represents the shortened version of the | |
/// URL. | |
#[derive(Clone, Debug, PartialEq)] | |
pub struct Slug(pub String); | |
/// The original URL that the short link points to. | |
#[derive(Clone, Debug, PartialEq)] | |
pub struct Url(pub String); | |
/// Shortened URL representation. | |
#[derive(Debug, Clone, PartialEq)] | |
pub struct ShortLink { | |
/// A unique string (or alias) that represents the shortened version of the | |
/// URL. | |
pub slug: Slug, | |
/// The original URL that the short link points to. | |
pub url: Url, | |
} | |
/// Statistics of the [`ShortLink`]. | |
#[derive(Debug, Clone, PartialEq)] | |
pub struct Stats { | |
/// [`ShortLink`] to which this [`Stats`] are related. | |
pub link: ShortLink, | |
/// Count of redirects of the [`ShortLink`]. | |
pub redirects: u64, | |
} | |
pub mod log { | |
use super::{Slug, Url}; | |
use std::time::Instant; | |
#[derive(Debug, Clone, PartialEq)] | |
pub enum SlugActionType { | |
CREATE, | |
REDIRECT, | |
DELETE, | |
} | |
#[derive(Debug, Clone, PartialEq)] | |
pub struct StatLog { | |
pub slug: Slug, | |
pub url: Url, | |
pub action_type: SlugActionType, | |
pub created: Instant, | |
} | |
} | |
/// Commands for CQRS. | |
pub mod commands { | |
use super::{ShortLink, ShortenerError, Slug, Url}; | |
/// Trait for command handlers. | |
pub trait CommandHandler { | |
/// Creates a new short link. It accepts the original url and an | |
/// optional [`Slug`]. If a [`Slug`] is not provided, the service will generate | |
/// one. Returns the newly created [`ShortLink`]. | |
/// | |
/// ## Errors | |
/// | |
/// See [`ShortenerError`]. | |
fn handle_create_short_link( | |
&mut self, | |
url: Url, | |
slug: Option<Slug>, | |
) -> Result<ShortLink, ShortenerError>; | |
/// Processes a redirection by [`Slug`], returning the associated | |
/// [`ShortLink`] or a [`ShortenerError`]. | |
fn handle_redirect(&mut self, slug: Slug) -> Result<ShortLink, ShortenerError>; | |
} | |
} | |
/// Queries for CQRS | |
pub mod queries { | |
use super::{ShortenerError, Slug, Stats}; | |
/// Trait for query handlers. | |
pub trait QueryHandler { | |
/// Returns the [`Stats`] for a specific [`ShortLink`], such as the | |
/// number of redirects (clicks). | |
/// | |
/// [`ShortLink`]: super::ShortLink | |
fn get_stats(&self, slug: Slug) -> Result<Stats, ShortenerError>; | |
} | |
} | |
/// CQRS and Event Sourcing-based service implementation | |
pub struct UrlShortenerService { | |
slug_url_map: Arc<RwLock<HashMap<String, Url>>>, | |
slug_counter_map: Arc<RwLock<HashMap<String, u64>>>, | |
url_to_slug_map: HashMap<String, Slug>, | |
log_tx: SyncSender<StatLog>, | |
} | |
impl UrlShortenerService { | |
/// Creates a new instance of the service | |
pub fn new() -> Self { | |
let (tx, rx): (SyncSender<StatLog>, Receiver<StatLog>) = sync_channel(2); | |
let mut s = Self { | |
slug_url_map: Arc::new(RwLock::new(HashMap::new())), | |
slug_counter_map: Arc::new(RwLock::new(HashMap::new())), | |
url_to_slug_map: HashMap::new(), | |
log_tx: tx, | |
}; | |
s.receive_action_for_slug(rx); | |
s | |
} | |
} | |
impl UrlShortenerService { | |
fn apply_slug_for_url(&mut self, url: &Url, slug: &Slug) -> Result<ShortLink, ShortenerError> { | |
let read_guard = self.slug_url_map.read().unwrap(); | |
let is_slug_occupies: bool = read_guard.get(&slug.0).is_some(); | |
drop(read_guard); | |
if is_slug_occupies { | |
return Err(ShortenerError::SlugAlreadyInUse); | |
} | |
let mut guard = self.slug_url_map.write().unwrap(); | |
guard.insert(slug.0.clone(), url.clone()); | |
drop(guard); | |
Some(url.clone()) | |
.map(|inserted_url: Url| { | |
self.url_to_slug_map | |
.insert(inserted_url.0.clone(), slug.clone()) | |
.map(|prev_slug| { | |
self.send_action_for_slug(&prev_slug, &url, SlugActionType::DELETE) | |
}); | |
(inserted_url, slug.clone()) | |
}) | |
.map(|(inserted_url, inserted_slug): (Url, Slug)| ShortLink { | |
slug: inserted_slug.clone(), | |
url: inserted_url, | |
}) | |
.ok_or(ShortenerError::SlugAlreadyInUse) | |
} | |
fn create_short_link(&mut self, url: &Url) -> Result<ShortLink, ShortenerError> { | |
self.url_to_slug_map | |
.get(&url.0) | |
.map(|slug_ref| ShortLink { | |
slug: slug_ref.to_owned(), | |
url: url.clone(), | |
}) | |
.or_else(|| { | |
let mut generated_slug = UrlShortenerHelper::generate_slug(); | |
while self.apply_slug_for_url(&url, &generated_slug).is_err() { | |
generated_slug = UrlShortenerHelper::generate_slug(); | |
} | |
Some(ShortLink { | |
slug: generated_slug, | |
url: url.clone(), | |
}) | |
}) | |
.ok_or(ShortenerError::InvalidUrl) | |
} | |
fn get_short_link_for_slug(&mut self, slug: &Slug) -> Result<ShortLink, ShortenerError> { | |
let short_link_opt: Option<ShortLink> = | |
self.slug_url_map | |
.read() | |
.unwrap() | |
.get(&slug.0) | |
.map(|url_ref| ShortLink { | |
slug: slug.clone(), | |
url: url_ref.clone(), | |
}); | |
short_link_opt | |
.map(|short_link: ShortLink| { | |
self.send_action_for_slug( | |
&short_link.slug, | |
&short_link.url, | |
SlugActionType::REDIRECT, | |
); | |
short_link | |
}) | |
.ok_or(ShortenerError::SlugNotFound) | |
} | |
fn get_stats_for_slug(&self, slug: &Slug) -> Result<Stats, ShortenerError> { | |
self.slug_counter_map | |
.read() | |
.unwrap() | |
.get(&slug.0) | |
.map(|v| v.clone()) | |
.ok_or(ShortenerError::SlugNotFound) | |
.and_then(|redirects| { | |
self.slug_url_map | |
.read() | |
.unwrap() | |
.get(&slug.0) | |
.map(|url: &Url| url.clone()) | |
.ok_or(ShortenerError::SlugNotFound) | |
.map(|url| Stats { | |
link: ShortLink { | |
slug: slug.clone(), | |
url, | |
}, | |
redirects, | |
}) | |
}) | |
} | |
fn send_action_for_slug(&mut self, slug: &Slug, url: &Url, action_type: SlugActionType) { | |
self.log_tx | |
.send(StatLog { | |
slug: slug.clone(), | |
url: url.clone(), | |
action_type, | |
created: Instant::now(), | |
}) | |
.expect("Could not send log on channel"); | |
} | |
fn receive_action_for_slug(&mut self, rx: Receiver<StatLog>) { | |
let slug_counter_map = self.slug_counter_map.clone(); | |
let slug_url_map = self.slug_url_map.clone(); | |
// thread::scope(|s| { | |
thread::spawn(move || { | |
for stat_log in rx.iter() { | |
match stat_log.action_type { | |
SlugActionType::CREATE => { | |
UrlShortenerService::store_stat_log(&stat_log, "created"); | |
} | |
SlugActionType::REDIRECT => { | |
UrlShortenerService::store_stat_log(&stat_log, "redireted"); | |
// 1. increment counter at slug_counter_map | |
let mut guard = slug_counter_map.write().unwrap(); | |
*guard.entry(stat_log.slug.0.to_owned()).or_default() += 1; | |
} | |
SlugActionType::DELETE => { | |
UrlShortenerService::store_stat_log(&stat_log, "deleted"); | |
// !!!!!! | |
// I'm not sure if I need to remove old slugs (with stats counter??) when a new slug is provided for a Url | |
// !!!!!! | |
let mut guard = slug_url_map.write().unwrap(); | |
guard.remove(&stat_log.slug.0); | |
drop(guard); | |
let mut guard = slug_counter_map.write().unwrap(); | |
guard.remove(&stat_log.slug.0); | |
drop(guard); | |
} | |
} | |
} | |
}); | |
// }) | |
} | |
fn store_stat_log(stat_log: &StatLog, action: &str) { | |
println!( | |
"slug '{:?} for url '{:?}' {} at {:?};", | |
stat_log.slug, stat_log.url, action, stat_log | |
) | |
} | |
} | |
impl commands::CommandHandler for UrlShortenerService { | |
fn handle_create_short_link( | |
&mut self, | |
url: Url, | |
slug: Option<Slug>, | |
) -> Result<ShortLink, ShortenerError> { | |
// todo!("Implement the logic for creating a short link"); | |
println!("requested url {:?} , slug {:?}", &url, slug); | |
UrlShortenerHelper::validate_url(&url)?; | |
match slug { | |
Some(s) => self.apply_slug_for_url(&url, &s), | |
None => self.create_short_link(&url), | |
} | |
} | |
fn handle_redirect(&mut self, slug: Slug) -> Result<ShortLink, ShortenerError> { | |
// todo!("Implement the logic for redirection and incrementing the click counter") | |
self.get_short_link_for_slug(&slug) | |
} | |
} | |
impl queries::QueryHandler for UrlShortenerService { | |
fn get_stats(&self, slug: Slug) -> Result<Stats, ShortenerError> { | |
// todo!("Implement the logic for retrieving link statistics") | |
self.get_stats_for_slug(&slug) | |
} | |
} | |
#[cfg(test)] | |
mod tests { | |
use std::time::Duration; | |
use commands::CommandHandler; | |
use queries::QueryHandler; | |
use super::*; | |
#[test] | |
fn should_pass_test_init_test() { | |
// before | |
println!("1"); | |
let url_str = String::from("http://google.com/folder/subfolder/123?page=2"); | |
let mut url_short_service = UrlShortenerService::new(); | |
println!("2"); | |
// when | |
let short_link_res = url_short_service.handle_create_short_link(Url(url_str.clone()), None); | |
println!("3"); | |
// then | |
assert!(short_link_res.is_ok()); | |
let short_link: ShortLink = short_link_res.unwrap(); | |
assert!(short_link.slug.0.len() == 10); | |
assert!(short_link.url.0 == url_str); | |
let stored_short_link: ShortLink = | |
url_short_service.handle_redirect(short_link.slug).unwrap(); | |
assert!(stored_short_link.url == Url(url_str.clone())) | |
} | |
#[test] | |
fn should_pass_test_same_url_should_receive_same_slug() { | |
// before | |
let url_1 = "http://google.com/folder/subfolder/123?page=1"; | |
let mut url_short_service = UrlShortenerService::new(); | |
// when | |
let short_link_res_1 = | |
url_short_service.handle_create_short_link(Url(String::from(url_1)), None); | |
let short_link_res_2 = | |
url_short_service.handle_create_short_link(Url(String::from(url_1)), None); | |
// then | |
assert_eq!(short_link_res_1, short_link_res_2); | |
} | |
#[test] | |
fn should_pass_test_different_url_paths_should_have_different_slugs() { | |
// before | |
let url_1 = "http://google.com/folder/subfolder/123?page=1"; | |
let url_2 = "http://google.com/folder/subfolder/123?page=2"; | |
// when | |
let mut url_short_service = UrlShortenerService::new(); | |
let short_link_res_1 = | |
url_short_service.handle_create_short_link(Url(String::from(url_1)), None); | |
let short_link_res_2 = | |
url_short_service.handle_create_short_link(Url(String::from(url_2)), None); | |
// then | |
assert!(short_link_res_1 != short_link_res_2); | |
} | |
#[test] | |
fn should_pass_test_different_url_domains_should_have_different_slugs() { | |
// before | |
let url_1 = "http://google.com/folder/subfolder/123?page=1"; | |
let url_2 = "http://yandex.com/folder/subfolder/123?page=1"; | |
// when | |
let mut url_short_service = UrlShortenerService::new(); | |
let short_link_res_1 = | |
url_short_service.handle_create_short_link(Url(String::from(url_1)), None); | |
let short_link_res_2 = | |
url_short_service.handle_create_short_link(Url(String::from(url_2)), None); | |
// then | |
assert!(short_link_res_1 != short_link_res_2); | |
} | |
#[test] | |
fn should_pass_test_change_slug_and_retrieve_new_one() { | |
// before | |
let url_1 = "http://google.com/folder/subfolder/123?page=1"; | |
let url_2 = "http://yandex.com/folder/subfolder/123?page=2"; | |
let new_slug = String::from("1234567890"); | |
let mut url_short_service = UrlShortenerService::new(); | |
// when | |
let short_link_res_1 = | |
url_short_service.handle_create_short_link(Url(String::from(url_1)), None); | |
let _ = url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), Some(Slug(new_slug.clone()))); | |
let short_link_2: ShortLink = url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), None) | |
.unwrap(); | |
// then | |
assert_eq!(short_link_2.slug.0, new_slug); | |
} | |
#[test] | |
fn should_fail_test_slug_doesnt_exists() { | |
// before | |
let absent_slug = String::from("1234567890"); | |
let mut url_short_service = UrlShortenerService::new(); | |
// when | |
// then | |
assert!( | |
url_short_service | |
.handle_redirect(Slug(absent_slug.clone())) | |
.unwrap_err() | |
== ShortenerError::SlugNotFound | |
); | |
// when | |
// then | |
assert!( | |
url_short_service.get_stats(Slug(absent_slug)).unwrap_err() | |
== ShortenerError::SlugNotFound | |
); | |
} | |
#[test] | |
fn should_pass_on_valid_url() { | |
// before | |
let mut url_short_service = UrlShortenerService::new(); | |
let url_1 = "http://google.com"; | |
let url_2 = "HTTPS://GOOGLE.COM/"; | |
let url_3 = "HTTPS://WWW.YA-YA.RU/folder/subfolder/123?page=1"; | |
let url_4 = "FTP://GOO-gle.com/folder/subfolder/123?page=1"; | |
let url_5 = "http://www.goo-123-123-gle.com/folder/subfolder/123?page=1"; | |
// when | |
// then | |
assert!(url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), None) | |
.is_ok()); | |
// when | |
// then | |
assert!(url_short_service | |
.handle_create_short_link(Url(String::from(url_2)), None) | |
.is_ok()); | |
// when | |
// then | |
assert!(url_short_service | |
.handle_create_short_link(Url(String::from(url_3)), None) | |
.is_ok()); | |
// when | |
// then | |
assert!(url_short_service | |
.handle_create_short_link(Url(String::from(url_4)), None) | |
.is_ok()); | |
// when | |
// then | |
assert!(url_short_service | |
.handle_create_short_link(Url(String::from(url_5)), None) | |
.is_ok()); | |
} | |
#[test] | |
fn should_fail_on_invalid_url() { | |
// before | |
let mut url_short_service = UrlShortenerService::new(); | |
let url_1 = "ht-tp://google.com/folder/subfolder/123?page=1"; | |
let url_2 = "assdasd://google.com/folder/subfolder/123?page=1"; | |
let url_3 = "wwwwwww.google.com/folder/subfolder/123?page=1"; | |
let url_4 = "http://goo_gle.com/folder/subfolder/123?page=1"; | |
let url_5 = "ht-tp://google.comcomcom/folder/subfolder/123?page=1"; | |
// when | |
// then | |
assert!( | |
url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), None) | |
.unwrap_err() | |
== ShortenerError::InvalidUrl | |
); | |
// when | |
// then | |
assert!( | |
url_short_service | |
.handle_create_short_link(Url(String::from(url_2)), None) | |
.unwrap_err() | |
== ShortenerError::InvalidUrl | |
); | |
// when | |
// then | |
assert!( | |
url_short_service | |
.handle_create_short_link(Url(String::from(url_3)), None) | |
.unwrap_err() | |
== ShortenerError::InvalidUrl | |
); | |
// when | |
// then | |
assert!( | |
url_short_service | |
.handle_create_short_link(Url(String::from(url_4)), None) | |
.unwrap_err() | |
== ShortenerError::InvalidUrl | |
); | |
// when | |
// then | |
assert!( | |
url_short_service | |
.handle_create_short_link(Url(String::from(url_5)), None) | |
.unwrap_err() | |
== ShortenerError::InvalidUrl | |
); | |
} | |
#[test] | |
fn should_pass_test_count_redirects_for_valid_slug() { | |
// before | |
let url_1 = "http://google.com/folder/subfolder/123?page=1"; | |
let mut url_short_service = UrlShortenerService::new(); | |
// when | |
let short_link: ShortLink = url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), None) | |
.unwrap(); | |
let _ = url_short_service.handle_redirect(short_link.slug.clone()); | |
let _ = url_short_service.handle_redirect(short_link.slug.clone()); | |
let _ = url_short_service.handle_redirect(short_link.slug.clone()); | |
thread::sleep(Duration::from_millis(100)); | |
// then | |
let stats: Stats = url_short_service.get_stats(short_link.slug).unwrap(); | |
assert_eq!(stats.link.url.0, url_1); | |
assert_eq!(stats.redirects, 3); | |
} | |
#[test] | |
fn should_fail_test_slug_already_in_use() { | |
// before | |
let url_1 = "http://google.com/folder/subfolder/123?page=1"; | |
let url_2 = "http://yandex.com/folder/subfolder/123?page=2"; | |
// when | |
let mut url_short_service = UrlShortenerService::new(); | |
let short_link: ShortLink = url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), None) | |
.unwrap(); | |
let result = url_short_service | |
.handle_create_short_link(Url(String::from(url_2)), Some(short_link.slug.clone())); | |
// then | |
assert!(result.unwrap_err() == ShortenerError::SlugAlreadyInUse); | |
} | |
#[test] | |
fn should_fail_test_slug_already_in_use_for_same_url() { | |
// before | |
let url_1 = "http://google.com/folder/subfolder/123?page=1"; | |
let mut url_short_service = UrlShortenerService::new(); | |
// whoen | |
let short_link: ShortLink = url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), None) | |
.unwrap(); | |
let result = url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), Some(short_link.slug.clone())); | |
// then | |
assert!(result.unwrap_err() == ShortenerError::SlugAlreadyInUse); | |
} | |
#[test] | |
fn should_fail_test_receive_stats_for_outdated_slug() { | |
// before | |
let url_1 = "http://google.com/folder/subfolder/123?page=1"; | |
let mut url_short_service = UrlShortenerService::new(); | |
// when | |
let short_link: ShortLink = url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), None) | |
.unwrap(); | |
let _ = url_short_service.handle_redirect(short_link.slug.clone()); | |
let new_slug = Slug(String::from("123")); | |
let updated_short_url = url_short_service | |
.handle_create_short_link(Url(String::from(url_1)), Some(new_slug.clone())) | |
.unwrap(); | |
let _ = url_short_service.handle_redirect(updated_short_url.slug.clone()); | |
thread::sleep(Duration::from_millis(100)); | |
// then | |
assert!( | |
url_short_service | |
.handle_redirect(short_link.slug.clone()) | |
.unwrap_err() | |
== ShortenerError::SlugNotFound | |
); | |
assert!( | |
url_short_service.get_stats(short_link.slug).unwrap_err() | |
== ShortenerError::SlugNotFound | |
); | |
assert!(url_short_service | |
.handle_redirect(updated_short_url.slug.clone()) | |
.is_ok()); | |
assert!(url_short_service.get_stats(updated_short_url.slug).is_ok()); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//! ## Task Description | |
//! | |
//! The goal is to develop a backend service for shortening URLs using CQRS | |
//! (Command Query Responsibility Segregation) and ES (Event Sourcing) | |
//! approaches. The service should support the following features: | |
//! | |
//! ## Functional Requirements | |
//! | |
//! ### Creating a short link with a random slug | |
//! | |
//! The user sends a long URL, and the service returns a shortened URL with a | |
//! random slug. | |
//! | |
//! ### Creating a short link with a predefined slug | |
//! | |
//! The user sends a long URL along with a predefined slug, and the service | |
//! checks if the slug is unique. If it is unique, the service creates the short | |
//! link. | |
//! | |
//! ### Counting the number of redirects for the link | |
//! | |
//! - Every time a user accesses the short link, the click count should | |
//! increment. | |
//! - The click count can be retrieved via an API. | |
//! | |
//! ### CQRS+ES Architecture | |
//! | |
//! CQRS: Commands (creating links, updating click count) are separated from | |
//! queries (retrieving link information). | |
//! | |
//! Event Sourcing: All state changes (link creation, click count update) must be | |
//! recorded as events, which can be replayed to reconstruct the system's state. | |
//! | |
//! ### Technical Requirements | |
//! | |
//! - The service must be built using CQRS and Event Sourcing approaches. | |
//! - The service must be possible to run in Rust Playground (so no database like | |
//! Postgres is allowed) | |
//! - Public API already written for this task must not be changed (any change to | |
//! the public API items must be considered as breaking change). | |
//! - Event Sourcing should be actively utilized for implementing logic, rather | |
//! than existing without a clear purpose. | |
#![allow(unused_variables, dead_code)] | |
/// All possible errors of the [`UrlShortenerService`]. | |
#[derive(Debug, PartialEq)] | |
pub enum ShortenerError { | |
/// This error occurs when an invalid [`Url`] is provided for shortening. | |
InvalidUrl, | |
/// This error occurs when an attempt is made to use a slug (custom alias) | |
/// that already exists. | |
SlugAlreadyInUse, | |
/// This error occurs when the provided [`Slug`] does not map to any existing | |
/// short link. | |
SlugNotFound, | |
} | |
/// A unique string (or alias) that represents the shortened version of the | |
/// URL. | |
#[derive(Clone, Debug, PartialEq)] | |
pub struct Slug(pub String); | |
/// The original URL that the short link points to. | |
#[derive(Clone, Debug, PartialEq)] | |
pub struct Url(pub String); | |
/// Shortened URL representation. | |
#[derive(Debug, Clone, PartialEq)] | |
pub struct ShortLink { | |
/// A unique string (or alias) that represents the shortened version of the | |
/// URL. | |
pub slug: Slug, | |
/// The original URL that the short link points to. | |
pub url: Url, | |
} | |
/// Statistics of the [`ShortLink`]. | |
#[derive(Debug, Clone, PartialEq)] | |
pub struct Stats { | |
/// [`ShortLink`] to which this [`Stats`] are related. | |
pub link: ShortLink, | |
/// Count of redirects of the [`ShortLink`]. | |
pub redirects: u64, | |
} | |
/// Commands for CQRS. | |
pub mod commands { | |
use super::{ShortLink, ShortenerError, Slug, Url}; | |
/// Trait for command handlers. | |
pub trait CommandHandler { | |
/// Creates a new short link. It accepts the original url and an | |
/// optional [`Slug`]. If a [`Slug`] is not provided, the service will generate | |
/// one. Returns the newly created [`ShortLink`]. | |
/// | |
/// ## Errors | |
/// | |
/// See [`ShortenerError`]. | |
fn handle_create_short_link( | |
&mut self, | |
url: Url, | |
slug: Option<Slug>, | |
) -> Result<ShortLink, ShortenerError>; | |
/// Processes a redirection by [`Slug`], returning the associated | |
/// [`ShortLink`] or a [`ShortenerError`]. | |
fn handle_redirect( | |
&mut self, | |
slug: Slug, | |
) -> Result<ShortLink, ShortenerError>; | |
} | |
} | |
/// Queries for CQRS | |
pub mod queries { | |
use super::{ShortenerError, Slug, Stats}; | |
/// Trait for query handlers. | |
pub trait QueryHandler { | |
/// Returns the [`Stats`] for a specific [`ShortLink`], such as the | |
/// number of redirects (clicks). | |
/// | |
/// [`ShortLink`]: super::ShortLink | |
fn get_stats(&self, slug: Slug) -> Result<Stats, ShortenerError>; | |
} | |
} | |
/// CQRS and Event Sourcing-based service implementation | |
pub struct UrlShortenerService { | |
// TODO: add needed fields | |
} | |
impl UrlShortenerService { | |
/// Creates a new instance of the service | |
pub fn new() -> Self { | |
Self {} | |
} | |
} | |
impl commands::CommandHandler for UrlShortenerService { | |
fn handle_create_short_link( | |
&mut self, | |
url: Url, | |
slug: Option<Slug>, | |
) -> Result<ShortLink, ShortenerError> { | |
todo!("Implement the logic for creating a short link") | |
} | |
fn handle_redirect( | |
&mut self, | |
slug: Slug, | |
) -> Result<ShortLink, ShortenerError> { | |
todo!("Implement the logic for redirection and incrementing the click counter") | |
} | |
} | |
impl queries::QueryHandler for UrlShortenerService { | |
fn get_stats(&self, slug: Slug) -> Result<Stats, ShortenerError> { | |
todo!("Implement the logic for retrieving link statistics") | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment