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]
|
[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
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};
|
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
|
|
||||||
}
|
|
Loading…
Reference in a new issue