Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: status page at GET / and GET /api/v1/status routes #68

Merged
merged 11 commits into from
Sep 25, 2023
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
8 changes: 5 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
65 changes: 60 additions & 5 deletions src/responder.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use hiro_system_kit::slog;
use hyper::{
header::{
ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS,
Expand All @@ -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<String>,
allowed_methods: Vec<String>,
allowed_headers: String,
headers: HeaderMap<HeaderValue>,
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<HeaderValue>,
ctx: Context,
) -> Result<Responder, String> {
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,
})
}

Expand Down Expand Up @@ -60,20 +74,61 @@ impl Responder {

fn _respond(&self, code: StatusCode, body: String) -> Result<Response<Body>, 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<Response<Body>, 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<Response<Body>, Infallible> {
self._respond(StatusCode::OK, "Ok".into())
}

pub fn ok_with_json(&self, body: Body) -> Result<Response<Body>, 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<Response<Body>, Infallible> {
self._respond(StatusCode::METHOD_NOT_ALLOWED, body)
}
Expand Down
31 changes: 24 additions & 7 deletions src/routes.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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<Response<Body>, 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<Body>,
user_id: &str,
Expand Down Expand Up @@ -60,12 +82,7 @@ pub async fn handle_get_devnet(
) -> Result<Response<Body>, 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: {}",
Expand Down
27 changes: 21 additions & 6 deletions src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Body> = request_builder.body(Body::empty()).unwrap();
let response = handle_request(
let mut response = handle_request(
request,
k8s_manager.clone(),
ApiConfig::default(),
Expand All @@ -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")]
Expand Down Expand Up @@ -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(), "*");
Expand All @@ -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;

Expand Down
Loading