mirror of
https://gitlab.com/msrd0/gotham-restful.git
synced 2025-02-23 04:52:28 +00:00
split the openapi code into several files
This commit is contained in:
parent
b4eaeca01c
commit
01f818e268
7 changed files with 221 additions and 211 deletions
|
@ -27,7 +27,7 @@ gotham_restful_derive = { version = "0.0.4-dev" }
|
||||||
indexmap = { version = "1.3.2", optional = true }
|
indexmap = { version = "1.3.2", optional = true }
|
||||||
itertools = "0.9.0"
|
itertools = "0.9.0"
|
||||||
jsonwebtoken = { version = "7.1.0", optional = true }
|
jsonwebtoken = { version = "7.1.0", optional = true }
|
||||||
log = { version = "0.4.8", optional = true }
|
log = "0.4.8"
|
||||||
mime = "0.3.16"
|
mime = "0.3.16"
|
||||||
openapiv3 = { version = "0.3", optional = true }
|
openapiv3 = { version = "0.3", optional = true }
|
||||||
serde = { version = "1.0.106", features = ["derive"] }
|
serde = { version = "1.0.106", features = ["derive"] }
|
||||||
|
@ -44,7 +44,7 @@ default = []
|
||||||
auth = ["gotham_restful_derive/auth", "base64", "cookie", "jsonwebtoken"]
|
auth = ["gotham_restful_derive/auth", "base64", "cookie", "jsonwebtoken"]
|
||||||
errorlog = []
|
errorlog = []
|
||||||
database = ["gotham_restful_derive/database", "gotham_middleware_diesel"]
|
database = ["gotham_restful_derive/database", "gotham_middleware_diesel"]
|
||||||
openapi = ["gotham_restful_derive/openapi", "indexmap", "log", "openapiv3"]
|
openapi = ["gotham_restful_derive/openapi", "indexmap", "openapiv3"]
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
[package.metadata.docs.rs]
|
||||||
all-features = true
|
all-features = true
|
||||||
|
|
|
@ -110,6 +110,7 @@ Licensed under your option of:
|
||||||
extern crate self as gotham_restful;
|
extern crate self as gotham_restful;
|
||||||
|
|
||||||
#[macro_use] extern crate gotham_derive;
|
#[macro_use] extern crate gotham_derive;
|
||||||
|
#[macro_use] extern crate log;
|
||||||
#[macro_use] extern crate serde;
|
#[macro_use] extern crate serde;
|
||||||
|
|
||||||
#[doc(no_inline)]
|
#[doc(no_inline)]
|
||||||
|
|
96
gotham_restful/src/openapi/builder.rs
Normal file
96
gotham_restful/src/openapi/builder.rs
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
use crate::{OpenapiType, OpenapiSchema};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use openapiv3::{
|
||||||
|
Components, OpenAPI, PathItem, ReferenceOr, ReferenceOr::Item, ReferenceOr::Reference, Schema,
|
||||||
|
Server
|
||||||
|
};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
|
pub struct OpenapiBuilder
|
||||||
|
{
|
||||||
|
pub openapi : Arc<RwLock<OpenAPI>>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenapiBuilder
|
||||||
|
{
|
||||||
|
pub fn new(title : String, version : String, url : String) -> Self
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
openapi: Arc::new(RwLock::new(OpenAPI {
|
||||||
|
openapi: "3.0.2".to_string(),
|
||||||
|
info: openapiv3::Info {
|
||||||
|
title, version,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
servers: vec![Server {
|
||||||
|
url,
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove path from the OpenAPI spec, or return an empty one if not included. This is handy if you need to
|
||||||
|
/// modify the path and add it back after the modification
|
||||||
|
pub fn remove_path(&mut self, path : &str) -> PathItem
|
||||||
|
{
|
||||||
|
let mut openapi = self.openapi.write().unwrap();
|
||||||
|
match openapi.paths.swap_remove(path) {
|
||||||
|
Some(Item(item)) => item,
|
||||||
|
_ => PathItem::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_path<Path : ToString>(&mut self, path : Path, item : PathItem)
|
||||||
|
{
|
||||||
|
let mut openapi = self.openapi.write().unwrap();
|
||||||
|
openapi.paths.insert(path.to_string(), Item(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_schema_impl(&mut self, name : String, mut schema : OpenapiSchema)
|
||||||
|
{
|
||||||
|
self.add_schema_dependencies(&mut schema.dependencies);
|
||||||
|
|
||||||
|
let mut openapi = self.openapi.write().unwrap();
|
||||||
|
match &mut openapi.components {
|
||||||
|
Some(comp) => {
|
||||||
|
comp.schemas.insert(name, Item(schema.into_schema()));
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
let mut comp = Components::default();
|
||||||
|
comp.schemas.insert(name, Item(schema.into_schema()));
|
||||||
|
openapi.components = Some(comp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_schema_dependencies(&mut self, dependencies : &mut IndexMap<String, OpenapiSchema>)
|
||||||
|
{
|
||||||
|
let keys : Vec<String> = dependencies.keys().map(|k| k.to_string()).collect();
|
||||||
|
for dep in keys
|
||||||
|
{
|
||||||
|
let dep_schema = dependencies.swap_remove(&dep);
|
||||||
|
if let Some(dep_schema) = dep_schema
|
||||||
|
{
|
||||||
|
self.add_schema_impl(dep, dep_schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_schema<T : OpenapiType>(&mut self) -> ReferenceOr<Schema>
|
||||||
|
{
|
||||||
|
let mut schema = T::schema();
|
||||||
|
match schema.name.clone() {
|
||||||
|
Some(name) => {
|
||||||
|
let reference = Reference { reference: format!("#/components/schemas/{}", name) };
|
||||||
|
self.add_schema_impl(name, schema);
|
||||||
|
reference
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
self.add_schema_dependencies(&mut schema.dependencies);
|
||||||
|
Item(schema.into_schema())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
110
gotham_restful/src/openapi/handler.rs
Normal file
110
gotham_restful/src/openapi/handler.rs
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
use super::SECURITY_NAME;
|
||||||
|
use futures_util::{future, future::FutureExt};
|
||||||
|
use gotham::{
|
||||||
|
error::Result,
|
||||||
|
handler::{Handler, HandlerFuture, NewHandler},
|
||||||
|
helpers::http::response::create_response,
|
||||||
|
state::State
|
||||||
|
};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use mime::{APPLICATION_JSON, TEXT_PLAIN};
|
||||||
|
use openapiv3::{APIKeyLocation, OpenAPI, ReferenceOr, SecurityScheme};
|
||||||
|
use std::{
|
||||||
|
pin::Pin,
|
||||||
|
sync::{Arc, RwLock}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OpenapiHandler
|
||||||
|
{
|
||||||
|
openapi : Arc<RwLock<OpenAPI>>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenapiHandler
|
||||||
|
{
|
||||||
|
pub fn new(openapi : Arc<RwLock<OpenAPI>>) -> Self
|
||||||
|
{
|
||||||
|
Self { openapi }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewHandler for OpenapiHandler
|
||||||
|
{
|
||||||
|
type Instance = Self;
|
||||||
|
|
||||||
|
fn new_handler(&self) -> Result<Self>
|
||||||
|
{
|
||||||
|
Ok(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "auth")]
|
||||||
|
fn get_security(state : &mut State) -> IndexMap<String, ReferenceOr<SecurityScheme>>
|
||||||
|
{
|
||||||
|
use crate::AuthSource;
|
||||||
|
use gotham::state::FromState;
|
||||||
|
|
||||||
|
let source = match AuthSource::try_borrow_from(state) {
|
||||||
|
Some(source) => source,
|
||||||
|
None => return Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let security_scheme = match source {
|
||||||
|
AuthSource::Cookie(name) => SecurityScheme::APIKey {
|
||||||
|
location: APIKeyLocation::Cookie,
|
||||||
|
name: name.to_string()
|
||||||
|
},
|
||||||
|
AuthSource::Header(name) => SecurityScheme::APIKey {
|
||||||
|
location: APIKeyLocation::Header,
|
||||||
|
name: name.to_string()
|
||||||
|
},
|
||||||
|
AuthSource::AuthorizationHeader => SecurityScheme::HTTP {
|
||||||
|
scheme: "bearer".to_owned(),
|
||||||
|
bearer_format: Some("JWT".to_owned())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut security_schemes : IndexMap<String, ReferenceOr<SecurityScheme>> = Default::default();
|
||||||
|
security_schemes.insert(SECURITY_NAME.to_owned(), ReferenceOr::Item(security_scheme));
|
||||||
|
|
||||||
|
security_schemes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "auth"))]
|
||||||
|
fn get_security(state : &mut State) -> (Vec<SecurityRequirement>, IndexMap<String, ReferenceOr<SecurityScheme>>)
|
||||||
|
{
|
||||||
|
Default::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler for OpenapiHandler
|
||||||
|
{
|
||||||
|
fn handle(self, mut state : State) -> Pin<Box<HandlerFuture>>
|
||||||
|
{
|
||||||
|
let openapi = match self.openapi.read() {
|
||||||
|
Ok(openapi) => openapi,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Unable to acquire read lock for the OpenAPI specification: {}", e);
|
||||||
|
let res = create_response(&state, crate::StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, "");
|
||||||
|
return future::ok((state, res)).boxed()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut openapi = openapi.clone();
|
||||||
|
let security_schemes = get_security(&mut state);
|
||||||
|
let mut components = openapi.components.unwrap_or_default();
|
||||||
|
components.security_schemes = security_schemes;
|
||||||
|
openapi.components = Some(components);
|
||||||
|
|
||||||
|
match serde_json::to_string(&openapi) {
|
||||||
|
Ok(body) => {
|
||||||
|
let res = create_response(&state, crate::StatusCode::OK, APPLICATION_JSON, body);
|
||||||
|
future::ok((state, res)).boxed()
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("Unable to handle OpenAPI request due to error: {}", e);
|
||||||
|
let res = create_response(&state, crate::StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, "");
|
||||||
|
future::ok((state, res)).boxed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,7 @@
|
||||||
|
|
||||||
|
const SECURITY_NAME : &str = "authToken";
|
||||||
|
|
||||||
|
pub mod builder;
|
||||||
|
pub mod handler;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
|
@ -6,220 +6,19 @@ use crate::{
|
||||||
OpenapiType,
|
OpenapiType,
|
||||||
RequestBody
|
RequestBody
|
||||||
};
|
};
|
||||||
use futures_util::{future, future::FutureExt};
|
use super::{builder::OpenapiBuilder, handler::OpenapiHandler, SECURITY_NAME};
|
||||||
use gotham::{
|
use gotham::{
|
||||||
handler::{Handler, HandlerFuture, NewHandler},
|
|
||||||
helpers::http::response::create_response,
|
|
||||||
pipeline::chain::PipelineHandleChain,
|
pipeline::chain::PipelineHandleChain,
|
||||||
router::builder::*,
|
router::builder::*
|
||||||
state::State
|
|
||||||
};
|
};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use log::error;
|
use mime::{Mime, STAR_STAR};
|
||||||
use mime::{Mime, APPLICATION_JSON, STAR_STAR, TEXT_PLAIN};
|
|
||||||
use openapiv3::{
|
use openapiv3::{
|
||||||
APIKeyLocation, Components, MediaType, OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent, PathItem,
|
MediaType, Operation, Parameter, ParameterData, ParameterSchemaOrContent, ReferenceOr,
|
||||||
ReferenceOr, ReferenceOr::Item, ReferenceOr::Reference, RequestBody as OARequestBody, Response, Responses, Schema,
|
ReferenceOr::Item, RequestBody as OARequestBody, Response, Responses, Schema, SchemaKind,
|
||||||
SchemaKind, SecurityScheme, Server, StatusCode, Type
|
StatusCode, Type
|
||||||
};
|
};
|
||||||
use std::{
|
use std::panic::RefUnwindSafe;
|
||||||
panic::RefUnwindSafe,
|
|
||||||
pin::Pin,
|
|
||||||
sync::{Arc, RwLock}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
This type is required to build routes while adding them to the generated OpenAPI Spec at the
|
|
||||||
same time. There is no need to use this type directly. See [`WithOpenapi`] on how to do this.
|
|
||||||
|
|
||||||
[`WithOpenapi`]: trait.WithOpenapi.html
|
|
||||||
*/
|
|
||||||
pub struct OpenapiBuilder
|
|
||||||
{
|
|
||||||
openapi : Arc<RwLock<OpenAPI>>
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpenapiBuilder
|
|
||||||
{
|
|
||||||
pub fn new(title : String, version : String, url : String) -> Self
|
|
||||||
{
|
|
||||||
Self {
|
|
||||||
openapi: Arc::new(RwLock::new(OpenAPI {
|
|
||||||
openapi: "3.0.2".to_string(),
|
|
||||||
info: openapiv3::Info {
|
|
||||||
title, version,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
servers: vec![Server {
|
|
||||||
url,
|
|
||||||
..Default::default()
|
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove path from the OpenAPI spec, or return an empty one if not included. This is handy if you need to
|
|
||||||
/// modify the path and add it back after the modification
|
|
||||||
fn remove_path(&mut self, path : &str) -> PathItem
|
|
||||||
{
|
|
||||||
let mut openapi = self.openapi.write().unwrap();
|
|
||||||
match openapi.paths.swap_remove(path) {
|
|
||||||
Some(Item(item)) => item,
|
|
||||||
_ => PathItem::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_path<Path : ToString>(&mut self, path : Path, item : PathItem)
|
|
||||||
{
|
|
||||||
let mut openapi = self.openapi.write().unwrap();
|
|
||||||
openapi.paths.insert(path.to_string(), Item(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_schema_impl(&mut self, name : String, mut schema : OpenapiSchema)
|
|
||||||
{
|
|
||||||
self.add_schema_dependencies(&mut schema.dependencies);
|
|
||||||
|
|
||||||
let mut openapi = self.openapi.write().unwrap();
|
|
||||||
match &mut openapi.components {
|
|
||||||
Some(comp) => {
|
|
||||||
comp.schemas.insert(name, Item(schema.into_schema()));
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
let mut comp = Components::default();
|
|
||||||
comp.schemas.insert(name, Item(schema.into_schema()));
|
|
||||||
openapi.components = Some(comp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_schema_dependencies(&mut self, dependencies : &mut IndexMap<String, OpenapiSchema>)
|
|
||||||
{
|
|
||||||
let keys : Vec<String> = dependencies.keys().map(|k| k.to_string()).collect();
|
|
||||||
for dep in keys
|
|
||||||
{
|
|
||||||
let dep_schema = dependencies.swap_remove(&dep);
|
|
||||||
if let Some(dep_schema) = dep_schema
|
|
||||||
{
|
|
||||||
self.add_schema_impl(dep, dep_schema);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_schema<T : OpenapiType>(&mut self) -> ReferenceOr<Schema>
|
|
||||||
{
|
|
||||||
let mut schema = T::schema();
|
|
||||||
match schema.name.clone() {
|
|
||||||
Some(name) => {
|
|
||||||
let reference = Reference { reference: format!("#/components/schemas/{}", name) };
|
|
||||||
self.add_schema_impl(name, schema);
|
|
||||||
reference
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
self.add_schema_dependencies(&mut schema.dependencies);
|
|
||||||
Item(schema.into_schema())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct OpenapiHandler
|
|
||||||
{
|
|
||||||
openapi : Arc<RwLock<OpenAPI>>
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OpenapiHandler
|
|
||||||
{
|
|
||||||
fn new(openapi : Arc<RwLock<OpenAPI>>) -> Self
|
|
||||||
{
|
|
||||||
Self { openapi }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NewHandler for OpenapiHandler
|
|
||||||
{
|
|
||||||
type Instance = Self;
|
|
||||||
|
|
||||||
fn new_handler(&self) -> gotham::error::Result<Self::Instance>
|
|
||||||
{
|
|
||||||
Ok(self.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "auth")]
|
|
||||||
const SECURITY_NAME : &str = "authToken";
|
|
||||||
|
|
||||||
#[cfg(feature = "auth")]
|
|
||||||
fn get_security(state : &mut State) -> IndexMap<String, ReferenceOr<SecurityScheme>>
|
|
||||||
{
|
|
||||||
use crate::AuthSource;
|
|
||||||
use gotham::state::FromState;
|
|
||||||
|
|
||||||
let source = match AuthSource::try_borrow_from(state) {
|
|
||||||
Some(source) => source,
|
|
||||||
None => return Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let security_scheme = match source {
|
|
||||||
AuthSource::Cookie(name) => SecurityScheme::APIKey {
|
|
||||||
location: APIKeyLocation::Cookie,
|
|
||||||
name: name.to_string()
|
|
||||||
},
|
|
||||||
AuthSource::Header(name) => SecurityScheme::APIKey {
|
|
||||||
location: APIKeyLocation::Header,
|
|
||||||
name: name.to_string()
|
|
||||||
},
|
|
||||||
AuthSource::AuthorizationHeader => SecurityScheme::HTTP {
|
|
||||||
scheme: "bearer".to_owned(),
|
|
||||||
bearer_format: Some("JWT".to_owned())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut security_schemes : IndexMap<String, ReferenceOr<SecurityScheme>> = Default::default();
|
|
||||||
security_schemes.insert(SECURITY_NAME.to_owned(), ReferenceOr::Item(security_scheme));
|
|
||||||
|
|
||||||
security_schemes
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "auth"))]
|
|
||||||
fn get_security(state : &mut State) -> (Vec<SecurityRequirement>, IndexMap<String, ReferenceOr<SecurityScheme>>)
|
|
||||||
{
|
|
||||||
Default::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Handler for OpenapiHandler
|
|
||||||
{
|
|
||||||
fn handle(self, mut state : State) -> Pin<Box<HandlerFuture>>
|
|
||||||
{
|
|
||||||
let openapi = match self.openapi.read() {
|
|
||||||
Ok(openapi) => openapi,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Unable to acquire read lock for the OpenAPI specification: {}", e);
|
|
||||||
let res = create_response(&state, crate::StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, "");
|
|
||||||
return future::ok((state, res)).boxed()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut openapi = openapi.clone();
|
|
||||||
let security_schemes = get_security(&mut state);
|
|
||||||
let mut components = openapi.components.unwrap_or_default();
|
|
||||||
components.security_schemes = security_schemes;
|
|
||||||
openapi.components = Some(components);
|
|
||||||
|
|
||||||
match serde_json::to_string(&openapi) {
|
|
||||||
Ok(body) => {
|
|
||||||
let res = create_response(&state, crate::StatusCode::OK, APPLICATION_JSON, body);
|
|
||||||
future::ok((state, res)).boxed()
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
error!("Unable to handle OpenAPI request due to error: {}", e);
|
|
||||||
let res = create_response(&state, crate::StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, "");
|
|
||||||
future::ok((state, res)).boxed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This trait adds the `get_openapi` method to an OpenAPI-aware router.
|
/// This trait adds the `get_openapi` method to an OpenAPI-aware router.
|
||||||
pub trait GetOpenapi
|
pub trait GetOpenapi
|
||||||
|
|
|
@ -6,7 +6,7 @@ use crate::{
|
||||||
StatusCode
|
StatusCode
|
||||||
};
|
};
|
||||||
#[cfg(feature = "openapi")]
|
#[cfg(feature = "openapi")]
|
||||||
use crate::openapi::router::OpenapiBuilder;
|
use crate::openapi::builder::OpenapiBuilder;
|
||||||
|
|
||||||
use futures_util::{future, future::FutureExt};
|
use futures_util::{future, future::FutureExt};
|
||||||
use gotham::{
|
use gotham::{
|
||||||
|
|
Loading…
Add table
Reference in a new issue