Skip to content

Commit a183d36

Browse files
Merge pull request #75 from reddevilmidzy/email
이메일 유효성 검증
2 parents 52f25de + 3a74e75 commit a183d36

File tree

9 files changed

+465
-118
lines changed

9 files changed

+465
-118
lines changed

rook/Cargo.lock

Lines changed: 202 additions & 29 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rook/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ tracing = "0.1"
2222
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
2323
shuttle-axum = "0.54.0"
2424
shuttle-runtime = { version = "0.54.0", default-features = false } # https://docs.shuttle.dev/docs/logs#default-tracing-subscriber
25+
validator = "0.14"
2526

2627
[dev-dependencies]
2728
serial_test = "3.0.0"
29+
fake = "~2.3"
30+
quickcheck = "0.9.2"
31+
quickcheck_macros = "0.9.1"

rook/src/domain/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
mod new_subscriber;
2+
mod repository_url;
3+
mod subscriber_email;
4+
5+
pub use new_subscriber::*;
6+
pub use repository_url::*;
7+
pub use subscriber_email::*;

rook/src/domain/new_subscriber.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use crate::domain::repository_url::RepositoryURL;
2+
use crate::domain::subscriber_email::SubscriberEmail;
3+
use serde::Deserialize;
4+
5+
#[derive(Debug, Deserialize)]
6+
pub struct NewSubscriber {
7+
email: SubscriberEmail,
8+
repository_url: RepositoryURL,
9+
branch: Option<String>, // TODO: 브랜치 이름 제약 조건 확인하기
10+
}
11+
12+
impl NewSubscriber {
13+
pub fn new(
14+
email: SubscriberEmail,
15+
repository_url: RepositoryURL,
16+
branch: Option<String>,
17+
) -> Self {
18+
Self {
19+
email,
20+
repository_url,
21+
branch,
22+
}
23+
}
24+
25+
pub fn email(&self) -> &SubscriberEmail {
26+
&self.email
27+
}
28+
29+
pub fn repository_url(&self) -> &RepositoryURL {
30+
&self.repository_url
31+
}
32+
33+
pub fn branch(&self) -> Option<&String> {
34+
self.branch.as_ref()
35+
}
36+
}

