Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 202 additions & 29 deletions rook/Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions rook/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
shuttle-axum = "0.54.0"
shuttle-runtime = { version = "0.54.0", default-features = false } # https://docs.shuttle.dev/docs/logs#default-tracing-subscriber
validator = "0.14"

[dev-dependencies]
serial_test = "3.0.0"
fake = "~2.3"
quickcheck = "0.9.2"
quickcheck_macros = "0.9.1"
7 changes: 7 additions & 0 deletions rook/src/domain/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
mod new_subscriber;
mod repository_url;
mod subscriber_email;

pub use new_subscriber::*;
pub use repository_url::*;
pub use subscriber_email::*;
36 changes: 36 additions & 0 deletions rook/src/domain/new_subscriber.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use crate::domain::repository_url::RepositoryURL;
use crate::domain::subscriber_email::SubscriberEmail;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct NewSubscriber {
email: SubscriberEmail,
repository_url: RepositoryURL,
branch: Option<String>, // TODO: 브랜치 이름 제약 조건 확인하기
}

impl NewSubscriber {
pub fn new(
email: SubscriberEmail,
repository_url: RepositoryURL,
branch: Option<String>,
) -> Self {
Self {
email,
repository_url,
branch,
}
}

pub fn email(&self) -> &SubscriberEmail {
&self.email
}

pub fn repository_url(&self) -> &RepositoryURL {
&self.repository_url
}

pub fn branch(&self) -> Option<&String> {
self.branch.as_ref()
}
}
114 changes: 114 additions & 0 deletions rook/src/domain/repository_url.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};

const GITHUB_BASE_URL: &str = "https://github.com/";
const GITHUB_URL_FORMAT: &str = "https://github.com/{owner}/{repo_name}";

/// Represents a GitHub repository URL.
///
/// This struct ensures that the URL is valid and follows the format
/// `https://github.com/{owner}/{repo_name}`. It includes validation logic
/// to enforce this format.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct RepositoryURL {
/// The URL of the repository.
url: String,
}

impl RepositoryURL {
/// Creates a new `RepositoryURL` instance.
///
/// # Arguments
///
/// * `url` - The GitHub repository URL to validate and store.
///
/// # Returns
///
/// Returns `Ok(RepositoryURL)` if the URL is valid, or `Err(String)` if the URL is invalid.
///
/// # Examples
///
/// ```
/// use queensac::domain::RepositoryURL;
///
/// let url = RepositoryURL::new("https://github.com/owner/repo").unwrap();
/// ```
pub fn new(url: impl Into<String>) -> Result<Self, String> {
let repo = RepositoryURL { url: url.into() };
repo.validate()?;
Ok(repo)
}

/// Returns a reference to the repository URL.
pub fn url(&self) -> &str {
&self.url
}

fn validate(&self) -> Result<(), String> {
if !self.url.starts_with(GITHUB_BASE_URL) {
return Err(format!("URL must start with {}", GITHUB_BASE_URL));
}
let parts: Vec<&str> = self
.url
.trim_start_matches(GITHUB_BASE_URL)
.split('/')
.collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(format!("URL must be in format {}", GITHUB_URL_FORMAT));
}
Ok(())
}
}

impl<'de> Deserialize<'de> for RepositoryURL {
/// Custom deserialization logic for `RepositoryURL`.
///
/// This implementation ensures that the URL is validated during
/// deserialization. If the URL is invalid, an error is returned.
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let url = String::deserialize(deserializer)?;
RepositoryURL::new(url).map_err(serde::de::Error::custom)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_repository_url_creation() {
// Valid URLs
assert!(RepositoryURL::new("https://github.com/owner/repo").is_ok());
assert!(RepositoryURL::new("https://github.com/rust-lang/rust").is_ok());

// Invalid URLs
assert!(RepositoryURL::new("https://gitlab.com/owner/repo").is_err());
assert!(RepositoryURL::new("https://github.com/").is_err());
assert!(RepositoryURL::new("https://github.com/owner").is_err());
assert!(RepositoryURL::new("https://github.com/owner/").is_err());
assert!(RepositoryURL::new("http://github.com/owner/repo").is_err());
assert!(RepositoryURL::new("https://github.com//repo").is_err());
}

#[test]
fn test_repository_url_deserialization() {
// Valid URLs
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com/owner/repo\"").is_ok());
assert!(
serde_json::from_str::<RepositoryURL>("\"https://github.com/rust-lang/rust\"").is_ok()
);

// Invalid URLs
assert!(
serde_json::from_str::<RepositoryURL>("\"https://gitlab.com/owner/repo\"").is_err()
);
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com/\"").is_err());
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com/owner\"").is_err());
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com/owner/\"").is_err());
assert!(serde_json::from_str::<RepositoryURL>("\"http://github.com/owner/repo\"").is_err());
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com//repo\"").is_err());
}
}
80 changes: 80 additions & 0 deletions rook/src/domain/subscriber_email.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct SubscriberEmail(String);

impl SubscriberEmail {
/// Creates a new `SubscriberEmail` instance.
///
/// # Arguments
///
/// * `email` - The email address to validate and store.
///
/// # Returns
///
/// Returns `Ok(SubscriberEmail)` if the email is valid, or `Err(String)` if the email is invalid.
///
/// # Examples
///
/// ```
/// use queensac::domain::SubscriberEmail;
///
/// let email = SubscriberEmail::new("areyou@redddy.com").unwrap();
/// ```
pub fn new(email: impl Into<String>) -> Result<Self, String> {
let email = email.into();
if validator::validate_email(&email) {
Ok(Self(email))
} else {
Err(format!("{} is not a valid subscriber email.", email))
}
}

/// Returns a reference to the email address.
pub fn as_str(&self) -> &str {
&self.0
}
}

