From 6a4c8801becc54948d3b9597afecc418ccce10c1 Mon Sep 17 00:00:00 2001 From: Kent Tristan Yves Sarmiento Date: Mon, 18 Dec 2023 16:34:45 +0800 Subject: [PATCH] feat: GET /v1/links support (#7) --- .github/workflows/development.yml | 18 ++++++ Cargo.lock | 96 +++++++++++++++++++++++++++++++ Cargo.toml | 5 ++ README.md | 16 +++++- src/controller/links.rs | 85 +++++++++++++++++++++++++-- src/repository/links.rs | 16 ++++++ src/router.rs | 15 +++-- src/service/links.rs | 72 +++++++++++++++++++++++ src/types/links.rs | 12 +++- src/types/repository.rs | 23 +++----- src/types/request.rs | 0 src/types/service.rs | 23 +++----- src/types/state.rs | 14 ++--- tests/links.rs | 33 +++++++++++ 14 files changed, 378 insertions(+), 50 deletions(-) create mode 100644 src/types/request.rs create mode 100644 tests/links.rs diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index ce04572..63e715b 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -41,3 +41,21 @@ jobs: - name: Clippy run: cargo clippy --all-targets --all-features -- -D warnings + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: 1.74.0 + profile: minimal + components: rustfmt, clippy + + - name: Unit Test + run: cargo test --lib + + - name: Integration Test + run: cargo test --test '*' diff --git a/Cargo.lock b/Cargo.lock index cd20367..3c8bda5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + [[package]] name = "async-stream" version = "0.3.5" @@ -175,6 +181,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -205,6 +223,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + [[package]] name = "futures" version = "0.3.29" @@ -502,6 +526,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.10" @@ -630,11 +663,14 @@ name = "link-for-later" version = "0.1.0" dependencies = [ "axum", + "http-body-util", "lambda_http", "lambda_runtime 0.8.3 (registry+https://github.com/rust-lang/crates.io-index)", + "mockall", "serde", "serde_json", "tokio", + "tower", "tracing", "tracing-subscriber", ] @@ -683,6 +719,33 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "mockall" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a978c8292954bcb9347a4e28772c0a0621166a1598fc1be28ac0076a4bb810e" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2765371d0978ba4ace4ebef047baa62fc068b431e468444b5610dd441c639b" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -746,6 +809,33 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "predicates" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfc28575c2e3f19cb3c73b93af36460ae898d426eba6fc15b9bd2a5220758a0" +dependencies = [ + "anstyle", + "itertools", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.70" @@ -901,6 +991,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "thread_local" version = "1.1.7" diff --git a/Cargo.toml b/Cargo.toml index 562dbee..d9cb0b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,12 +10,17 @@ publish = false [dependencies] axum = "0.7.2" +http-body-util = "0.1.0" #lambda_http = "0.8.3" lambda_http = { git = "https://github.com/awslabs/aws-lambda-rust-runtime", branch = "hyper1_upgrade" } lambda_runtime = "0.8.3" serde = { version = "1.0.193", features = [ "derive" ] } serde_json = "1.0.108" tokio = { version = "1", features = ["macros"] } +tower = "0.4.13" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } +[dev-dependencies] +mockall = "0.12.0" + diff --git a/README.md b/README.md index 453d5a7..f6118f4 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,26 @@ Setup: Testing: -1. [cargo lambda](https://www.cargo-lambda.info/) is used for development of this service and it is pre-installed as part of the devcontainer. Use [cargo lambda watch](https://www.cargo-lambda.info/commands/watch.html) to hotcompile your changes: +1. [`cargo lambda`](https://www.cargo-lambda.info/) is used for development of this service and it is pre-installed as part of the devcontainer. Use [`cargo lambda watch`](https://www.cargo-lambda.info/commands/watch.html) to hotcompile your changes: ```sh cargo lambda watch ``` -1. [cargo clippy](https://github.com/rust-lang/rust-clippy) is used for linting to catch common errors. This is setup to run on saving changes in the devcontainer. You may also run it from bash using the following command: +1. [`cargo clippy`](https://github.com/rust-lang/rust-clippy) is used for linting to catch common errors. This is setup to run on saving changes in the devcontainer. You may also run it from bash using the following command: ```sh cargo clippy --all-targets --all-features -- -D warnings ``` + +1. `cargo test` is used to run unit/integration tests + + Unit Test: + ```sh + cargo test --lib + ``` + + Integration Test: + ```sh + cargo test --test '*' + ``` diff --git a/src/controller/links.rs b/src/controller/links.rs index bb41aef..ca85727 100644 --- a/src/controller/links.rs +++ b/src/controller/links.rs @@ -28,7 +28,7 @@ async fn list(State(app_state): State) -> impl IntoResponse { tracing::error!("Error: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "msg": e.to_string() })), + Json(json!({ "msg": "An error occurred." })), ) .into_response() } @@ -43,7 +43,7 @@ async fn post(State(app_state): State) -> impl IntoResponse { tracing::error!("Error: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "msg": e.to_string() })), + Json(json!({ "msg": "An error occurred." })), ) .into_response() } @@ -58,7 +58,7 @@ async fn get(Path(id): Path, State(app_state): State) -> im tracing::error!("Error: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "msg": e.to_string() })), + Json(json!({ "msg": "An error occurred." })), ) .into_response() } @@ -73,7 +73,7 @@ async fn put(Path(id): Path, State(app_state): State) -> im tracing::error!("Error: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "msg": e.to_string() })), + Json(json!({ "msg": "An error occurred." })), ) .into_response() } @@ -88,9 +88,84 @@ async fn delete(Path(id): Path, State(app_state): State) -> tracing::error!("Error: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "msg": e.to_string() })), + Json(json!({ "msg": "An error occurred." })), ) .into_response() } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use http_body_util::BodyExt; + + use crate::types::{ + links::LinkItem, repository::MockLinks as MockRepository, service::MockLinks as MockService, + }; + + use super::*; + + #[tokio::test] + async fn test_get_links_empty() { + let mut mock_links_service = MockService::new(); + let mock_links_repo = MockRepository::new(); + mock_links_service + .expect_list() + .times(1) + .returning(|_| Ok(vec![])); + + let app_state = RouterState::new(Arc::new(mock_links_service), Arc::new(mock_links_repo)); + + let response = list(State(app_state)).await; + + let (parts, body) = response.into_response().into_parts(); + assert_eq!(StatusCode::OK, parts.status); + + let body = body.collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"[]"); + } + + #[tokio::test] + async fn test_get_links_non_empty() { + let mut mock_links_service = MockService::new(); + let mock_links_repo = MockRepository::new(); + mock_links_service + .expect_list() + .times(1) + .returning(|_| Ok(vec![LinkItem::new("http://link")])); + + let app_state = RouterState::new(Arc::new(mock_links_service), Arc::new(mock_links_repo)); + + let response = list(State(app_state)).await; + + let (parts, body) = response.into_response().into_parts(); + assert_eq!(StatusCode::OK, parts.status); + + let body = body.collect().await.unwrap().to_bytes(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("http://link")); + } + + #[tokio::test] + async fn test_get_links_service_error() { + let mut mock_links_service = MockService::new(); + let mock_links_repo = MockRepository::new(); + mock_links_service + .expect_list() + .times(1) + .returning(|_| Err("A service error occurred.".into())); + + let app_state = RouterState::new(Arc::new(mock_links_service), Arc::new(mock_links_repo)); + + let response = list(State(app_state)).await; + + let (parts, body) = response.into_response().into_parts(); + assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, parts.status); + + let body = body.collect().await.unwrap().to_bytes(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("An error occurred.")); + } +} diff --git a/src/repository/links.rs b/src/repository/links.rs index 9a216c4..ade4d7a 100644 --- a/src/repository/links.rs +++ b/src/repository/links.rs @@ -12,4 +12,20 @@ impl Links for Repository { async fn list(&self) -> Result> { Ok(vec![]) } + + async fn post(&self) -> Result { + Err("Not implemented".into()) + } + + async fn get(&self, _id: &str) -> Result { + Err("Not implemented".into()) + } + + async fn put(&self, _id: &str) -> Result { + Err("Not implemented".into()) + } + + async fn delete(&self, _id: &str) -> Result<()> { + Err("Not implemented".into()) + } } diff --git a/src/router.rs b/src/router.rs index ef792c6..46f7df9 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,16 +1,21 @@ use std::sync::Arc; +use axum::Router; + use crate::{ controller, repository, service, - types::{repository::DynLinks as DynLinksRepo, service::DynLinks as DynLinksService, state}, + types::{ + repository::DynLinks as DynLinksRepo, service::DynLinks as DynLinksService, + state::Router as RouterState, + }, }; -pub fn new() -> axum::Router { - let links_repo = Arc::new(repository::links::Repository {}) as DynLinksRepo; +pub fn new() -> Router { let links_service = Arc::new(service::links::Service {}) as DynLinksService; + let links_repo = Arc::new(repository::links::Repository {}) as DynLinksRepo; - let state = state::Router::new(links_repo, links_service); - axum::Router::new() + let state = RouterState::new(links_service, links_repo); + Router::new() .merge(controller::links::router()) .with_state(state) } diff --git a/src/service/links.rs b/src/service/links.rs index 8da40d5..acac4e6 100644 --- a/src/service/links.rs +++ b/src/service/links.rs @@ -35,3 +35,75 @@ impl Links for Service { links_repo.delete(id).await } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::types::{ + links::LinkItem, repository::MockLinks as MockRepository, + service::MockLinks as MockService, state::Router as RouterState, + }; + + use super::*; + + #[tokio::test] + async fn test_get_links_empty() { + let mock_links_service = MockService::new(); + let mut mock_links_repo = MockRepository::new(); + mock_links_repo + .expect_list() + .times(1) + .returning(|| Ok(vec![])); + + let app_state = RouterState::new(Arc::new(mock_links_service), Arc::new(mock_links_repo)); + + let links_service = Service {}; + let response = links_service.list(&app_state).await; + + assert!(response.is_ok()); + assert!(response.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_get_links_non_empty() { + let mock_links_service = MockService::new(); + let mut mock_links_repo = MockRepository::new(); + mock_links_repo + .expect_list() + .times(1) + .returning(|| Ok(vec![LinkItem::new("http://link")])); + + let expected_items = vec![LinkItem::new("http://link")]; + + let app_state = RouterState::new(Arc::new(mock_links_service), Arc::new(mock_links_repo)); + + let links_service = Service {}; + let response = links_service.list(&app_state).await; + + assert!(response.is_ok()); + + let returned_items = response.unwrap(); + assert!(!returned_items.is_empty()); + assert!(returned_items + .iter() + .all(|item| expected_items.contains(item))); + } + + #[tokio::test] + async fn test_get_links_repo_error() { + let mock_links_service = MockService::new(); + let mut mock_links_repo = MockRepository::new(); + mock_links_repo + .expect_list() + .times(1) + .returning(|| Err("A service error occurred.".into())); + + let app_state = RouterState::new(Arc::new(mock_links_service), Arc::new(mock_links_repo)); + + let links_service = Service {}; + let response = links_service.list(&app_state).await; + + assert!(response.is_err()); + } +} diff --git a/src/types/links.rs b/src/types/links.rs index 2524d56..923bf23 100644 --- a/src/types/links.rs +++ b/src/types/links.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct LinkItem { id: String, owner: String, @@ -10,3 +10,13 @@ pub struct LinkItem { created_at: String, updated_at: String, } + +impl LinkItem { + #[allow(dead_code)] + pub fn new(url: &str) -> Self { + Self { + url: url.to_string(), + ..Default::default() + } + } +} diff --git a/src/types/repository.rs b/src/types/repository.rs index 4a2cd8d..c0c9960 100644 --- a/src/types/repository.rs +++ b/src/types/repository.rs @@ -1,31 +1,24 @@ use std::sync::Arc; use axum::async_trait; +#[cfg(test)] +use mockall::{automock, predicate::*}; use super::links::LinkItem; pub type DynLinks = Arc; pub type Result = std::result::Result>; +#[cfg_attr(test, automock)] #[async_trait] pub trait Links { - async fn list(&self) -> Result> { - Err("Not implemented".into()) - } + async fn list(&self) -> Result>; - async fn post(&self) -> Result { - Err("Not implemented".into()) - } + async fn post(&self) -> Result; - async fn get(&self, _id: &str) -> Result { - Err("Not implemented".into()) - } + async fn get(&self, id: &str) -> Result; - async fn put(&self, _id: &str) -> Result { - Err("Not implemented".into()) - } + async fn put(&self, id: &str) -> Result; - async fn delete(&self, _id: &str) -> Result<()> { - Err("Not implemented".into()) - } + async fn delete(&self, id: &str) -> Result<()>; } diff --git a/src/types/request.rs b/src/types/request.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/types/service.rs b/src/types/service.rs index e27ac8a..d900a29 100644 --- a/src/types/service.rs +++ b/src/types/service.rs @@ -1,31 +1,24 @@ use std::sync::Arc; use axum::async_trait; +#[cfg(test)] +use mockall::{automock, predicate::*}; use super::{links::LinkItem, state}; pub type DynLinks = Arc; pub type Result = std::result::Result>; +#[cfg_attr(test, automock)] #[async_trait] pub trait Links { - async fn list<'a>(&self, _app_state: &'a state::Router) -> Result> { - Err("Not implemented".into()) - } + async fn list<'a>(&self, app_state: &'a state::Router) -> Result>; - async fn post<'a>(&self, _app_state: &'a state::Router) -> Result { - Err("Not implemented".into()) - } + async fn post<'a>(&self, app_state: &'a state::Router) -> Result; - async fn get<'a>(&self, _id: &str, _app_state: &'a state::Router) -> Result { - Err("Not implemented".into()) - } + async fn get<'a>(&self, id: &str, app_state: &'a state::Router) -> Result; - async fn put<'a>(&self, _id: &str, _app_state: &'a state::Router) -> Result { - Err("Not implemented".into()) - } + async fn put<'a>(&self, id: &str, app_state: &'a state::Router) -> Result; - async fn delete<'a>(&self, _id: &str, _app_state: &'a state::Router) -> Result<()> { - Err("Not implemented".into()) - } + async fn delete<'a>(&self, id: &str, app_state: &'a state::Router) -> Result<()>; } diff --git a/src/types/state.rs b/src/types/state.rs index 83f54bf..29de9a8 100644 --- a/src/types/state.rs +++ b/src/types/state.rs @@ -2,23 +2,23 @@ use super::{repository::DynLinks as DynLinksRepo, service::DynLinks as DynLinksS #[derive(Clone)] pub struct Router { - links_repo: DynLinksRepo, links_service: DynLinksService, + links_repo: DynLinksRepo, } impl Router { - pub fn new(links_repo: DynLinksRepo, links_service: DynLinksService) -> Self { + pub fn new(links_service: DynLinksService, links_repo: DynLinksRepo) -> Self { Self { - links_repo, links_service, + links_repo, } } - pub fn get_links_repo(&self) -> &DynLinksRepo { - &self.links_repo - } - pub fn get_links_service(&self) -> &DynLinksService { &self.links_service } + + pub fn get_links_repo(&self) -> &DynLinksRepo { + &self.links_repo + } } diff --git a/tests/links.rs b/tests/links.rs new file mode 100644 index 0000000..34f171d --- /dev/null +++ b/tests/links.rs @@ -0,0 +1,33 @@ +use std::error::Error; + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +// Verifies GET /v1/links request +// +// GIVEN empty set of link items in the Server +// WHEN GET /v1/links request is sent to the Server +// THEN the response is 200 OK and empty set of link items are returned +#[tokio::test] +async fn test_get_links_empty() -> Result<(), Box> { + let handler = link_for_later::router::new(); + let response = handler + .oneshot( + Request::builder() + .uri("/v1/links") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"[]"); + Ok(()) +}