rook/src/domain/repository_url.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
const GITHUB_BASE_URL: &str = "https://github.com/";
4+
const GITHUB_URL_FORMAT: &str = "https://github.com/{owner}/{repo_name}";
5+
6+
/// Represents a GitHub repository URL.
7+
///
8+
/// This struct ensures that the URL is valid and follows the format
9+
/// `https://github.com/{owner}/{repo_name}`. It includes validation logic
10+
/// to enforce this format.
11+
#[derive(Debug, Clone, Serialize)]
12+
#[serde(transparent)]
13+
pub struct RepositoryURL {
14+
/// The URL of the repository.
15+
url: String,
16+
}
17+
18+
impl RepositoryURL {
19+
/// Creates a new `RepositoryURL` instance.
20+
///
21+
/// # Arguments
22+
///
23+
/// * `url` - The GitHub repository URL to validate and store.
24+
///
25+
/// # Returns
26+
///
27+
/// Returns `Ok(RepositoryURL)` if the URL is valid, or `Err(String)` if the URL is invalid.
28+
///
29+
/// # Examples
30+
///
31+
/// ```
32+
/// use queensac::domain::RepositoryURL;
33+
///
34+
/// let url = RepositoryURL::new("https://github.com/owner/repo").unwrap();
35+
/// ```
36+
pub fn new(url: impl Into<String>) -> Result<Self, String> {
37+
let repo = RepositoryURL { url: url.into() };
38+
repo.validate()?;
39+
Ok(repo)
40+
}
41+
42+
/// Returns a reference to the repository URL.
43+
pub fn url(&self) -> &str {
44+
&self.url
45+
}
46+
47+
fn validate(&self) -> Result<(), String> {
48+
if !self.url.starts_with(GITHUB_BASE_URL) {
49+
return Err(format!("URL must start with {}", GITHUB_BASE_URL));
50+
}
51+
let parts: Vec<&str> = self
52+
.url
53+
.trim_start_matches(GITHUB_BASE_URL)
54+
.split('/')
55+
.collect();
56+
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
57+
return Err(format!("URL must be in format {}", GITHUB_URL_FORMAT));
58+
}
59+
Ok(())
60+
}
61+
}
62+
63+
impl<'de> Deserialize<'de> for RepositoryURL {
64+
/// Custom deserialization logic for `RepositoryURL`.
65+
///
66+
/// This implementation ensures that the URL is validated during
67+
/// deserialization. If the URL is invalid, an error is returned.
68+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69+
where
70+
D: serde::Deserializer<'de>,
71+
{
72+
let url = String::deserialize(deserializer)?;
73+
RepositoryURL::new(url).map_err(serde::de::Error::custom)
74+
}
75+
}
76+
77+
#[cfg(test)]
78+
mod tests {
79+
use super::*;
80+
81+
#[test]
82+
fn test_repository_url_creation() {
83+
// Valid URLs
84+
assert!(RepositoryURL::new("https://github.com/owner/repo").is_ok());
85+
assert!(RepositoryURL::new("https://github.com/rust-lang/rust").is_ok());
86+
87+
// Invalid URLs
88+
assert!(RepositoryURL::new("https://gitlab.com/owner/repo").is_err());
89+
assert!(RepositoryURL::new("https://github.com/").is_err());
90+
assert!(RepositoryURL::new("https://github.com/owner").is_err());
91+
assert!(RepositoryURL::new("https://github.com/owner/").is_err());
92+
assert!(RepositoryURL::new("http://github.com/owner/repo").is_err());
93+
assert!(RepositoryURL::new("https://github.com//repo").is_err());
94+
}
95+
96+
#[test]
97+
fn test_repository_url_deserialization() {
98+
// Valid URLs
99+
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com/owner/repo\"").is_ok());
100+
assert!(
101+
serde_json::from_str::<RepositoryURL>("\"https://github.com/rust-lang/rust\"").is_ok()
102+
);
103+
104+
// Invalid URLs
105+
assert!(
106+
serde_json::from_str::<RepositoryURL>("\"https://gitlab.com/owner/repo\"").is_err()
107+
);
108+
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com/\"").is_err());
109+
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com/owner\"").is_err());
110+
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com/owner/\"").is_err());
111+
assert!(serde_json::from_str::<RepositoryURL>("\"http://github.com/owner/repo\"").is_err());
112+
assert!(serde_json::from_str::<RepositoryURL>("\"https://github.com//repo\"").is_err());
113+
}
114+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use serde::Deserialize;
2+
3+
#[derive(Debug, Deserialize)]
4+
pub struct SubscriberEmail(String);
5+
6+
impl SubscriberEmail {
7+
/// Creates a new `SubscriberEmail` instance.
8+
///
9+
/// # Arguments
10+
///
11+
/// * `email` - The email address to validate and store.
12+
///
13+
/// # Returns
14+
///
15+
/// Returns `Ok(SubscriberEmail)` if the email is valid, or `Err(String)` if the email is invalid.
16+
///
17+
/// # Examples
18+
///
19+
/// ```
20+
/// use queensac::domain::SubscriberEmail;
21+
///
22+
/// let email = SubscriberEmail::new("areyou@redddy.com").unwrap();
23+
/// ```
24+
pub fn new(email: impl Into<String>) -> Result<Self, String> {
25+
let email = email.into();
26+
if validator::validate_email(&email) {
27+
Ok(Self(email))
28+
} else {
29+
Err(format!("{} is not a valid subscriber email.", email))
30+
}
31+
}
32+
33+
/// Returns a reference to the email address.
34+
pub fn as_str(&self) -> &str {
35+
&self.0
36+
}
37+
}
38+
39+
impl AsRef<str> for SubscriberEmail {
40+
fn as_ref(&self) -> &str {
41+
&self.0
42+
}
43+
}
44+
45+
#[cfg(test)]
46+
mod tests {
47+
use super::SubscriberEmail;
48+
use fake::Fake;
49+
use fake::faker::internet::en::SafeEmail;
50+
51+
#[test]
52+
fn empty_string_is_rejected() {
53+
assert!(SubscriberEmail::new("").is_err());
54+
}
55+
56+
#[test]
57+
fn email_missing_at_symbol_is_rejected() {
58+
assert!(SubscriberEmail::new("redddy.com").is_err());
59+
}
60+
61+
#[test]
62+
fn email_missing_subject_is_rejected() {
63+
assert!(SubscriberEmail::new("@redddy.com").is_err());
64+
}
65+
66+
#[derive(Debug, Clone)]
67+
struct ValidEmailFixture(pub String);
68+
69+
impl quickcheck::Arbitrary for ValidEmailFixture {
70+
fn arbitrary<G: quickcheck::Gen>(g: &mut G) -> Self {
71+
let email = SafeEmail().fake_with_rng(g);
72+
Self(email)
73+
}
74+
}
75+
76+
#[quickcheck_macros::quickcheck]
77+
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
78+
SubscriberEmail::new(valid_email.0).is_ok()
79+
}
80+
}

