mirror of
https://gitlab.com/msrd0/gotham-restful.git
synced 2025-04-19 22:44:38 +00:00
add swagger_ui to the router
This commit is contained in:
parent
7ed98c82e8
commit
28ae4dfdee
5 changed files with 230 additions and 53 deletions
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- New `endpoint` router extension with associated `Endpoint` trait ([!18])
|
- New `endpoint` router extension with associated `Endpoint` trait ([!18])
|
||||||
- Support for custom endpoints using the `#[endpoint]` macro ([!19])
|
- Support for custom endpoints using the `#[endpoint]` macro ([!19])
|
||||||
- Support for `anyhow::Error` (or any type implementing `Into<HandlerError>`) in most responses
|
- Support for `anyhow::Error` (or any type implementing `Into<HandlerError>`) in most responses
|
||||||
|
- `swagger_ui` method to the OpenAPI router to render the specification using Swagger UI
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- The cors handler can now copy headers from the request if desired
|
- The cors handler can now copy headers from the request if desired
|
||||||
|
|
26
Cargo.toml
26
Cargo.toml
|
@ -20,27 +20,33 @@ include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"]
|
||||||
gitlab = { repository = "msrd0/gotham-restful", branch = "master" }
|
gitlab = { repository = "msrd0/gotham-restful", branch = "master" }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = { version = "0.13.0", optional = true }
|
|
||||||
chrono = { version = "0.4.19", features = ["serde"], optional = true }
|
|
||||||
cookie = { version = "0.14", optional = true }
|
|
||||||
futures-core = "0.3.7"
|
futures-core = "0.3.7"
|
||||||
futures-util = "0.3.7"
|
futures-util = "0.3.7"
|
||||||
gotham = { version = "0.5.0", default-features = false }
|
gotham = { version = "0.5.0", default-features = false }
|
||||||
gotham_derive = "0.5.0"
|
gotham_derive = "0.5.0"
|
||||||
gotham_middleware_diesel = { version = "0.2.0", optional = true }
|
|
||||||
gotham_restful_derive = "0.2.0-dev"
|
gotham_restful_derive = "0.2.0-dev"
|
||||||
indexmap = { version = "1.3.2", optional = true }
|
|
||||||
jsonwebtoken = { version = "7.1.0", optional = true }
|
|
||||||
log = "0.4.8"
|
log = "0.4.8"
|
||||||
mime = "0.3.16"
|
mime = "0.3.16"
|
||||||
once_cell = { version = "1.5", optional = true }
|
|
||||||
openapiv3 = { version = "0.3.2", optional = true }
|
|
||||||
regex = { version = "1.4", optional = true }
|
|
||||||
serde = { version = "1.0.110", features = ["derive"] }
|
serde = { version = "1.0.110", features = ["derive"] }
|
||||||
serde_json = "1.0.58"
|
serde_json = "1.0.58"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
# features
|
||||||
|
chrono = { version = "0.4.19", features = ["serde"], optional = true }
|
||||||
uuid = { version = "0.8.1", optional = true }
|
uuid = { version = "0.8.1", optional = true }
|
||||||
|
|
||||||
|
# non-feature optional dependencies
|
||||||
|
base64 = { version = "0.13.0", optional = true }
|
||||||
|
cookie = { version = "0.14", optional = true }
|
||||||
|
gotham_middleware_diesel = { version = "0.2.0", optional = true }
|
||||||
|
indexmap = { version = "1.3.2", optional = true }
|
||||||
|
indoc = { version = "1.0", optional = true }
|
||||||
|
jsonwebtoken = { version = "7.1.0", optional = true }
|
||||||
|
once_cell = { version = "1.5", optional = true }
|
||||||
|
openapiv3 = { version = "0.3.2", optional = true }
|
||||||
|
regex = { version = "1.4", optional = true }
|
||||||
|
sha2 = { version = "0.9.3", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
diesel = { version = "1.4.4", features = ["postgres"] }
|
diesel = { version = "1.4.4", features = ["postgres"] }
|
||||||
futures-executor = "0.3.5"
|
futures-executor = "0.3.5"
|
||||||
|
@ -61,7 +67,7 @@ errorlog = []
|
||||||
|
|
||||||
# These features are exclusive - https://gitlab.com/msrd0/gotham-restful/-/issues/4
|
# These features are exclusive - https://gitlab.com/msrd0/gotham-restful/-/issues/4
|
||||||
without-openapi = []
|
without-openapi = []
|
||||||
openapi = ["gotham_restful_derive/openapi", "indexmap", "once_cell", "openapiv3", "regex"]
|
openapi = ["gotham_restful_derive/openapi", "base64", "indexmap", "indoc", "once_cell", "openapiv3", "regex", "sha2"]
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
[package.metadata.docs.rs]
|
||||||
no-default-features = true
|
no-default-features = true
|
||||||
|
|
|
@ -122,6 +122,7 @@ fn main() {
|
||||||
route.resource::<Users>("users");
|
route.resource::<Users>("users");
|
||||||
route.resource::<Auth>("auth");
|
route.resource::<Auth>("auth");
|
||||||
route.get_openapi("openapi");
|
route.get_openapi("openapi");
|
||||||
|
route.swagger_ui("");
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,37 +4,26 @@ use futures_util::{future, future::FutureExt};
|
||||||
use gotham::{
|
use gotham::{
|
||||||
anyhow,
|
anyhow,
|
||||||
handler::{Handler, HandlerFuture, NewHandler},
|
handler::{Handler, HandlerFuture, NewHandler},
|
||||||
helpers::http::response::create_response,
|
helpers::http::response::{create_empty_response, create_response},
|
||||||
hyper::StatusCode,
|
hyper::{
|
||||||
|
header::{
|
||||||
|
HeaderMap, HeaderValue, CACHE_CONTROL, CONTENT_SECURITY_POLICY, ETAG, IF_NONE_MATCH, REFERRER_POLICY,
|
||||||
|
X_CONTENT_TYPE_OPTIONS
|
||||||
|
},
|
||||||
|
Body, Response, StatusCode, Uri
|
||||||
|
},
|
||||||
state::State
|
state::State
|
||||||
};
|
};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use mime::{APPLICATION_JSON, TEXT_PLAIN};
|
use mime::{APPLICATION_JSON, TEXT_HTML, TEXT_PLAIN};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
use openapiv3::{APIKeyLocation, OpenAPI, ReferenceOr, SecurityScheme};
|
use openapiv3::{APIKeyLocation, OpenAPI, ReferenceOr, SecurityScheme};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
use std::{
|
||||||
pin::Pin,
|
pin::Pin,
|
||||||
sync::{Arc, RwLock}
|
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) -> anyhow::Result<Self> {
|
|
||||||
Ok(self.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "auth")]
|
#[cfg(feature = "auth")]
|
||||||
fn get_security(state: &mut State) -> IndexMap<String, ReferenceOr<SecurityScheme>> {
|
fn get_security(state: &mut State) -> IndexMap<String, ReferenceOr<SecurityScheme>> {
|
||||||
use crate::AuthSource;
|
use crate::AuthSource;
|
||||||
|
@ -71,33 +60,202 @@ fn get_security(_state: &mut State) -> IndexMap<String, ReferenceOr<SecuritySche
|
||||||
Default::default()
|
Default::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_openapi_response(state: &mut State, openapi: &Arc<RwLock<OpenAPI>>) -> Response<Body> {
|
||||||
|
let openapi = match openapi.read() {
|
||||||
|
Ok(openapi) => openapi,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Unable to acquire read lock for the OpenAPI specification: {}", e);
|
||||||
|
return create_response(&state, StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut openapi = openapi.clone();
|
||||||
|
let security_schemes = get_security(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 mut res = create_response(&state, StatusCode::OK, APPLICATION_JSON, body);
|
||||||
|
let headers = res.headers_mut();
|
||||||
|
headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||||
|
res
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
error!("Unable to handle OpenAPI request due to error: {}", e);
|
||||||
|
create_response(&state, StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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) -> anyhow::Result<Self> {
|
||||||
|
Ok(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Handler for OpenapiHandler {
|
impl Handler for OpenapiHandler {
|
||||||
fn handle(self, mut state: State) -> Pin<Box<HandlerFuture>> {
|
fn handle(self, mut state: State) -> Pin<Box<HandlerFuture>> {
|
||||||
let openapi = match self.openapi.read() {
|
let res = create_openapi_response(&mut state, &self.openapi);
|
||||||
Ok(openapi) => openapi,
|
future::ok((state, res)).boxed()
|
||||||
Err(e) => {
|
}
|
||||||
error!("Unable to acquire read lock for the OpenAPI specification: {}", e);
|
}
|
||||||
let res = create_response(&state, StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, "");
|
|
||||||
return future::ok((state, res)).boxed();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut openapi = openapi.clone();
|
#[derive(Clone)]
|
||||||
let security_schemes = get_security(&mut state);
|
pub struct SwaggerUiHandler {
|
||||||
let mut components = openapi.components.unwrap_or_default();
|
openapi: Arc<RwLock<OpenAPI>>
|
||||||
components.security_schemes = security_schemes;
|
}
|
||||||
openapi.components = Some(components);
|
|
||||||
|
|
||||||
match serde_json::to_string(&openapi) {
|
impl SwaggerUiHandler {
|
||||||
Ok(body) => {
|
pub fn new(openapi: Arc<RwLock<OpenAPI>>) -> Self {
|
||||||
let res = create_response(&state, StatusCode::OK, APPLICATION_JSON, body);
|
Self { openapi }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NewHandler for SwaggerUiHandler {
|
||||||
|
type Instance = Self;
|
||||||
|
|
||||||
|
fn new_handler(&self) -> anyhow::Result<Self> {
|
||||||
|
Ok(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler for SwaggerUiHandler {
|
||||||
|
fn handle(self, mut state: State) -> Pin<Box<HandlerFuture>> {
|
||||||
|
let uri: &Uri = state.borrow();
|
||||||
|
let query = uri.query();
|
||||||
|
match query {
|
||||||
|
// TODO this is hacky
|
||||||
|
Some(q) if q.contains("spec") => {
|
||||||
|
let res = create_openapi_response(&mut state, &self.openapi);
|
||||||
future::ok((state, res)).boxed()
|
future::ok((state, res)).boxed()
|
||||||
},
|
},
|
||||||
Err(e) => {
|
_ => {
|
||||||
error!("Unable to handle OpenAPI request due to error: {}", e);
|
{
|
||||||
let res = create_response(&state, StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, "");
|
let headers: &HeaderMap = state.borrow();
|
||||||
|
if headers
|
||||||
|
.get(IF_NONE_MATCH)
|
||||||
|
.map_or(false, |etag| etag.as_bytes() == SWAGGER_UI_HTML_ETAG.as_bytes())
|
||||||
|
{
|
||||||
|
let res = create_empty_response(&state, StatusCode::NOT_MODIFIED);
|
||||||
|
return future::ok((state, res)).boxed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut res = create_response(&state, StatusCode::OK, TEXT_HTML, SWAGGER_UI_HTML.as_bytes());
|
||||||
|
let headers = res.headers_mut();
|
||||||
|
headers.insert(CACHE_CONTROL, HeaderValue::from_static("public,max-age=2592000"));
|
||||||
|
headers.insert(CONTENT_SECURITY_POLICY, format!("default-src 'none'; script-src 'unsafe-inline' 'sha256-{}' 'strict-dynamic'; style-src 'unsafe-inline' https://cdnjs.cloudflare.com; connect-src 'self'; img-src data:;", SWAGGER_UI_SCRIPT_HASH.as_str()).parse().unwrap());
|
||||||
|
headers.insert(ETAG, SWAGGER_UI_HTML_ETAG.parse().unwrap());
|
||||||
|
headers.insert(REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin"));
|
||||||
|
headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||||
future::ok((state, res)).boxed()
|
future::ok((state, res)).boxed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inspired by https://github.com/swagger-api/swagger-ui/blob/master/dist/index.html
|
||||||
|
const SWAGGER_UI_HTML: Lazy<&'static String> = Lazy::new(|| {
|
||||||
|
let template = indoc::indoc! {
|
||||||
|
r#"
|
||||||
|
<!DOCTYPE HTML>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.43.0/swagger-ui.css" integrity="sha512-sphGjcjFvN5sAW6S28Ge+F9SCzRuc9IVkLinDHu7B1wOUHHFAY5sSQ2Axff+qs7/0GTm0Ifg4i0lQKgM8vdV2w==" crossorigin="anonymous"/>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script>{{script}}</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#
|
||||||
|
};
|
||||||
|
Box::leak(Box::new(template.replace("{{script}}", SWAGGER_UI_SCRIPT)))
|
||||||
|
});
|
||||||
|
static SWAGGER_UI_HTML_ETAG: Lazy<String> = Lazy::new(|| {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(SWAGGER_UI_HTML.as_bytes());
|
||||||
|
let hash = hash.finalize();
|
||||||
|
let hash = base64::encode(hash);
|
||||||
|
format!("\"{}\"", hash)
|
||||||
|
});
|
||||||
|
const SWAGGER_UI_SCRIPT: &str = r#"
|
||||||
|
let s0rdy = false;
|
||||||
|
let s1rdy = false;
|
||||||
|
|
||||||
|
window.onload = function() {
|
||||||
|
const cb = function() {
|
||||||
|
if (!s0rdy || !s1rdy)
|
||||||
|
return;
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: window.location.origin + window.location.pathname + '?spec',
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: 'StandaloneLayout'
|
||||||
|
});
|
||||||
|
window.ui = ui;
|
||||||
|
};
|
||||||
|
|
||||||
|
const s0 = document.createElement('script');
|
||||||
|
s0.setAttribute('src', 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.43.0/swagger-ui-bundle.js');
|
||||||
|
s0.setAttribute('integrity', 'sha512-EfK//grBlevo9MrtEDyNvf4SkBA0avHZoVLEuSR2Yl6ymnjcIwClgZ7FXdr/42yGqnhEHxb+Sv/bJeUp26YPRw==');
|
||||||
|
s0.setAttribute('crossorigin', 'anonymous');
|
||||||
|
s0.onload = function() {
|
||||||
|
s0rdy = true;
|
||||||
|
cb();
|
||||||
|
};
|
||||||
|
document.head.appendChild(s0);
|
||||||
|
|
||||||
|
const s1 = document.createElement('script');
|
||||||
|
s1.setAttribute('src', 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.43.0/swagger-ui-standalone-preset.js');
|
||||||
|
s1.setAttribute('integrity', 'sha512-Hbx9NyAhG+P6YNBU9mp6hc6ntRjGYHqo/qae4OHhzPA69xbNmF8n2aJxpzUwdbXbYICO6eor4IhgSfiSQm9OYg==');
|
||||||
|
s1.setAttribute('crossorigin', 'anonymous');
|
||||||
|
s1.onload = function() {
|
||||||
|
s1rdy = true;
|
||||||
|
cb();
|
||||||
|
};
|
||||||
|
document.head.appendChild(s1);
|
||||||
|
};
|
||||||
|
"#;
|
||||||
|
static SWAGGER_UI_SCRIPT_HASH: Lazy<String> = Lazy::new(|| {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
hash.update(SWAGGER_UI_SCRIPT);
|
||||||
|
let hash = hash.finalize();
|
||||||
|
base64::encode(hash)
|
||||||
|
});
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
use super::{builder::OpenapiBuilder, handler::OpenapiHandler, operation::OperationDescription};
|
use super::{
|
||||||
|
builder::OpenapiBuilder,
|
||||||
|
handler::{OpenapiHandler, SwaggerUiHandler},
|
||||||
|
operation::OperationDescription
|
||||||
|
};
|
||||||
use crate::{routing::*, EndpointWithSchema, OpenapiType, ResourceResultSchema, ResourceWithSchema};
|
use crate::{routing::*, EndpointWithSchema, OpenapiType, ResourceResultSchema, ResourceWithSchema};
|
||||||
use gotham::{hyper::Method, pipeline::chain::PipelineHandleChain, router::builder::*};
|
use gotham::{hyper::Method, pipeline::chain::PipelineHandleChain, router::builder::*};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::{Captures, Regex};
|
use regex::{Captures, Regex};
|
||||||
use std::panic::RefUnwindSafe;
|
use std::panic::RefUnwindSafe;
|
||||||
|
|
||||||
/// This trait adds the `get_openapi` method to an OpenAPI-aware router.
|
/// This trait adds the `get_openapi` and `swagger_ui` method to an OpenAPI-aware router.
|
||||||
pub trait GetOpenapi {
|
pub trait GetOpenapi {
|
||||||
fn get_openapi(&mut self, path: &str);
|
fn get_openapi(&mut self, path: &str);
|
||||||
|
fn swagger_ui(&mut self, path: &str);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -51,6 +56,12 @@ macro_rules! implOpenapiRouter {
|
||||||
.get(path)
|
.get(path)
|
||||||
.to_new_handler(OpenapiHandler::new(self.openapi_builder.openapi.clone()));
|
.to_new_handler(OpenapiHandler::new(self.openapi_builder.openapi.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn swagger_ui(&mut self, path: &str) {
|
||||||
|
self.router
|
||||||
|
.get(path)
|
||||||
|
.to_new_handler(SwaggerUiHandler::new(self.openapi_builder.openapi.clone()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, 'b, C, P> DrawResourcesWithSchema for OpenapiRouter<'a, $implType<'b, C, P>>
|
impl<'a, 'b, C, P> DrawResourcesWithSchema for OpenapiRouter<'a, $implType<'b, C, P>>
|
||||||
|
|
Loading…
Add table
Reference in a new issue