Skip to content

Commit

Permalink
added unit test, updated endpoints from put to patch, updated readme
Browse files Browse the repository at this point in the history
  • Loading branch information
benborla committed Aug 28, 2024
1 parent 4dd4972 commit 69e5b10
Show file tree
Hide file tree
Showing 11 changed files with 483 additions and 48 deletions.
247 changes: 218 additions & 29 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ thiserror = "1.0"
dotenv = "0.15.0"
sea-orm = { version = "1.0.0-rc.5", features = [ "sqlx-postgres", "runtime-tokio-native-tls", "macros", "debug-print" ] }
axum-macros = "0.4.1"
async-trait = "0.1.81"

[dev-dependencies]
bytes = "1.0"
futures = "0.3"
mockall = "0.13.0"
tokio-test = "0.4"
hyper = { version = "0.14", features = ["full"] }
tower = "0.4"
serde_json = "1.0"
sea-orm = { version = "1.0.0-rc.5", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros", "mock"] }
axum = "0.7.5"
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,11 @@ cargo test
- [Rust](https://www.rust-lang.org/) - Programming Language
- [Axum](https://github.com/tokio-rs/axum) - Web Framework
- [Tokio](https://tokio.rs/) - Asynchronous Runtime
- SQLX - async, pure Rust SQL crate featuring compile-time checked queries without a DSL.
- [SeaORM](https://www.sea-ql.org/SeaORM/) - ORM and Query Builder
- [SeaORM CLI](https://www.sea-ql.org/SeaORM/docs/generate-entity/sea-orm-cli/) - Official SeaORM CLI tool
- [Postgres](https://www.postgresql.org/) - Database
- [Neon](https://neon.tech/) - Serverless Postgres for modern developers
- [Docker](https://www.docker.com/) - Containerization
- [Docker](https://www.docker.com/) - Containerization (Soon)

## ✍️ Authors <a name = "authors"></a>
- [@benborla](https://github.com/benborla) - Idea & Initial work
Expand Down
9 changes: 4 additions & 5 deletions src/api/handlers/feature_flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::sync::Arc;

use crate::error::AppError;
use crate::models::feature_flags::Model as FeatureFlag;
use crate::services::feature_flag_service::FeatureFlagService;
use crate::services::feature_flag_service::{FeatureFlagService, PartialFeatureFlag};

#[debug_handler]
pub async fn all(
Expand Down Expand Up @@ -45,11 +45,10 @@ pub async fn get(
#[debug_handler]
pub async fn update(
State(service): State<Arc<FeatureFlagService>>,
Path(name): Path<String>,
Json(mut flag): Json<FeatureFlag>,
Path(id): Path<String>,
Json(flag): Json<PartialFeatureFlag>,
) -> Result<Json<FeatureFlag>, AppError> {
flag.name = name;
service.update(flag).await.map(Json)
service.update(&id, flag).await.map(Json)
}

#[debug_handler]
Expand Down
6 changes: 3 additions & 3 deletions src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ pub fn create_router(feature_flag_service: Arc<FeatureFlagService>) -> Router {
"/api/v1/feature_flags",
get(feature_flags::all).post(feature_flags::create),
)
//# Route [GET, PUT, DELETE]: /api/v1/feature_flags/[:name] => feature_flags handler
//# Route [GET, PATCH, DELETE]: /api/v1/feature_flags/[:name] => feature_flags handler
.route(
"/api/v1/featrure_flags/:name",
"/api/v1/feature_flags/:id",
get(feature_flags::get)
.put(feature_flags::update)
.patch(feature_flags::update)
.delete(feature_flags::delete),
)
.with_state(feature_flag_service)
Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ pub enum AppError {

#[error("Server Error: {0}")]
ServerError(#[from] std::io::Error),

#[error("Lock Error")]
LockError,
}

impl IntoResponse for AppError {
Expand All @@ -31,6 +34,7 @@ impl IntoResponse for AppError {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "Not Found"),
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "Bad request"),
AppError::ServerError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Server Error"),
AppError::LockError => (StatusCode::INTERNAL_SERVER_ERROR, "Lock Error"),
};

(status, error_message.to_string()).into_response()
Expand Down
62 changes: 53 additions & 9 deletions src/services/feature_flag_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,33 @@ use crate::models::feature_flags::{
ActiveModel as ActiveFeatureFlag, Entity as FeatureFlags, Model as FeatureFlag,
};
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ModelTrait, Set};
use serde::Deserialize;
use tracing::info;

//# @INFO: Allow partial update on feature_flags
//# This will be allowed (PATCH) /api/v1/feature_flags/some_feature_flag
//# Payload: { enabled: false }
#[derive(Deserialize)]
pub struct PartialFeatureFlag {
description: Option<String>,
enabled: Option<bool>,
}

pub trait IntoFeatureFlag {
fn into_feature_flag(self, existing: &FeatureFlag) -> FeatureFlag;
}

impl IntoFeatureFlag for PartialFeatureFlag {
fn into_feature_flag(self, existing: &FeatureFlag) -> FeatureFlag {
FeatureFlag {
name: existing.name.clone(),
description: self
.description
.unwrap_or_else(|| existing.description.clone()),
enabled: self.enabled.unwrap_or(existing.enabled),
}
}
}

pub struct FeatureFlagService {
db: DatabaseConnection,
Expand Down Expand Up @@ -41,17 +68,34 @@ impl FeatureFlagService {
.ok_or_else(|| AppError::NotFound(format!("Feature flag '{}' not found", name)))
}

pub async fn update(&self, flag: FeatureFlag) -> Result<FeatureFlag, AppError> {
let existing_flag = self.get(&flag.name).await?;
pub async fn update(
&self,
id: &str,
flag: PartialFeatureFlag,
) -> Result<FeatureFlag, AppError> {
let existing_flag = self.get(id).await?;
info!("Existing flag: {:?}", existing_flag);
let updated_flag = flag.into_feature_flag(&existing_flag);
info!("Updated flag: {:?}", updated_flag);

let mut active_flag: ActiveFeatureFlag = existing_flag.into();
active_flag.description = Set(flag.description);
active_flag.enabled = Set(flag.enabled);
let mut active_flag: ActiveFeatureFlag = existing_flag.clone().into();

active_flag
.update(&self.db)
.await
.map_err(AppError::DatabaseError)
if updated_flag.description != existing_flag.description {
active_flag.description = Set(updated_flag.description);
}

if updated_flag.enabled != existing_flag.enabled {
active_flag.enabled = Set(updated_flag.enabled);
}

if active_flag.is_changed() {
active_flag
.update(&self.db)
.await
.map_err(AppError::DatabaseError)
} else {
Ok(existing_flag)
}
}

pub async fn delete(&self, name: &str) -> Result<(), AppError> {
Expand Down
132 changes: 132 additions & 0 deletions tests/api_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
mod common;

use axum::{
body::Body,
http::{Request, StatusCode},
};
use rust_microservice_starter_kit::models::feature_flags::Model as FeatureFlag;
use serde_json::{json, Value};
use tower::ServiceExt; // for `oneshot` and `ready`

use crate::common::feature_flag_mock;
use crate::common::read_body;

#[tokio::test]
async fn test_health_check() {
let service = feature_flag_mock::create_feature_flag_service();
let app = feature_flag_mock::create_test_app(service);

let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();

let body = read_body(response.into_body()).await.unwrap();
let json: Value = serde_json::from_slice(&body).expect("Failed to parse JSON");

// Check the structure of the JSON response
assert_eq!(json["status"], "Ok");
assert!(json["available_routes"].is_array());
}

#[tokio::test]
async fn test_get_all_feature_flags() {
let service = feature_flag_mock::create_feature_flag_service();
let app = feature_flag_mock::create_test_app(service);

let response = app
.oneshot(
Request::builder()
.uri("/api/v1/feature_flags")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::OK);

let body = read_body(response.into_body()).await.unwrap();
let flags: Vec<FeatureFlag> = serde_json::from_slice(&body).unwrap();

assert_eq!(flags.len(), 3);
assert_eq!(flags[1].name, "flag1");
assert_eq!(flags[2].name, "flag2");
}

#[tokio::test]
async fn test_delete_feature_flag() {
let service = feature_flag_mock::create_feature_flag_service();
let app = feature_flag_mock::create_test_app(service);

let response = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/feature_flags/flag--sample-deleted")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert!(
response.status() == StatusCode::NO_CONTENT || response.status() == StatusCode::NOT_FOUND
);
}

#[tokio::test]
async fn test_get_specific_feature_flag() {
let service = feature_flag_mock::create_feature_flag_service();
let app = feature_flag_mock::create_test_app(service);

let response = app
.oneshot(
Request::builder()
.uri("/api/v1/feature_flags/new_flag")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

let body = read_body(response.into_body()).await.unwrap();
let flag: FeatureFlag = serde_json::from_slice(&body).unwrap();

assert_eq!(flag.name, "new_flag");
assert_eq!(flag.description, "New test flag");
assert_eq!(flag.enabled as i32, 1);
}

#[tokio::test]
async fn test_create_feature_flag() {
let service = feature_flag_mock::create_feature_flag_service();
let app = feature_flag_mock::create_test_app(service);

let new_flag = json!({
"name": "new_flag",
"description": "New test flag",
"enabled": true
});

let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/feature_flags")
.header("Content-Type", "application/json")
.body(Body::from(new_flag.to_string()))
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::CREATED);

let body = read_body(response.into_body()).await.unwrap();
let created_flag: FeatureFlag = serde_json::from_slice(&body).unwrap();

assert_eq!(created_flag.name, "new_flag");
assert_eq!(created_flag.description, "New test flag");
assert_eq!(created_flag.enabled as u8, 1);
}
35 changes: 35 additions & 0 deletions tests/common/feature_flag_mock.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use crate::common::create_mock_db;

use axum::Router;
use rust_microservice_starter_kit::{
api::routes, models::feature_flags, services::feature_flag_service::FeatureFlagService,
};
use std::sync::Arc;

//# @INFO: Preloaded Feature Flag Mocked Data
pub fn create_feature_flag_service() -> FeatureFlagService {
let mock_data = vec![
feature_flags::Model {
name: "new_flag".to_string(),
description: "New test flag".to_string(),
enabled: true,
},
feature_flags::Model {
name: "flag1".to_string(),
description: "Test flag 1".to_string(),
enabled: true,
},
feature_flags::Model {
name: "flag2".to_string(),
description: "Test flag 2".to_string(),
enabled: false,
},
];

let db = create_mock_db::<feature_flags::Entity>(mock_data);
FeatureFlagService::new(db)
}

pub fn create_test_app(service: FeatureFlagService) -> Router {
routes::create_router(Arc::new(service))
}
24 changes: 24 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
pub mod feature_flag_mock;

use axum::body::to_bytes;
use axum::body::Body;
use bytes::Bytes;
use sea_orm::{DatabaseConnection, EntityTrait, MockDatabase, MockExecResult};

const MAX_BODY_SIZE: usize = 1024 * 1024; // 1 MB limit, adjust as needed
pub async fn read_body(body: Body) -> Result<Bytes, Box<dyn std::error::Error>> {
Ok(to_bytes(body, MAX_BODY_SIZE).await?)
}

pub fn create_mock_db<E: EntityTrait>(mock_data: Vec<E::Model>) -> DatabaseConnection
where
E::Model: Clone,
{
MockDatabase::new(sea_orm::DatabaseBackend::Postgres)
.append_query_results([mock_data])
.append_exec_results([MockExecResult {
last_insert_id: 1,
rows_affected: 1,
}])
.into_connection()
}
Empty file removed tests/service.tests.rs
Empty file.

0 comments on commit 69e5b10

Please sign in to comment.