rook/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
pub mod domain;
12
pub mod git;
23
pub mod link;
34
pub mod schedule;
45

6+
pub use domain::*;
57
pub use git::*;
68
pub use link::*;
79
pub use schedule::*;

rook/src/main.rs

Lines changed: 20 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use queensac::domain::NewSubscriber;
12
use queensac::{cancel_repository_checker, check_repository_links};
23

34
use axum::{
@@ -10,9 +11,6 @@ use std::time::Duration;
1011
use tracing::{Level, error, info};
1112
use tracing_subscriber::FmtSubscriber;
1213

13-
const GITHUB_BASE_URL: &str = "https://github.com/";
14-
const GITHUB_URL_FORMAT: &str = "https://github.com/{owner}/{repo_name}";
15-
1614
async fn spawn_repository_checker(
1715
repo_url: &str,
1816
branch: Option<String>,
@@ -36,20 +34,27 @@ async fn health_check() -> &'static str {
3634

3735
#[derive(Deserialize)]
3836
struct CheckRequest {
39-
repo: RepositoryURL,
40-
branch: Option<String>,
37+
subscriber: NewSubscriber,
4138
interval_secs: Option<u64>,
4239
}
4340

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

5964
#[derive(Deserialize)]
6065
struct CancelRequest {
61-
repo: RepositoryURL,
62-
branch: Option<String>,
63-
}
64-
65-
/// Represents a GitHub repository URL.
66-
///
67-
/// This struct ensures that the URL is valid and follows the format
68-
/// `https://github.com/{owner}/{repo_name}`. It includes validation logic
69-
/// to enforce this format.
70-
#[derive(Debug, Clone)]
71-
struct RepositoryURL {
72-
/// The URL of the repository.
73-
url: String,
74-
}
75-
76-
impl<'de> Deserialize<'de> for RepositoryURL {
77-
/// Custom deserialization logic for `RepositoryURL`.
78-
///
79-
/// This implementation ensures that the URL is validated during
80-
/// deserialization. If the URL is invalid, an error is returned.
81-
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82-
where
83-
D: serde::Deserializer<'de>,
84-
{
85-
let url = String::deserialize(deserializer)?;
86-
let repo = RepositoryURL { url };
87-
repo.validate().map_err(serde::de::Error::custom)?;
88-
Ok(repo)
89-
}
90-
}
91-
92-
impl RepositoryURL {
93-
fn validate(&self) -> Result<(), String> {
94-
if !self.url.starts_with(GITHUB_BASE_URL) {
95-
return Err(format!("URL must start with {}", GITHUB_BASE_URL));
96-
}
97-
let parts: Vec<&str> = self
98-
.url
99-
.trim_start_matches(GITHUB_BASE_URL)
100-
.split('/')
101-
.collect();
102-
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
103-
return Err(format!("URL must be in format {}", GITHUB_URL_FORMAT));
104-
}
105-
Ok(())
106-
}
66+
subscriber: NewSubscriber,
10767
}
10868

10969
async fn cancel_handler(Json(payload): Json<CancelRequest>) -> Result<&'static str, StatusCode> {
110-
if let Err(e) = cancel_repository_checker(&payload.repo.url, payload.branch).await {
70+
if let Err(e) = cancel_repository_checker(
71+
payload.subscriber.repository_url().url(),
72+
payload.subscriber.branch().cloned(),
73+
)
74+
.await
75+
{
11176
error!("Repository checker failed: {}", e);
11277
return Err(StatusCode::BAD_REQUEST);
11378
}

0 commit comments

Comments
 (0)