From 28ae4dfdeeff6c9b291c489877fe597c08fafb30 Mon Sep 17 00:00:00 2001 From: Dominic Date: Thu, 25 Feb 2021 00:37:55 +0100 Subject: [PATCH] add swagger_ui to the router --- CHANGELOG.md | 1 + Cargo.toml | 26 +++-- example/src/main.rs | 1 + src/openapi/handler.rs | 240 ++++++++++++++++++++++++++++++++++------- src/openapi/router.rs | 15 ++- 5 files changed, 230 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3349897..26ed987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]) - Support for custom endpoints using the `#[endpoint]` macro ([!19]) - Support for `anyhow::Error` (or any type implementing `Into`) in most responses + - `swagger_ui` method to the OpenAPI router to render the specification using Swagger UI ### Changed - The cors handler can now copy headers from the request if desired diff --git a/Cargo.toml b/Cargo.toml index dff6df4..bf96d7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,27 +20,33 @@ include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"] gitlab = { repository = "msrd0/gotham-restful", branch = "master" } [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-util = "0.3.7" gotham = { version = "0.5.0", default-features = false } gotham_derive = "0.5.0" -gotham_middleware_diesel = { version = "0.2.0", optional = true } 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" 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_json = "1.0.58" thiserror = "1.0" + +# features +chrono = { version = "0.4.19", features = ["serde"], 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] diesel = { version = "1.4.4", features = ["postgres"] } futures-executor = "0.3.5" @@ -61,7 +67,7 @@ errorlog = [] # These features are exclusive - https://gitlab.com/msrd0/gotham-restful/-/issues/4 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] no-default-features = true diff --git a/example/src/main.rs b/example/src/main.rs index d95795c..e85f911 100644 --- a/example/src/main.rs +++ b/example/src/main.rs @@ -122,6 +122,7 @@ fn main() { route.resource::("users"); route.resource::("auth"); route.get_openapi("openapi"); + route.swagger_ui(""); }); }) ); diff --git a/src/openapi/handler.rs b/src/openapi/handler.rs index f340a6d..9762ca6 100644 --- a/src/openapi/handler.rs +++ b/src/openapi/handler.rs @@ -4,37 +4,26 @@ use futures_util::{future, future::FutureExt}; use gotham::{ anyhow, handler::{Handler, HandlerFuture, NewHandler}, - helpers::http::response::create_response, - hyper::StatusCode, + helpers::http::response::{create_empty_response, create_response}, + 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 }; 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 sha2::{Digest, Sha256}; use std::{ pin::Pin, sync::{Arc, RwLock} }; -#[derive(Clone)] -pub struct OpenapiHandler { - openapi: Arc> -} - -impl OpenapiHandler { - pub fn new(openapi: Arc>) -> Self { - Self { openapi } - } -} - -impl NewHandler for OpenapiHandler { - type Instance = Self; - - fn new_handler(&self) -> anyhow::Result { - Ok(self.clone()) - } -} - #[cfg(feature = "auth")] fn get_security(state: &mut State) -> IndexMap> { use crate::AuthSource; @@ -71,33 +60,202 @@ fn get_security(_state: &mut State) -> IndexMap>) -> Response { + 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> +} + +impl OpenapiHandler { + pub fn new(openapi: Arc>) -> Self { + Self { openapi } + } +} + +impl NewHandler for OpenapiHandler { + type Instance = Self; + + fn new_handler(&self) -> anyhow::Result { + Ok(self.clone()) + } +} + impl Handler for OpenapiHandler { fn handle(self, mut state: State) -> Pin> { - 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, StatusCode::INTERNAL_SERVER_ERROR, TEXT_PLAIN, ""); - return future::ok((state, res)).boxed(); - } - }; + let res = create_openapi_response(&mut state, &self.openapi); + 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); +#[derive(Clone)] +pub struct SwaggerUiHandler { + openapi: Arc> +} - match serde_json::to_string(&openapi) { - Ok(body) => { - let res = create_response(&state, StatusCode::OK, APPLICATION_JSON, body); +impl SwaggerUiHandler { + pub fn new(openapi: Arc>) -> Self { + Self { openapi } + } +} + +impl NewHandler for SwaggerUiHandler { + type Instance = Self; + + fn new_handler(&self) -> anyhow::Result { + Ok(self.clone()) + } +} + +impl Handler for SwaggerUiHandler { + fn handle(self, mut state: State) -> Pin> { + 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() }, - 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() } } } } + +// 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#" + + + + + + + + +
+ + + + "# + }; + Box::leak(Box::new(template.replace("{{script}}", SWAGGER_UI_SCRIPT))) +}); +static SWAGGER_UI_HTML_ETAG: Lazy = 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 = Lazy::new(|| { + let mut hash = Sha256::new(); + hash.update(SWAGGER_UI_SCRIPT); + let hash = hash.finalize(); + base64::encode(hash) +}); diff --git a/src/openapi/router.rs b/src/openapi/router.rs index 2fd8caa..7097cfd 100644 --- a/src/openapi/router.rs +++ b/src/openapi/router.rs @@ -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 gotham::{hyper::Method, pipeline::chain::PipelineHandleChain, router::builder::*}; use once_cell::sync::Lazy; use regex::{Captures, Regex}; 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 { fn get_openapi(&mut self, path: &str); + fn swagger_ui(&mut self, path: &str); } #[derive(Debug)] @@ -51,6 +56,12 @@ macro_rules! implOpenapiRouter { .get(path) .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>>