add todo main from utoipa-swagger-ui
This commit is contained in:
parent
bf627f6003
commit
cab6b92c52
4 changed files with 482 additions and 29 deletions
|
@ -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"
|
||||
|
|
202
backend/main.rs
Normal file
202
backend/main.rs
Normal 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
269
backend/todo.rs
Normal 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<_>>(),
|
||||
)
|
||||
}
|
|
@ -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(|| {
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.service(hello)
|
||||
.service(echo)
|
||||
.route("/hey", web::get().to(manual_hello))
|
||||
.service(
|
||||
SwaggerUi::new("/swagger-ui/{_:.*}")
|
||||
.url("/api-docs/openapi.json", ApiDoc::openapi()),
|
||||
)
|
||||
})
|
||||
.bind(("127.0.0.1", 8080))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
.bind((Ipv4Addr::UNSPECIFIED, 8000)).unwrap()
|
||||
.run();
|
Loading…
Reference in a new issue