Mitgliederladen/backend/todo.rs

269 lines
8.3 KiB
Rust

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<Vec<Todo>>,
}
pub(super) fn configure(store: Data<TodoStore>) -> 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<String>,
/// Optional check status to mark is the task done or not.
checked: Option<bool>,
}
/// 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<TodoStore>) -> 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>, todo_store: Data<TodoStore>) -> 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<i32>, todo_store: Data<TodoStore>) -> 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::<Vec<_>>();
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<i32>, todo_store: Data<TodoStore>) -> 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<i32>,
todo: Json<TodoUpdateRequest>,
todo_store: Data<TodoStore>,
) -> 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<SearchTodos>,
todo_store: Data<TodoStore>,
) -> 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::<Vec<_>>(),
)
}