add todo main from utoipa-swagger-ui

This commit is contained in:
Frank Denzer 2023-07-16 21:33:14 +02:00
parent bf627f6003
commit cab6b92c52
4 changed files with 482 additions and 29 deletions

View file

@ -7,7 +7,8 @@ edition = "2021"
[dependencies] [dependencies]
actix-web ="4" actix-web ="4"
utoipa = { version = "3", features = ["actix_extras"] } utoipa-swagger-ui = { version = "3", features = ["actix-web"] }
# utoipa = { version = "3", features = ["actix_extras"] }
[[bin]] [[bin]]
name = "webserver" name = "webserver"

202
backend/main.rs Normal file
View file

@ -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<S> Transform<S, ServiceRequest> for RequireApiKey
where
S: Service<
ServiceRequest,
Response = ServiceResponse<actix_web::body::BoxBody>,
Error = actix_web::Error,
>,
S::Future: 'static,
{
type Response = ServiceResponse<actix_web::body::BoxBody>;
type Error = actix_web::Error;
type Transform = ApiKeyMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
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<S> Transform<S, ServiceRequest> for LogApiKey
where
S: Service<
ServiceRequest,
Response = ServiceResponse<actix_web::body::BoxBody>,
Error = actix_web::Error,
>,
S::Future: 'static,
{
type Response = ServiceResponse<actix_web::body::BoxBody>;
type Error = actix_web::Error;
type Transform = ApiKeyMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
future::ready(Ok(ApiKeyMiddleware {
service,
log_only: true,
}))
}
}
struct ApiKeyMiddleware<S> {
service: S,
log_only: bool,
}
impl<S> Service<ServiceRequest> for ApiKeyMiddleware<S>
where
S: Service<
ServiceRequest,
Response = ServiceResponse<actix_web::body::BoxBody>,
Error = actix_web::Error,
>,
S::Future: 'static,
{
type Response = ServiceResponse<actix_web::body::BoxBody>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, actix_web::Error>>;
fn poll_ready(
&self,
ctx: &mut core::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
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)
})
}
}

269
backend/todo.rs Normal file
View file

@ -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<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<_>>(),
)
}

View file

@ -1,28 +1,9 @@
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder}; HttpServer::new(move || {
#[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() App::new()
.service(hello) .service(
.service(echo) SwaggerUi::new("/swagger-ui/{_:.*}")
.route("/hey", web::get().to(manual_hello)) .url("/api-docs/openapi.json", ApiDoc::openapi()),
)
}) })
.bind(("127.0.0.1", 8080))? .bind((Ipv4Addr::UNSPECIFIED, 8000)).unwrap()
.run() .run();
.await
}