Finish the skeleton of the api for nomos-node (#451)
* finish the barebone of the api for nomos-node
This commit is contained in:
parent
dbac7d7597
commit
b43d268543
|
@ -13,6 +13,7 @@ members = [
|
||||||
"nomos-da/reed-solomon",
|
"nomos-da/reed-solomon",
|
||||||
"nomos-da/kzg",
|
"nomos-da/kzg",
|
||||||
"nomos-da/full-replication",
|
"nomos-da/full-replication",
|
||||||
|
"nomos-http-api",
|
||||||
"nomos-cli",
|
"nomos-cli",
|
||||||
"nodes/nomos-node",
|
"nodes/nomos-node",
|
||||||
"nodes/mixnode",
|
"nodes/mixnode",
|
||||||
|
|
|
@ -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"] }
|
|
@ -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<Self, Self::Error>
|
||||||
|
where
|
||||||
|
Self: Sized;
|
||||||
|
|
||||||
|
async fn serve(self) -> Result<(), Self::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ApiServiceSettings<S> {
|
||||||
|
pub backend_settings: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApiService<B: Backend> {
|
||||||
|
settings: ApiServiceSettings<B::Settings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: Backend> ServiceData for ApiService<B> {
|
||||||
|
const SERVICE_ID: overwatch_rs::services::ServiceId = "nomos-api";
|
||||||
|
|
||||||
|
type Settings = ApiServiceSettings<B::Settings>;
|
||||||
|
|
||||||
|
type State = NoState<Self::Settings>;
|
||||||
|
|
||||||
|
type StateOperator = NoOperator<Self::State>;
|
||||||
|
|
||||||
|
type Message = NoMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<B> ServiceCore for ApiService<B>
|
||||||
|
where
|
||||||
|
B: Backend + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
/// Initialize the service with the given state
|
||||||
|
fn init(service_state: ServiceStateHandle<Self>) -> Result<Self, DynError> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<ApiService<WebServer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Self, Self::Error>
|
||||||
|
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::<NomosApi>::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<Vec<Todo>>;
|
||||||
|
|
||||||
|
/// 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<Arc<Store>>) -> Json<Vec<Todo>> {
|
||||||
|
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<Arc<Store>>,
|
||||||
|
query: Query<TodoSearchQuery>,
|
||||||
|
) -> Json<Vec<Todo>> {
|
||||||
|
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<Arc<Store>>,
|
||||||
|
Json(todo): Json<Todo>,
|
||||||
|
) -> 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<i32>,
|
||||||
|
State(store): State<Arc<Store>>,
|
||||||
|
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<i32>,
|
||||||
|
State(store): State<Arc<Store>>,
|
||||||
|
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<TodoError>)> {
|
||||||
|
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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue