diff --git a/Cargo.toml b/Cargo.toml index 3a6c5779..c3a0590f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "nomos-da/reed-solomon", "nomos-da/kzg", "nomos-da/full-replication", + "nomos-http-api", "nomos-cli", "nodes/nomos-node", "nodes/mixnode", diff --git a/nomos-http-api/Cargo.toml b/nomos-http-api/Cargo.toml new file mode 100644 index 00000000..11562005 --- /dev/null +++ b/nomos-http-api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nomos-http-api" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-trait = "0.1" +overwatch-rs = { git = "https://github.com/logos-co/Overwatch", branch = "main" } +overwatch-derive = { git = "https://github.com/logos-co/Overwatch", branch = "main" } +tracing = "0.1" + +[dev-dependencies] +axum = "0.6.20" +tokio = { version = "1.32", features = ["full"] } +hyper = { version = "*", features = ["full"] } +utoipa = "3.5" +reqwest = { version = "0.11", features = ["blocking", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +utoipa-swagger-ui = { version = "3.1", features = ["axum"] } diff --git a/nomos-http-api/src/lib.rs b/nomos-http-api/src/lib.rs new file mode 100644 index 00000000..5aa6a8c3 --- /dev/null +++ b/nomos-http-api/src/lib.rs @@ -0,0 +1,63 @@ +use overwatch_rs::{ + services::{ + handle::ServiceStateHandle, + relay::NoMessage, + state::{NoOperator, NoState}, + ServiceCore, ServiceData, + }, + DynError, +}; + +/// A simple abstraction so that we can easily +/// change the underlying http server +#[async_trait::async_trait] +pub trait Backend { + type Error: std::error::Error + Send + Sync + 'static; + type Settings: Clone + Send + Sync + 'static; + + async fn new(settings: Self::Settings) -> Result + where + Self: Sized; + + async fn serve(self) -> Result<(), Self::Error>; +} + +#[derive(Debug, Clone)] +pub struct ApiServiceSettings { + pub backend_settings: S, +} + +pub struct ApiService { + settings: ApiServiceSettings, +} + +impl ServiceData for ApiService { + const SERVICE_ID: overwatch_rs::services::ServiceId = "nomos-api"; + + type Settings = ApiServiceSettings; + + type State = NoState; + + type StateOperator = NoOperator; + + type Message = NoMessage; +} + +#[async_trait::async_trait] +impl ServiceCore for ApiService +where + B: Backend + Send + Sync + 'static, +{ + /// Initialize the service with the given state + fn init(service_state: ServiceStateHandle) -> Result { + let settings = service_state.settings_reader.get_updated_settings(); + Ok(Self { settings }) + } + + /// Service main loop + async fn run(mut self) -> Result<(), DynError> { + let endpoint = B::new(self.settings.backend_settings).await?; + endpoint.serve().await?; + Ok(()) + } +} diff --git a/nomos-http-api/tests/todo.rs b/nomos-http-api/tests/todo.rs new file mode 100644 index 00000000..aae85c24 --- /dev/null +++ b/nomos-http-api/tests/todo.rs @@ -0,0 +1,361 @@ +use std::{ + net::{Ipv4Addr, SocketAddr}, + sync::Arc, + time::Duration, +}; + +use axum::{routing, Router, Server}; +use hyper::Error; +use nomos_http_api::{ApiService, ApiServiceSettings, Backend}; +use overwatch_derive::Services; +use overwatch_rs::{overwatch::OverwatchRunner, services::handle::ServiceHandle}; +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use utoipa_swagger_ui::SwaggerUi; + +use crate::todo::Store; + +#[derive(Services)] +pub struct NomosApi { + http: ServiceHandle>, +} + +#[derive(OpenApi)] +#[openapi( + paths( + todo::list_todos, + todo::search_todos, + todo::create_todo, + todo::mark_done, + todo::delete_todo, + ), + components( + schemas(todo::Todo, todo::TodoError) + ), + modifiers(&SecurityAddon), + tags( + (name = "todo", description = "Todo items management API") + ) +)] +struct ApiDoc; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } +} + +pub struct WebServer { + addr: SocketAddr, +} + +#[async_trait::async_trait] +impl Backend for WebServer { + type Error = hyper::Error; + + type Settings = SocketAddr; + + async fn new(settings: Self::Settings) -> Result + where + Self: Sized, + { + Ok(Self { addr: settings }) + } + + async fn serve(self) -> Result<(), Self::Error> { + let store = Arc::new(Store::default()); + let app = Router::new() + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) + .route( + "/todo", + routing::get(todo::list_todos).post(todo::create_todo), + ) + .route("/todo/search", routing::get(todo::search_todos)) + .route( + "/todo/:id", + routing::put(todo::mark_done).delete(todo::delete_todo), + ) + .with_state(store); + + Server::bind(&self.addr) + .serve(app.into_make_service()) + .await + } +} + +#[test] +fn test_todo() -> Result<(), Error> { + let addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 8080); + + // have to spawn the server in a separate thread because the overwatch limitation + std::thread::spawn(move || { + let app = OverwatchRunner::::run( + NomosApiServiceSettings { + http: ApiServiceSettings { + backend_settings: addr, + }, + }, + None, + ) + .unwrap(); + app.wait_finished(); + }); + + std::thread::sleep(Duration::from_secs(1)); + let client = reqwest::blocking::Client::new(); + + let response = client + .get(format!("http://{}/swagger-ui", addr)) + .send() + .unwrap(); + + assert!(response.status().is_success()); + + Ok(()) +} + +mod todo { + use std::sync::Arc; + + use axum::{ + extract::{Path, Query, State}, + response::IntoResponse, + Json, + }; + use hyper::{HeaderMap, StatusCode}; + use serde::{Deserialize, Serialize}; + use tokio::sync::Mutex; + use utoipa::{IntoParams, ToSchema}; + + /// In-memory todo store + pub(super) type Store = Mutex>; + + /// Item to do. + #[derive(Serialize, Deserialize, ToSchema, Clone)] + pub(super) struct Todo { + id: i32, + #[schema(example = "Buy groceries")] + value: String, + done: bool, + } + + /// Todo operation errors + #[derive(Serialize, Deserialize, ToSchema)] + pub(super) enum TodoError { + /// Todo already exists conflict. + #[schema(example = "Todo already exists")] + Conflict(String), + /// Todo not found by id. + #[schema(example = "id = 1")] + NotFound(String), + /// Todo operation unauthorized + #[schema(example = "missing api key")] + Unauthorized(String), + } + + /// List all Todo items + /// + /// List all Todo items from in-memory storage. + #[utoipa::path( + get, + path = "/todo", + responses( + (status = 200, description = "List all todos successfully", body = [Todo]) + ) + )] + pub(super) async fn list_todos(State(store): State>) -> Json> { + let todos = store.lock().await.clone(); + + Json(todos) + } + + /// Todo search query + #[derive(Deserialize, IntoParams)] + pub(super) struct TodoSearchQuery { + /// Search by value. Search is incase sensitive. + value: String, + /// Search by `done` status. + done: bool, + } + + /// Search Todos by query params. + /// + /// Search `Todo`s by query params and return matching `Todo`s. + #[utoipa::path( + get, + path = "/todo/search", + params( + TodoSearchQuery + ), + responses( + (status = 200, description = "List matching todos by query", body = [Todo]) + ) + )] + pub(super) async fn search_todos( + State(store): State>, + query: Query, + ) -> Json> { + Json( + store + .lock() + .await + .iter() + .filter(|todo| { + todo.value.to_lowercase() == query.value.to_lowercase() + && todo.done == query.done + }) + .cloned() + .collect(), + ) + } + + /// Create new Todo + /// + /// Tries to create a new Todo item to in-memory storage or fails with 409 conflict if already exists. + #[utoipa::path( + post, + path = "/todo", + request_body = Todo, + responses( + (status = 201, description = "Todo item created successfully", body = Todo), + (status = 409, description = "Todo already exists", body = TodoError) + ) + )] + pub(super) async fn create_todo( + State(store): State>, + Json(todo): Json, + ) -> impl IntoResponse { + let mut todos = store.lock().await; + + todos + .iter_mut() + .find(|existing_todo| existing_todo.id == todo.id) + .map(|found| { + ( + StatusCode::CONFLICT, + Json(TodoError::Conflict(format!( + "todo already exists: {}", + found.id + ))), + ) + .into_response() + }) + .unwrap_or_else(|| { + todos.push(todo.clone()); + + (StatusCode::CREATED, Json(todo)).into_response() + }) + } + + /// Mark Todo item done by id + /// + /// Mark Todo item done by given id. Return only status 200 on success or 404 if Todo is not found. + #[utoipa::path( + put, + path = "/todo/{id}", + responses( + (status = 200, description = "Todo marked done successfully"), + (status = 404, description = "Todo not found") + ), + params( + ("id" = i32, Path, description = "Todo database id") + ), + security( + (), // <-- make optional authentication + ("api_key" = []) + ) + )] + pub(super) async fn mark_done( + Path(id): Path, + State(store): State>, + headers: HeaderMap, + ) -> StatusCode { + match check_api_key(false, headers) { + Ok(_) => (), + Err(_) => return StatusCode::UNAUTHORIZED, + } + + let mut todos = store.lock().await; + + todos + .iter_mut() + .find(|todo| todo.id == id) + .map(|todo| { + todo.done = true; + StatusCode::OK + }) + .unwrap_or(StatusCode::NOT_FOUND) + } + + /// Delete Todo item by id + /// + /// Delete Todo item from in-memory storage by id. Returns either 200 success of 404 with TodoError if Todo is not found. + #[utoipa::path( + delete, + path = "/todo/{id}", + responses( + (status = 200, description = "Todo marked done successfully"), + (status = 401, description = "Unauthorized to delete Todo", body = TodoError, example = json!(TodoError::Unauthorized(String::from("missing api key")))), + (status = 404, description = "Todo not found", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) + ), + params( + ("id" = i32, Path, description = "Todo database id") + ), + security( + ("api_key" = []) + ) + )] + pub(super) async fn delete_todo( + Path(id): Path, + State(store): State>, + headers: HeaderMap, + ) -> impl IntoResponse { + match check_api_key(true, headers) { + Ok(_) => (), + Err(error) => return error.into_response(), + } + + let mut todos = store.lock().await; + + let len = todos.len(); + + todos.retain(|todo| todo.id != id); + + if todos.len() != len { + StatusCode::OK.into_response() + } else { + ( + StatusCode::NOT_FOUND, + Json(TodoError::NotFound(format!("id = {id}"))), + ) + .into_response() + } + } + + // normally you should create a middleware for this but this is sufficient for sake of example. + fn check_api_key( + require_api_key: bool, + headers: HeaderMap, + ) -> Result<(), (StatusCode, Json)> { + match headers.get("todo_apikey") { + Some(header) if header != "utoipa-rocks" => Err(( + StatusCode::UNAUTHORIZED, + Json(TodoError::Unauthorized(String::from("incorrect api key"))), + )), + None if require_api_key => Err(( + StatusCode::UNAUTHORIZED, + Json(TodoError::Unauthorized(String::from("missing api key"))), + )), + _ => Ok(()), + } + } +}