diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01cb6be..99baff4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -123,7 +123,7 @@ jobs: run: | cargo install cargo-tarpaulin cargo --version - cargo tarpaulin --out lcov + cargo tarpaulin --out lcov --features k8s_tests - name: Upload to codecov.io uses: codecov/codecov-action@v3 diff --git a/Cargo.toml b/Cargo.toml index ec5b592..cb663ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,3 +37,6 @@ tower-test = "0.4.0" test-case = "3.1.0" rand = "0.8.5" serial_test = "2.0.0" + +[features] +k8s_tests = [] \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 2db9a14..3ed9209 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use stacks_devnet_api::api_config::ApiConfig; use stacks_devnet_api::responder::Responder; use stacks_devnet_api::routes::{ get_standardized_path_parts, handle_check_devnet, handle_delete_devnet, handle_get_devnet, - handle_new_devnet, handle_try_proxy_service, API_PATH, + handle_get_status, handle_new_devnet, handle_try_proxy_service, API_PATH, }; use stacks_devnet_api::{Context, StacksDevnetApiK8sManager}; use std::env; @@ -78,11 +78,13 @@ async fn handle_request( ) }); let headers = request.headers().clone(); - let responder = Responder::new(http_response_config, headers.clone()).unwrap(); - + let responder = Responder::new(http_response_config, headers.clone(), ctx.clone()).unwrap(); if method == &Method::OPTIONS { return responder.ok(); } + if method == &Method::GET && (path == "/" || path == &format!("{API_PATH}status")) { + return handle_get_status(responder, ctx).await; + } let auth_header = auth_config .auth_header .unwrap_or("x-auth-request-user".to_string()); diff --git a/src/responder.rs b/src/responder.rs index b359c30..5eb1957 100644 --- a/src/responder.rs +++ b/src/responder.rs @@ -1,3 +1,4 @@ +use hiro_system_kit::slog; use hyper::{ header::{ ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, @@ -8,26 +9,39 @@ use hyper::{ }; use std::convert::Infallible; -use crate::api_config::ResponderConfig; +use crate::{api_config::ResponderConfig, Context}; -#[derive(Default)] pub struct Responder { allowed_origins: Vec, allowed_methods: Vec, allowed_headers: String, headers: HeaderMap, + ctx: Context, } +impl Default for Responder { + fn default() -> Self { + Responder { + allowed_origins: Vec::default(), + allowed_methods: Vec::default(), + allowed_headers: String::default(), + headers: HeaderMap::default(), + ctx: Context::empty(), + } + } +} impl Responder { pub fn new( config: ResponderConfig, headers: HeaderMap, + ctx: Context, ) -> Result { Ok(Responder { allowed_origins: config.allowed_origins.unwrap_or_default(), allowed_methods: config.allowed_methods.unwrap_or_default(), allowed_headers: config.allowed_headers.unwrap_or("*".to_string()), headers, + ctx, }) } @@ -60,20 +74,61 @@ impl Responder { fn _respond(&self, code: StatusCode, body: String) -> Result, Infallible> { let builder = self.response_builder(); - match builder.status(code).body(Body::try_from(body).unwrap()) { + let body = match Body::try_from(body) { + Ok(b) => b, + Err(e) => { + self.ctx.try_log(|logger| { + slog::error!( + logger, + "responder failed to create response body: {}", + e.to_string() + ) + }); + Body::empty() + } + }; + match builder.status(code).body(body) { Ok(r) => Ok(r), - Err(_) => unreachable!(), + Err(e) => { + self.ctx.try_log(|logger| { + slog::error!( + logger, + "responder failed to send response: {}", + e.to_string() + ) + }); + Ok(self + .response_builder() + .status(500) + .body(Body::empty()) + .unwrap()) + } } } pub fn respond(&self, code: u16, body: String) -> Result, Infallible> { - self._respond(StatusCode::from_u16(code).unwrap(), body) + self._respond( + StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + body, + ) } pub fn ok(&self) -> Result, Infallible> { self._respond(StatusCode::OK, "Ok".into()) } + pub fn ok_with_json(&self, body: Body) -> Result, Infallible> { + match self + .response_builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(body) + { + Ok(r) => Ok(r), + Err(e) => self.err_internal(format!("failed to send response: {}", e.to_string())), + } + } + pub fn err_method_not_allowed(&self, body: String) -> Result, Infallible> { self._respond(StatusCode::METHOD_NOT_ALLOWED, body) } diff --git a/src/routes.rs b/src/routes.rs index ecb9c02..1d807cf 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,5 +1,6 @@ use hiro_system_kit::slog; -use hyper::{Body, Client, Request, Response, StatusCode, Uri}; +use hyper::{Body, Client, Request, Response, Uri}; +use serde_json::json; use std::{convert::Infallible, str::FromStr}; use crate::{ @@ -9,6 +10,27 @@ use crate::{ Context, StacksDevnetApiK8sManager, }; +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const PRJ_NAME: &str = env!("CARGO_PKG_NAME"); + +pub async fn handle_get_status( + responder: Responder, + ctx: Context, +) -> Result, Infallible> { + let version_info = format!("{PRJ_NAME} v{VERSION}"); + let version_info = json!({ "version": version_info }); + let version_info = match serde_json::to_vec(&version_info) { + Ok(v) => v, + Err(e) => { + let msg = format!("failed to parse version info: {}", e.to_string()); + ctx.try_log(|logger| slog::error!(logger, "{}", msg)); + return responder.err_internal(msg); + } + }; + let body = Body::from(version_info); + responder.ok_with_json(body) +} + pub async fn handle_new_devnet( request: Request, user_id: &str, @@ -60,12 +82,7 @@ pub async fn handle_get_devnet( ) -> Result, Infallible> { match k8s_manager.get_devnet_info(&network).await { Ok(devnet_info) => match serde_json::to_vec(&devnet_info) { - Ok(body) => Ok(responder - .response_builder() - .status(StatusCode::OK) - .header("Content-Type", "application/json") - .body(Body::from(body)) - .unwrap()), + Ok(body) => responder.ok_with_json(Body::from(body)), Err(e) => { let msg = format!( "failed to form response body: NAMESPACE: {}, ERROR: {}", diff --git a/src/tests/mod.rs b/src/tests/mod.rs index ffc8b2d..deac41d 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -26,6 +26,11 @@ use stacks_devnet_api::{ use test_case::test_case; use tower_test::mock::{self, Handle}; +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const PRJ_NAME: &str = env!("CARGO_PKG_NAME"); +fn get_version_info() -> String { + format!("{{\"version\":\"{PRJ_NAME} v{VERSION}\"}}") +} fn get_template_config() -> StacksDevnetConfig { let file_path = "src/tests/fixtures/stacks-devnet-config.json"; let file = File::open(file_path) @@ -115,6 +120,7 @@ enum TestBody { #[test_case("/api/v1/network/{namespace}/stacks-node/v2/info/", Method::GET, None, true => using assert_failed_proxy; "proxies requests to downstream nodes")] #[serial_test::serial] #[tokio::test] +#[cfg_attr(not(feature = "k8s_tests"), ignore)] async fn it_responds_to_valid_requests_with_deploy( mut request_path: &str, method: Method, @@ -175,11 +181,14 @@ async fn it_responds_to_valid_requests_with_deploy( } #[test_case("any", Method::OPTIONS, false => is equal_to (StatusCode::OK, "Ok".to_string()); "200 for any OPTIONS request")] +#[test_case("/", Method::GET, false => is equal_to (StatusCode::OK, get_version_info()); "200 for GET /")] +#[test_case("/api/v1/status", Method::GET, false => is equal_to (StatusCode::OK, get_version_info()); "200 for GET /api/v1/status")] #[test_case("/api/v1/network/{namespace}", Method::DELETE, true => using assert_cannot_delete_devnet_err; "409 for network DELETE request to non-existing network")] #[test_case("/api/v1/network/{namespace}", Method::GET, true => using assert_not_all_assets_exist_err; "404 for network GET request to non-existing network")] #[test_case("/api/v1/network/{namespace}", Method::HEAD, true => is equal_to (StatusCode::NOT_FOUND, "not found".to_string()); "404 for network HEAD request to non-existing network")] #[test_case("/api/v1/network/{namespace}/stacks-node/v2/info/", Method::GET, true => using assert_not_all_assets_exist_err; "404 for proxy requests to downstream nodes of non-existing network")] #[tokio::test] +#[cfg_attr(not(feature = "k8s_tests"), ignore)] async fn it_responds_to_valid_requests( mut request_path: &str, method: Method, @@ -328,15 +337,16 @@ async fn it_responds_to_invalid_request_header() { assert_eq!(body_str, "missing required auth header".to_string()); } +#[test_case("/api/v1/network/test", Method::OPTIONS => is equal_to "Ok".to_string())] +#[test_case("/api/v1/status", Method::GET => is equal_to get_version_info() )] +#[test_case("/", Method::GET => is equal_to get_version_info())] #[tokio::test] -async fn it_ignores_request_header_for_options_requests() { +async fn it_ignores_request_header_for_some_requests(request_path: &str, method: Method) -> String { let (k8s_manager, ctx) = get_mock_k8s_manager().await; - let request_builder = Request::builder() - .uri("/api/v1/network/test") - .method(Method::OPTIONS); + let request_builder = Request::builder().uri(request_path).method(method); let request: Request = request_builder.body(Body::empty()).unwrap(); - let response = handle_request( + let mut response = handle_request( request, k8s_manager.clone(), ApiConfig::default(), @@ -345,6 +355,10 @@ async fn it_ignores_request_header_for_options_requests() { .await .unwrap(); assert_eq!(response.status(), 200); + let body = response.body_mut(); + let bytes = body::to_bytes(body).await.unwrap().to_vec(); + let body_str = String::from_utf8(bytes).unwrap(); + body_str } #[test_case("" => is equal_to PathParts { route: String::new(), ..Default::default() }; "for empty path")] @@ -399,7 +413,7 @@ fn responder_allows_configuring_allowed_origins() { }; let mut headers = HeaderMap::new(); headers.append("ORIGIN", HeaderValue::from_str("example.com").unwrap()); - let responder = Responder::new(config, headers).unwrap(); + let responder = Responder::new(config, headers, Context::empty()).unwrap(); let builder = responder.response_builder(); let built_headers = builder.headers_ref().unwrap(); assert_eq!(built_headers.get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), "*"); @@ -411,6 +425,7 @@ fn responder_allows_configuring_allowed_origins() { #[serial_test::serial] #[tokio::test] +#[cfg_attr(not(feature = "k8s_tests"), ignore)] async fn namespace_prefix_config_prepends_header() { let (k8s_manager, ctx) = get_k8s_manager().await;