diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 677db49..476ddb3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -7,7 +7,8 @@ edition = "2021" [dependencies] actix-web ="4" -utoipa = { version = "3", features = ["actix_extras"] } +utoipa-swagger-ui = { version = "3", features = ["actix-web"] } +# utoipa = { version = "3", features = ["actix_extras"] } [[bin]] name = "webserver" diff --git a/backend/main.rs b/backend/main.rs new file mode 100644 index 0000000..52cde8c --- /dev/null +++ b/backend/main.rs @@ -0,0 +1,202 @@ +use std::{ + error::Error, + future::{self, Ready}, + net::Ipv4Addr, +}; + +use actix_web::{ + dev::{Service, ServiceRequest, ServiceResponse, Transform}, + middleware::Logger, + web::Data, + App, HttpResponse, HttpServer, +}; +use futures::future::LocalBoxFuture; +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use utoipa_swagger_ui::SwaggerUi; + +use crate::todo::{ErrorResponse, TodoStore}; + +mod todo; + +const API_KEY_NAME: &str = "todo_apikey"; +const API_KEY: &str = "utoipa-rocks"; + +#[actix_web::main] +async fn main() -> Result<(), impl Error> { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi( + paths( + todo::get_todos, + todo::create_todo, + todo::delete_todo, + todo::get_todo_by_id, + todo::update_todo, + todo::search_todos + ), + components( + schemas(todo::Todo, todo::TodoUpdateRequest, todo::ErrorResponse) + ), + tags( + (name = "todo", description = "Todo management endpoints.") + ), + modifiers(&SecurityAddon) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + + let store = Data::new(TodoStore::default()); + // Make instance variable of ApiDoc so all worker threads gets the same instance. + let openapi = ApiDoc::openapi(); + + HttpServer::new(move || { + // This factory closure is called on each worker thread independently. + App::new() + .wrap(Logger::default()) + .configure(todo::configure(store.clone())) + .service( + SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", openapi.clone()), + ) + }) + .bind((Ipv4Addr::UNSPECIFIED, 8080))? + .run() + .await +} + +/// Require api key middleware will actually require valid api key +struct RequireApiKey; + +impl Transform for RequireApiKey +where + S: Service< + ServiceRequest, + Response = ServiceResponse, + Error = actix_web::Error, + >, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Transform = ApiKeyMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + future::ready(Ok(ApiKeyMiddleware { + service, + log_only: false, + })) + } +} + +/// Log api key middleware only logs about missing or invalid api keys +struct LogApiKey; + +impl Transform for LogApiKey +where + S: Service< + ServiceRequest, + Response = ServiceResponse, + Error = actix_web::Error, + >, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Transform = ApiKeyMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + future::ready(Ok(ApiKeyMiddleware { + service, + log_only: true, + })) + } +} + +struct ApiKeyMiddleware { + service: S, + log_only: bool, +} + +impl Service for ApiKeyMiddleware +where + S: Service< + ServiceRequest, + Response = ServiceResponse, + Error = actix_web::Error, + >, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Future = LocalBoxFuture<'static, Result>; + + fn poll_ready( + &self, + ctx: &mut core::task::Context<'_>, + ) -> std::task::Poll> { + self.service.poll_ready(ctx) + } + + fn call(&self, req: ServiceRequest) -> Self::Future { + let response = |req: ServiceRequest, response: HttpResponse| -> Self::Future { + Box::pin(async { Ok(req.into_response(response)) }) + }; + + match req.headers().get(API_KEY_NAME) { + Some(key) if key != API_KEY => { + if self.log_only { + log::debug!("Incorrect api api provided!!!") + } else { + return response( + req, + HttpResponse::Unauthorized().json(ErrorResponse::Unauthorized( + String::from("incorrect api key"), + )), + ); + } + } + None => { + if self.log_only { + log::debug!("Missing api key!!!") + } else { + return response( + req, + HttpResponse::Unauthorized() + .json(ErrorResponse::Unauthorized(String::from("missing api key"))), + ); + } + } + _ => (), // just passthrough + } + + if self.log_only { + log::debug!("Performing operation") + } + + let future = self.service.call(req); + + Box::pin(async move { + let response = future.await?; + + Ok(response) + }) + } +} diff --git a/backend/todo.rs b/backend/todo.rs new file mode 100644 index 0000000..a08aab1 --- /dev/null +++ b/backend/todo.rs @@ -0,0 +1,269 @@ +use std::sync::Mutex; + +use actix_web::{ + delete, get, post, put, + web::{Data, Json, Path, Query, ServiceConfig}, + HttpResponse, Responder, +}; +use serde::{Deserialize, Serialize}; +use utoipa::{ToSchema, IntoParams}; + +use crate::{LogApiKey, RequireApiKey}; + +#[derive(Default)] +pub(super) struct TodoStore { + todos: Mutex>, +} + +pub(super) fn configure(store: Data) -> impl FnOnce(&mut ServiceConfig) { + |config: &mut ServiceConfig| { + config + .app_data(store) + .service(search_todos) + .service(get_todos) + .service(create_todo) + .service(delete_todo) + .service(get_todo_by_id) + .service(update_todo); + } +} + +/// Task to do. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +pub(super) struct Todo { + /// Unique id for the todo item. + #[schema(example = 1)] + id: i32, + /// Description of the tasks to do. + #[schema(example = "Remember to buy groceries")] + value: String, + /// Mark is the task done or not + checked: bool, +} + +/// Request to update existing `Todo` item. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +pub(super) struct TodoUpdateRequest { + /// Optional new value for the `Todo` task. + #[schema(example = "Dentist at 14.00")] + value: Option, + /// Optional check status to mark is the task done or not. + checked: Option, +} + +/// Todo endpoint error responses +#[derive(Serialize, Deserialize, Clone, ToSchema)] +pub(super) enum ErrorResponse { + /// When Todo is not found by search term. + NotFound(String), + /// When there is a conflict storing a new todo. + Conflict(String), + /// When todo endpoint was called without correct credentials + Unauthorized(String), +} + +/// Get list of todos. +/// +/// List todos from in-memory todo store. +/// +/// One could call the api endpoint with following curl. +/// ```text +/// curl localhost:8080/todo +/// ``` +#[utoipa::path( + responses( + (status = 200, description = "List current todo items", body = [Todo]) + ) +)] +#[get("/todo")] +pub(super) async fn get_todos(todo_store: Data) -> impl Responder { + let todos = todo_store.todos.lock().unwrap(); + + HttpResponse::Ok().json(todos.clone()) +} + +/// Create new Todo to shared in-memory storage. +/// +/// Post a new `Todo` in request body as json to store it. Api will return +/// created `Todo` on success or `ErrorResponse::Conflict` if todo with same id already exists. +/// +/// One could call the api with. +/// ```text +/// curl localhost:8080/todo -d '{"id": 1, "value": "Buy movie ticket", "checked": false}' +/// ``` +#[utoipa::path( + request_body = Todo, + responses( + (status = 201, description = "Todo created successfully", body = Todo), + (status = 409, description = "Todo with id already exists", body = ErrorResponse, example = json!(ErrorResponse::Conflict(String::from("id = 1")))) + ) +)] +#[post("/todo")] +pub(super) async fn create_todo(todo: Json, todo_store: Data) -> impl Responder { + let mut todos = todo_store.todos.lock().unwrap(); + let todo = &todo.into_inner(); + + todos + .iter() + .find(|existing| existing.id == todo.id) + .map(|existing| { + HttpResponse::Conflict().json(ErrorResponse::Conflict(format!("id = {}", existing.id))) + }) + .unwrap_or_else(|| { + todos.push(todo.clone()); + + HttpResponse::Ok().json(todo) + }) +} + +/// Delete Todo by given path variable id. +/// +/// This endpoint needs `api_key` authentication in order to call. Api key can be found from README.md. +/// +/// Api will delete todo from shared in-memory storage by the provided id and return success 200. +/// If storage does not contain `Todo` with given id 404 not found will be returned. +#[utoipa::path( + responses( + (status = 200, description = "Todo deleted successfully"), + (status = 401, description = "Unauthorized to delete Todo", body = ErrorResponse, example = json!(ErrorResponse::Unauthorized(String::from("missing api key")))), + (status = 404, description = "Todo not found by id", body = ErrorResponse, example = json!(ErrorResponse::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Unique storage id of Todo") + ), + security( + ("api_key" = []) + ) +)] +#[delete("/todo/{id}", wrap = "RequireApiKey")] +pub(super) async fn delete_todo(id: Path, todo_store: Data) -> impl Responder { + let mut todos = todo_store.todos.lock().unwrap(); + let id = id.into_inner(); + + let new_todos = todos + .iter() + .filter(|todo| todo.id != id) + .cloned() + .collect::>(); + + if new_todos.len() == todos.len() { + HttpResponse::NotFound().json(ErrorResponse::NotFound(format!("id = {id}"))) + } else { + *todos = new_todos; + HttpResponse::Ok().finish() + } +} + +/// Get Todo by given todo id. +/// +/// Return found `Todo` with status 200 or 404 not found if `Todo` is not found from shared in-memory storage. +#[utoipa::path( + responses( + (status = 200, description = "Todo found from storage", body = Todo), + (status = 404, description = "Todo not found by id", body = ErrorResponse, example = json!(ErrorResponse::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Unique storage id of Todo") + ) +)] +#[get("/todo/{id}")] +pub(super) async fn get_todo_by_id(id: Path, todo_store: Data) -> impl Responder { + let todos = todo_store.todos.lock().unwrap(); + let id = id.into_inner(); + + todos + .iter() + .find(|todo| todo.id == id) + .map(|todo| HttpResponse::Ok().json(todo)) + .unwrap_or_else(|| { + HttpResponse::NotFound().json(ErrorResponse::NotFound(format!("id = {id}"))) + }) +} + +/// Update Todo with given id. +/// +/// This endpoint supports optional authentication. +/// +/// Tries to update `Todo` by given id as path variable. If todo is found by id values are +/// updated according `TodoUpdateRequest` and updated `Todo` is returned with status 200. +/// If todo is not found then 404 not found is returned. +#[utoipa::path( + request_body = TodoUpdateRequest, + responses( + (status = 200, description = "Todo updated successfully", body = Todo), + (status = 404, description = "Todo not found by id", body = ErrorResponse, example = json!(ErrorResponse::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Unique storage id of Todo") + ), + security( + (), + ("api_key" = []) + ) +)] +#[put("/todo/{id}", wrap = "LogApiKey")] +pub(super) async fn update_todo( + id: Path, + todo: Json, + todo_store: Data, +) -> impl Responder { + let mut todos = todo_store.todos.lock().unwrap(); + let id = id.into_inner(); + let todo = todo.into_inner(); + + todos + .iter_mut() + .find_map(|todo| if todo.id == id { Some(todo) } else { None }) + .map(|existing_todo| { + if let Some(checked) = todo.checked { + existing_todo.checked = checked; + } + if let Some(value) = todo.value { + existing_todo.value = value; + } + + HttpResponse::Ok().json(existing_todo) + }) + .unwrap_or_else(|| { + HttpResponse::NotFound().json(ErrorResponse::NotFound(format!("id = {id}"))) + }) +} + +/// Search todos Query +#[derive(Deserialize, Debug, IntoParams)] +pub(super) struct SearchTodos { + /// Content that should be found from Todo's value field + value: String, +} + +/// Search Todos with by value +/// +/// Perform search from `Todo`s present in in-memory storage by matching Todo's value to +/// value provided as query parameter. Returns 200 and matching `Todo` items. +#[utoipa::path( + params( + SearchTodos + ), + responses( + (status = 200, description = "Search Todos did not result error", body = [Todo]), + ) +)] +#[get("/todo/search")] +pub(super) async fn search_todos( + query: Query, + todo_store: Data, +) -> impl Responder { + let todos = todo_store.todos.lock().unwrap(); + + HttpResponse::Ok().json( + todos + .iter() + .filter(|todo| { + todo.value + .to_lowercase() + .contains(&query.value.to_lowercase()) + }) + .cloned() + .collect::>(), + ) +} diff --git a/backend/web.rs b/backend/web.rs index 45a43ef..4ede752 100644 --- a/backend/web.rs +++ b/backend/web.rs @@ -1,28 +1,9 @@ -use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder}; - -#[get("/")] -async fn hello() -> impl Responder { - HttpResponse::Ok().body("Hello world!") -} - -#[post("/echo")] -async fn echo(req_body: String) -> impl Responder { - HttpResponse::Ok().body(req_body) -} - -async fn manual_hello() -> impl Responder { - HttpResponse::Ok().body("Hey there!") -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - HttpServer::new(|| { - App::new() - .service(hello) - .service(echo) - .route("/hey", web::get().to(manual_hello)) - }) - .bind(("127.0.0.1", 8080))? - .run() - .await -} \ No newline at end of file +HttpServer::new(move || { + App::new() + .service( + SwaggerUi::new("/swagger-ui/{_:.*}") + .url("/api-docs/openapi.json", ApiDoc::openapi()), + ) + }) + .bind((Ipv4Addr::UNSPECIFIED, 8000)).unwrap() + .run(); \ No newline at end of file