impl AsRef<str> for SubscriberEmail {
fn as_ref(&self) -> &str {
&self.0
}
}

#[cfg(test)]
mod tests {
use super::SubscriberEmail;
use fake::Fake;
use fake::faker::internet::en::SafeEmail;

#[test]
fn empty_string_is_rejected() {
assert!(SubscriberEmail::new("").is_err());
}

#[test]
fn email_missing_at_symbol_is_rejected() {
assert!(SubscriberEmail::new("redddy.com").is_err());
}

#[test]
fn email_missing_subject_is_rejected() {
assert!(SubscriberEmail::new("@redddy.com").is_err());
}

#[derive(Debug, Clone)]
struct ValidEmailFixture(pub String);

impl quickcheck::Arbitrary for ValidEmailFixture {
fn arbitrary<G: quickcheck::Gen>(g: &mut G) -> Self {
let email = SafeEmail().fake_with_rng(g);
Self(email)
}
}

#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
SubscriberEmail::new(valid_email.0).is_ok()
}
}
2 changes: 2 additions & 0 deletions rook/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
pub mod domain;
pub mod git;
pub mod link;
pub mod schedule;

pub use domain::*;
pub use git::*;
pub use link::*;
pub use schedule::*;
75 changes: 20 additions & 55 deletions rook/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use queensac::domain::NewSubscriber;
use queensac::{cancel_repository_checker, check_repository_links};

use axum::{
Expand All @@ -10,9 +11,6 @@ use std::time::Duration;
use tracing::{Level, error, info};
use tracing_subscriber::FmtSubscriber;

const GITHUB_BASE_URL: &str = "https://github.com/";
const GITHUB_URL_FORMAT: &str = "https://github.com/{owner}/{repo_name}";

async fn spawn_repository_checker(
repo_url: &str,
branch: Option<String>,
Expand All @@ -36,20 +34,27 @@ async fn health_check() -> &'static str {

#[derive(Deserialize)]
struct CheckRequest {
repo: RepositoryURL,
branch: Option<String>,
subscriber: NewSubscriber,
interval_secs: Option<u64>,
}

async fn check_handler(Json(payload): Json<CheckRequest>) -> Result<&'static str, StatusCode> {
info!(
"Received check request for repository: {}, branch: {:?}",
payload.repo.url, payload.branch
"Received check request for repository: {}, branch: {:?}, email: {}",
payload.subscriber.repository_url().url(),
payload.subscriber.branch(),
payload.subscriber.email().as_str()
);
// FIXME 일단 interval_secs 는 유저가 수정할 수 없게 할 거긴 한데, 일단 테스트할 때 편하게 요청을 받아보자.
let interval = payload.interval_secs.unwrap_or(120);
let interval = Duration::from_secs(interval);
if let Err(e) = spawn_repository_checker(&payload.repo.url, payload.branch, interval).await {
if let Err(e) = spawn_repository_checker(
payload.subscriber.repository_url().url(),
payload.subscriber.branch().cloned(),
interval,
)
.await
{
error!("Failed to spawn repository checker: {}", e);
return Err(StatusCode::BAD_REQUEST);
}
Expand All @@ -58,56 +63,16 @@ async fn check_handler(Json(payload): Json<CheckRequest>) -> Result<&'static str

#[derive(Deserialize)]
struct CancelRequest {
repo: RepositoryURL,
branch: Option<String>,
}

/// Represents a GitHub repository URL.
///
/// This struct ensures that the URL is valid and follows the format
/// `https://github.com/{owner}/{repo_name}`. It includes validation logic
/// to enforce this format.
#[derive(Debug, Clone)]
struct RepositoryURL {
/// The URL of the repository.
url: String,
}

impl<'de> Deserialize<'de> for RepositoryURL {
/// Custom deserialization logic for `RepositoryURL`.
///
/// This implementation ensures that the URL is validated during
/// deserialization. If the URL is invalid, an error is returned.
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let url = String::deserialize(deserializer)?;
let repo = RepositoryURL { url };
repo.validate().map_err(serde::de::Error::custom)?;
Ok(repo)
}
}

impl RepositoryURL {
fn validate(&self) -> Result<(), String> {
if !self.url.starts_with(GITHUB_BASE_URL) {
return Err(format!("URL must start with {}", GITHUB_BASE_URL));
}
let parts: Vec<&str> = self
.url
.trim_start_matches(GITHUB_BASE_URL)
.split('/')
.collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(format!("URL must be in format {}", GITHUB_URL_FORMAT));
}
Ok(())
}
subscriber: NewSubscriber,
}

async fn cancel_handler(Json(payload): Json<CancelRequest>) -> Result<&'static str, StatusCode> {
if let Err(e) = cancel_repository_checker(&payload.repo.url, payload.branch).await {
if let Err(e) = cancel_repository_checker(
payload.subscriber.repository_url().url(),
payload.subscriber.branch().cloned(),
)
.await
{
error!("Repository checker failed: {}", e);
return Err(StatusCode::BAD_REQUEST);
}
Expand Down
Loading