From 6baa9b037d33989db9b89690bbe2d4084abafd9e Mon Sep 17 00:00:00 2001 From: Juha Kukkonen Date: Sun, 6 Aug 2023 17:45:20 +0300 Subject: [PATCH] Add redoc support for utoipa. (#720) Create new utoipa-redoc create and add simple redoc implementation. Add redoc service support for actix-web, axum and rocket. Add new warp-todo-redoc example to demonstarte utoipa-redoc in standalone without pre-existing web framework integration. Add CI setup for utoipa-redoc. --- .github/workflows/build.yaml | 3 + .github/workflows/draft.yaml | 1 + Cargo.toml | 8 +- README.md | 2 +- examples/README.md | 5 +- examples/rocket-todo/Cargo.toml | 7 +- examples/rocket-todo/README.md | 4 +- examples/rocket-todo/src/main.rs | 2 + examples/todo-actix/Cargo.toml | 3 +- examples/todo-actix/README.md | 4 +- examples/todo-actix/src/main.rs | 2 + examples/todo-axum/Cargo.toml | 3 +- examples/todo-axum/README.md | 4 +- examples/todo-axum/src/main.rs | 2 + .../Cargo.toml | 22 + .../README.md | 27 + .../todo-warp-redoc-with-file-config/build.rs | 3 + .../redoc.json | 3 + .../src/main.rs | 233 +++++++++ scripts/test.sh | 2 + utoipa-redoc/Cargo.toml | 25 + utoipa-redoc/LICENSE-APACHE | 1 + utoipa-redoc/LICENSE-MIT | 1 + utoipa-redoc/README.md | 137 +++++ utoipa-redoc/res/redoc.html | 31 ++ utoipa-redoc/src/actix.rs | 26 + utoipa-redoc/src/axum.rs | 19 + utoipa-redoc/src/lib.rs | 494 ++++++++++++++++++ utoipa-redoc/src/rocket.rs | 28 + 29 files changed, 1085 insertions(+), 17 deletions(-) create mode 100644 examples/todo-warp-redoc-with-file-config/Cargo.toml create mode 100644 examples/todo-warp-redoc-with-file-config/README.md create mode 100644 examples/todo-warp-redoc-with-file-config/build.rs create mode 100644 examples/todo-warp-redoc-with-file-config/redoc.json create mode 100644 examples/todo-warp-redoc-with-file-config/src/main.rs create mode 100644 utoipa-redoc/Cargo.toml create mode 120000 utoipa-redoc/LICENSE-APACHE create mode 120000 utoipa-redoc/LICENSE-MIT create mode 100644 utoipa-redoc/README.md create mode 100644 utoipa-redoc/res/redoc.html create mode 100644 utoipa-redoc/src/actix.rs create mode 100644 utoipa-redoc/src/axum.rs create mode 100644 utoipa-redoc/src/lib.rs create mode 100644 utoipa-redoc/src/rocket.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6858ac33..529d0e0c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -21,6 +21,7 @@ jobs: - utoipa - utoipa-gen - utoipa-swagger-ui + - utoipa-redoc fail-fast: true runs-on: ubuntu-latest @@ -50,6 +51,8 @@ jobs: changes=true elif [[ "$change" == "utoipa" && "${{ matrix.crate }}" == "utoipa" && $changes == false ]]; then changes=true + elif [[ "$change" == "utoipa-redoc" && "${{ matrix.crate }}" == "utoipa-redoc" && $changes == false ]]; then + changes=true fi done < <(git diff --name-only ${{ github.sha }}~ ${{ github.sha }} | grep .rs | awk -F \/ '{print $1}') echo "${{ matrix.crate }} changes: $changes" diff --git a/.github/workflows/draft.yaml b/.github/workflows/draft.yaml index 522dd7d7..5fa01537 100644 --- a/.github/workflows/draft.yaml +++ b/.github/workflows/draft.yaml @@ -16,6 +16,7 @@ jobs: - utoipa - utoipa-gen - utoipa-swagger-ui + - utoipa-redoc runs-on: ubuntu-latest steps: diff --git a/Cargo.toml b/Cargo.toml index 4df51f85..c3809615 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,6 @@ [workspace] resolver = "2" -members = [ - "utoipa", - "utoipa-gen", - "utoipa-swagger-ui", -] +members = ["utoipa", "utoipa-gen", "utoipa-swagger-ui", "utoipa-redoc"] [workspace.metadata.publish] -order = ["utoipa-gen", "utoipa", "utoipa-swagger-ui"] +order = ["utoipa-gen", "utoipa", "utoipa-swagger-ui", "utoipa-redoc"] diff --git a/README.md b/README.md index 39c7b5b6..5229a1a3 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Refer to the existing [examples](./examples) for building the "todo" app in the - **[tide](https://github.com/http-rs/tide)** - **[rocket](https://github.com/SergioBenitez/Rocket)** (`0.4` and `0.5.0-rc3`) -All examples include a [Swagger-UI](https://github.com/swagger-api/swagger-ui). +All examples include a [Swagger-UI](https://github.com/swagger-api/swagger-ui) or [Redoc](https://github.com/Redocly/redoc). There are also examples of building multiple OpenAPI docs in one application, each separated in Swagger UI. These examples exist only for the **actix** and **warp** frameworks. diff --git a/examples/README.md b/examples/README.md index dde1c9af..fa6651e8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,4 +6,7 @@ with the library. All examples have their own README.md, and can be seen using two steps: 1. Run `cargo run` -2. Browse to `http://localhost:8080/swagger-ui/`. +2. Browse to `http://localhost:8080/swagger-ui/` or `http://localhost:8080/redoc`. + +`Todo-actix`, `todo-axum` and `rocket-todo` has both Swagger UI and Redoc setup others have Swagger UI +if not explicitly stated otherwise. diff --git a/examples/rocket-todo/Cargo.toml b/examples/rocket-todo/Cargo.toml index 7823881f..b09f845c 100644 --- a/examples/rocket-todo/Cargo.toml +++ b/examples/rocket-todo/Cargo.toml @@ -1,12 +1,10 @@ [package] name = "rocket-todo" -description = "Simple rocket todo example api with utoipa and Swagger UI" +description = "Simple rocket todo example api with utoipa and Swagger UI and Redoc" version = "0.1.0" edition = "2021" license = "MIT" -authors = [ - "Elli Example " -] +authors = ["Elli Example "] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -14,6 +12,7 @@ authors = [ rocket = { version = "0.5.0-rc.3", features = ["json"] } utoipa = { path = "../../utoipa", features = ["rocket_extras"] } utoipa-swagger-ui = { path = "../../utoipa-swagger-ui", features = ["rocket"] } +utoipa-redoc = { path = "../../utoipa-redoc", features = ["rocket"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" env_logger = "0.10.0" diff --git a/examples/rocket-todo/README.md b/examples/rocket-todo/README.md index 44057743..0eee5620 100644 --- a/examples/rocket-todo/README.md +++ b/examples/rocket-todo/README.md @@ -1,4 +1,4 @@ -# todo-rocket ~ utoipa with utoipa-swagger-ui example +# todo-rocket ~ utoipa with utoipa-swagger-ui and utoipa-redoc example This is a demo `rocket` application with in-memory storage to manage Todo items. The API demonstrates `utoipa` with `utoipa-swagger-ui` functionalities. @@ -7,6 +7,8 @@ For security restricted endpoints the super secret API key is: `utoipa-rocks`. Just run command below to run the demo application and browse to `http://localhost:8000/swagger-ui/`. +If you prefer Redoc just head to `http://localhost:8000/redoc` and view the Open API. + ```bash cargo run ``` diff --git a/examples/rocket-todo/src/main.rs b/examples/rocket-todo/src/main.rs index 186038e1..73985a33 100644 --- a/examples/rocket-todo/src/main.rs +++ b/examples/rocket-todo/src/main.rs @@ -5,6 +5,7 @@ use utoipa::{ openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, Modify, OpenApi, }; +use utoipa_redoc::{Redoc, Servable}; use utoipa_swagger_ui::SwaggerUi; use crate::todo::TodoStore; @@ -51,6 +52,7 @@ fn rocket() -> Rocket { "/", SwaggerUi::new("/swagger-ui/<_..>").url("/api-docs/openapi.json", ApiDoc::openapi()), ) + .mount("/", Redoc::with_url("/redoc", ApiDoc::openapi())) .mount( "/todo", routes![ diff --git a/examples/todo-actix/Cargo.toml b/examples/todo-actix/Cargo.toml index 7a146e9d..9571b13a 100644 --- a/examples/todo-actix/Cargo.toml +++ b/examples/todo-actix/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "todo-actix" -description = "Simple actix-web todo example api with utoipa and Swagger" +description = "Simple actix-web todo example api with utoipa and Swagger UI and Redoc" version = "0.1.0" edition = "2021" license = "MIT" @@ -19,5 +19,6 @@ log = "0.4" futures = "0.3" utoipa = { path = "../../utoipa", features = ["actix_extras"] } utoipa-swagger-ui = { path = "../../utoipa-swagger-ui", features = ["actix-web"] } +utoipa-redoc = { path = "../../utoipa-redoc", features = ["actix-web"] } [workspace] diff --git a/examples/todo-actix/README.md b/examples/todo-actix/README.md index 2e3b8f98..6906b6f7 100644 --- a/examples/todo-actix/README.md +++ b/examples/todo-actix/README.md @@ -1,4 +1,4 @@ -# todo-actix ~ utoipa with utoipa-swagger-ui example +# todo-actix ~ utoipa with utoipa-swagger-ui and utoipa-redoc example This is a demo `actix-web` application with in-memory storage to manage Todo items. The API demonstrates `utoipa` with `utoipa-swagger-ui` functionalities. @@ -7,6 +7,8 @@ For security restricted endpoints the super secret API key is: `utoipa-rocks`. Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. +If you prefer Redoc just head to `http://localhost:8000/redoc` and view the Open API. + ```bash cargo run ``` diff --git a/examples/todo-actix/src/main.rs b/examples/todo-actix/src/main.rs index 52cde8cd..8f4c0493 100644 --- a/examples/todo-actix/src/main.rs +++ b/examples/todo-actix/src/main.rs @@ -15,6 +15,7 @@ use utoipa::{ openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, Modify, OpenApi, }; +use utoipa_redoc::{Redoc, Servable}; use utoipa_swagger_ui::SwaggerUi; use crate::todo::{ErrorResponse, TodoStore}; @@ -69,6 +70,7 @@ async fn main() -> Result<(), impl Error> { App::new() .wrap(Logger::default()) .configure(todo::configure(store.clone())) + .service(Redoc::with_url("/redoc", openapi.clone())) .service( SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", openapi.clone()), ) diff --git a/examples/todo-axum/Cargo.toml b/examples/todo-axum/Cargo.toml index 651741f7..56b57ca1 100644 --- a/examples/todo-axum/Cargo.toml +++ b/examples/todo-axum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "todo-axum" -description = "Simple axum todo example api with utoipa and Swagger UI" +description = "Simple axum todo example api with utoipa and Swagger UI and Redoc" version = "0.1.0" edition = "2021" license = "MIT" @@ -17,6 +17,7 @@ tokio = { version = "1.17", features = ["full"] } tower = "0.4" utoipa = { path = "../../utoipa", features = ["axum_extras"] } utoipa-swagger-ui = { path = "../../utoipa-swagger-ui", features = ["axum"] } +utoipa-redoc = { path = "../../utoipa-redoc", features = ["axum"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" env_logger = "0.10.0" diff --git a/examples/todo-axum/README.md b/examples/todo-axum/README.md index 43443b31..5a724993 100644 --- a/examples/todo-axum/README.md +++ b/examples/todo-axum/README.md @@ -1,4 +1,4 @@ -# todo-axum ~ utoipa with utoipa-swagger-ui example +# todo-axum ~ utoipa with utoipa-swagger-ui and utoipa-redoc example This is a demo `axum` application with in-memory storage to manage Todo items. The API demonstrates `utoipa` with `utoipa-swagger-ui` functionalities. @@ -7,6 +7,8 @@ For security restricted endpoints the super secret API key is: `utoipa-rocks`. Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. +If you prefer Redoc just head to `http://localhost:8000/redoc` and view the Open API. + ```bash cargo run ``` diff --git a/examples/todo-axum/src/main.rs b/examples/todo-axum/src/main.rs index 0120d852..b8e5db1c 100644 --- a/examples/todo-axum/src/main.rs +++ b/examples/todo-axum/src/main.rs @@ -9,6 +9,7 @@ use utoipa::{ openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, Modify, OpenApi, }; +use utoipa_redoc::{Redoc, Servable}; use utoipa_swagger_ui::SwaggerUi; use crate::todo::Store; @@ -50,6 +51,7 @@ async fn main() -> Result<(), Error> { let store = Arc::new(Store::default()); let app = Router::new() .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) + .merge(Redoc::with_url("/redoc", ApiDoc::openapi())) .route( "/todo", routing::get(todo::list_todos).post(todo::create_todo), diff --git a/examples/todo-warp-redoc-with-file-config/Cargo.toml b/examples/todo-warp-redoc-with-file-config/Cargo.toml new file mode 100644 index 00000000..89b6367c --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "todo-warp-redoc-with-file-config" +description = "Simple warp todo example api with utoipa and utoipa-redoc" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Elli Example "] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tokio = { version = "1", features = ["full"] } +warp = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.10.0" +log = "0.4" +futures = "0.3" +utoipa = { path = "../../utoipa" } +utoipa-redoc = { path = "../../utoipa-redoc" } + +[workspace] diff --git a/examples/todo-warp-redoc-with-file-config/README.md b/examples/todo-warp-redoc-with-file-config/README.md new file mode 100644 index 00000000..6e90d691 --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/README.md @@ -0,0 +1,27 @@ +# todo-warp-redoc-with-file-config ~ utoipa with utoipa-redoc example + +This is a demo `warp` application with in-memory storage to manage Todo items. + +This example is more bare minimum compared to `todo-actix`, since similarly same macro syntax is +supported, no matter the framework. + + +This how `utoipa-redoc` can be used as standalone without pre-existing framework integration with additional +file configuration for the Redoc UI. The configuration is applicable in any other `utoipa-redoc` setup as well. + +See the `build.rs` file that defines the Redoc config file and `redoc.json` where the [configuration options](https://redocly.com/docs/api-reference-docs/configuration/functionality/#configuration-options-for-api-docs) +are defined. + +For security restricted endpoints the super secret API key is: `utoipa-rocks`. + +Just run command below to run the demo application and browse to `http://localhost:8080/redoc`. + +```bash +cargo run +``` + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/todo-warp-redoc-with-file-config/build.rs b/examples/todo-warp-redoc-with-file-config/build.rs new file mode 100644 index 00000000..a5b1c81b --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rustc-env=UTOIPA_REDOC_CONFIG_FILE=redoc.json") +} diff --git a/examples/todo-warp-redoc-with-file-config/redoc.json b/examples/todo-warp-redoc-with-file-config/redoc.json new file mode 100644 index 00000000..318f0e19 --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/redoc.json @@ -0,0 +1,3 @@ +{ + "disableSearch": true +} diff --git a/examples/todo-warp-redoc-with-file-config/src/main.rs b/examples/todo-warp-redoc-with-file-config/src/main.rs new file mode 100644 index 00000000..6815e572 --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/src/main.rs @@ -0,0 +1,233 @@ +use std::net::Ipv4Addr; + +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use utoipa_redoc::{FileConfig, Redoc}; +use warp::Filter; + +#[tokio::main] +async fn main() { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi( + paths(todo::list_todos, todo::create_todo, todo::delete_todo), + components( + schemas(todo::Todo) + ), + modifiers(&SecurityAddon), + tags( + (name = "todo", description = "Todo items management API") + ) + )] + 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 redoc_ui = Redoc::with_config(ApiDoc::openapi(), FileConfig); + let redoc = warp::path("redoc") + .and(warp::get()) + .map(move || warp::reply::html(redoc_ui.to_html())); + + warp::serve(redoc.or(todo::handlers())) + .run((Ipv4Addr::UNSPECIFIED, 8080)) + .await +} + +mod todo { + use std::{ + convert::Infallible, + sync::{Arc, Mutex}, + }; + + use serde::{Deserialize, Serialize}; + use utoipa::{IntoParams, ToSchema}; + use warp::{hyper::StatusCode, Filter, Rejection, Reply}; + + pub type Store = Arc>>; + + /// Item to complete. + #[derive(Serialize, Deserialize, ToSchema, Clone)] + pub struct Todo { + /// Unique database id. + #[schema(example = 1)] + id: i64, + /// Description of what need to be done. + #[schema(example = "Buy movie tickets")] + value: String, + } + + #[derive(Debug, Deserialize, ToSchema)] + #[serde(rename_all = "snake_case")] + pub enum Order { + AscendingId, + DescendingId, + } + + #[derive(Debug, Deserialize, IntoParams)] + #[into_params(parameter_in = Query)] + pub struct ListQueryParams { + /// Filters the returned `Todo` items according to whether they contain the specified string. + #[param(style = Form, example = json!("task"))] + contains: Option, + /// Order the returned `Todo` items. + #[param(inline)] + order: Option, + } + + pub fn handlers() -> impl Filter + Clone { + let store = Store::default(); + + let list = warp::path("todo") + .and(warp::get()) + .and(warp::path::end()) + .and(with_store(store.clone())) + .and(warp::query::()) + .and_then(list_todos); + + let create = warp::path("todo") + .and(warp::post()) + .and(warp::path::end()) + .and(warp::body::json()) + .and(with_store(store.clone())) + .and_then(create_todo); + + let delete = warp::path!("todo" / i64) + .and(warp::delete()) + .and(warp::path::end()) + .and(with_store(store)) + .and(warp::header::header("todo_apikey")) + .and_then(delete_todo); + + list.or(create).or(delete) + } + + fn with_store(store: Store) -> impl Filter + Clone { + warp::any().map(move || store.clone()) + } + + /// List todos from in-memory storage. + /// + /// List all todos from in-memory storage. + #[utoipa::path( + get, + path = "/todo", + params(ListQueryParams), + responses( + (status = 200, description = "List todos successfully", body = [Todo]) + ) + )] + pub async fn list_todos( + store: Store, + query: ListQueryParams, + ) -> Result { + let todos = store.lock().unwrap(); + + let mut todos: Vec = if let Some(contains) = query.contains { + todos + .iter() + .filter(|todo| todo.value.contains(&contains)) + .cloned() + .collect() + } else { + todos.clone() + }; + + if let Some(order) = query.order { + match order { + Order::AscendingId => { + todos.sort_by_key(|todo| todo.id); + } + Order::DescendingId => { + todos.sort_by_key(|todo| todo.id); + todos.reverse(); + } + } + } + + Ok(warp::reply::json(&todos)) + } + + /// Create new todo item. + /// + /// Creates new todo item to in-memory storage if it is unique by id. + #[utoipa::path( + post, + path = "/todo", + request_body = Todo, + responses( + (status = 200, description = "Todo created successfully", body = Todo), + (status = 409, description = "Todo already exists") + ) + )] + pub async fn create_todo(todo: Todo, store: Store) -> Result, Infallible> { + let mut todos = store.lock().unwrap(); + + if todos + .iter() + .any(|existing_todo| existing_todo.id == todo.id) + { + Ok(Box::new(StatusCode::CONFLICT)) + } else { + todos.push(todo.clone()); + + Ok(Box::new(warp::reply::with_status( + warp::reply::json(&todo), + StatusCode::CREATED, + ))) + } + } + + /// Delete todo item by id. + /// + /// Delete todo item by id from in-memory storage. + #[utoipa::path( + delete, + path = "/todo/{id}", + responses( + (status = 200, description = "Delete successful"), + (status = 400, description = "Missing todo_apikey request header"), + (status = 401, description = "Unauthorized to delete todo"), + (status = 404, description = "Todo not found to delete"), + ), + params( + ("id" = i64, Path, description = "Todo's unique id") + ), + security( + ("api_key" = []) + ) + )] + pub async fn delete_todo( + id: i64, + store: Store, + api_key: String, + ) -> Result { + if api_key != "utoipa-rocks" { + return Ok(StatusCode::UNAUTHORIZED); + } + + let mut todos = store.lock().unwrap(); + + let size = todos.len(); + + todos.retain(|existing| existing.id != id); + + if size == todos.len() { + Ok(StatusCode::NOT_FOUND) + } else { + Ok(StatusCode::OK) + } + } +} diff --git a/scripts/test.sh b/scripts/test.sh index cef043fb..db1f4d0f 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -21,4 +21,6 @@ elif [[ "$crate" == "utoipa-gen" ]]; then cargo test -p utoipa-gen --test path_derive_auto_into_responses_axum --features axum_extras,utoipa/auto_into_responses elif [[ "$crate" == "utoipa-swagger-ui" ]]; then cargo test -p utoipa-swagger-ui --features actix-web,rocket,axum +elif [[ "$crate" == "utoipa-redoc" ]]; then + cargo test -p utoipa-redoc --features actix-web,rocket,axum fi diff --git a/utoipa-redoc/Cargo.toml b/utoipa-redoc/Cargo.toml new file mode 100644 index 00000000..4c79c1e3 --- /dev/null +++ b/utoipa-redoc/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "utoipa-redoc" +description = "Redoc for utoipa" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["redoc", "openapi", "documentation"] +repository = "https://github.com/juhaku/utoipa" +categories = ["web-programming"] +authors = ["Juha Kukkonen "] + +[package.metadata.docs.rs] +features = ["actix-web", "axum", "rocket"] +rustdoc-args = ["--cfg", "doc_cfg"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +utoipa = { version = "3", path = "../utoipa" } +actix-web = { version = "4", features = [ + "macros", +], optional = true, default-features = false } +rocket = { version = "0.5.0-rc.3", features = ["json"], optional = true } +axum = { version = "0.6", optional = true } diff --git a/utoipa-redoc/LICENSE-APACHE b/utoipa-redoc/LICENSE-APACHE new file mode 120000 index 00000000..965b606f --- /dev/null +++ b/utoipa-redoc/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/utoipa-redoc/LICENSE-MIT b/utoipa-redoc/LICENSE-MIT new file mode 120000 index 00000000..76219eb7 --- /dev/null +++ b/utoipa-redoc/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/utoipa-redoc/README.md b/utoipa-redoc/README.md new file mode 100644 index 00000000..0ff31ae4 --- /dev/null +++ b/utoipa-redoc/README.md @@ -0,0 +1,137 @@ +# utoipa-redoc + +[![Utoipa build](https://github.com/juhaku/utoipa/actions/workflows/build.yaml/badge.svg)](https://github.com/juhaku/utoipa/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/utoipa-redoc.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/utoipa-redoc) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=utoipa-redoc&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/utoipa-redoc/latest/utoipa_swagger_ui/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.60%2B&color=orange&logo=rust) + +This crate works as a bridge between [utoipa](https://docs.rs/utoipa/latest/utoipa/) and [Redoc](https://redocly.com/) OpenAPI visualizer. + +Utoipa-redoc provides simple mechanism to transform OpenAPI spec resource to a servable HTML +file which can be served via [predefined framework integration][Self#examples] or used +[standalone][Self#using-standalone] and served manually. + +You may find fullsize examples from utoipa's Github [repository][examples]. + +# Crate Features + +* **actix-web** Allows serving `Redoc` via _**`actix-web`**_. +* **rocket** Allows serving `Redoc` via _**`rocket`**_. +* **axum** Allows serving `Redoc` via _**`axum`**_. + +# Install + +Use Redoc only without any boiler plate implementation. +```toml +[dependencies] +utoipa-redoc = "0.1" +``` + +Enable actix-web integration with Redoc. +```toml +[dependencies] +utoipa-redoc = { version = "0.1", features = ["actix-web"] } +``` + +# Using standalone + +Utoipa-redoc can be used standalone as simply as creating a new `Redoc` instance and then +serving it by what ever means available as `text/html` from http handler in your favourite web +framework. + +`Redoc::to_html` method can be used to convert the `Redoc` instance to a servable html +file. +``` +let redoc = Redoc::new(ApiDoc::openapi()); + +// Then somewhere in your application that handles http operation. +// Make sure you return correct content type `text/html`. +let redoc_handler = move || async { + redoc.to_html() +}; +``` + +# Customization + +Utoipa-redoc enables full customizaton support for [Redoc][redoc] according to what can be +customized by modifying the HTML template and [configuration options][Self#configuration]. + +The default [HTML template][redoc_html_quickstart] can be fully overridden to ones liking with +`Redoc::custom_html` method. The HTML template **must** contain **`$spec`** and **`$config`** +variables which are replaced during `Redoc::to_html` evaluation. + +* **`$spec`** Will be the `Spec` that will be rendered via [Redoc][redoc]. +* **`$config`** Will be the current `Config`. By default this is `EmptyConfig`. + +_**Overiding the HTML template with a custom one.**_ +```rust +let html = "..."; +Redoc::new(ApiDoc::openapi()).custom_html(html); +``` + +# Configuration + +Redoc can be configured with JSON either inlined with the `Redoc` declaration or loaded from +user defined file with `FileConfig`. + +* [All supported Redoc configuration options][redoc_config]. + +_**Inlining the configuration.**_ +```rust +Redoc::with_config(ApiDoc::openapi(), || json!({ "disableSearch": true })); +``` + +_**Using `FileConfig`.**_ +```rust +Redoc::with_config(ApiDoc::openapi(), FileConfig); +``` + +Read more details in `Config`. + +# Examples + +_**Serve `Redoc` via `actix-web` framework.**_ +```rust +use actix_web::App; +use utoipa_redoc::{Redoc, Servable}; + +App::new().service(Redoc::with_url("/redoc", ApiDoc::openapi())); +``` + +_**Serve `Redoc` via `rocket` framework.**_ +```rust +use utoipa_redoc::{Redoc, Servable}; + +rocket::build() + .mount( + "/", + Redoc::with_url("/redoc", ApiDoc::openapi()), + ); +``` + +_**Serve `Redoc` via `axum` framework.**_ + ```rust + use axum::{Router, body::HttpBody}; + use utoipa_redoc::{Redoc, Servable}; + + let app = Router::::new() + .merge(Redoc::with_url("/redoc", ApiDoc::openapi())); +``` + +_**Use `Redoc` to serve OpenAPI spec from url.**_ +```rust +# use utoipa_redoc::Redoc; +Redoc::new( + "https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml") +``` + +_**Use `Redoc` to serve custom OpenAPI spec using serde's `json!()` macro.**_ +```rust +# use utoipa_redoc::Redoc; +Redoc::new(json!({"openapi": 3.1.0})); +``` + +[redoc]: +[redoc_html_quickstart]: +[redoc_config]: +[examples]: diff --git a/utoipa-redoc/res/redoc.html b/utoipa-redoc/res/redoc.html new file mode 100644 index 00000000..6f511269 --- /dev/null +++ b/utoipa-redoc/res/redoc.html @@ -0,0 +1,31 @@ + + + + Redoc + + + + + + + + +
+ + + + diff --git a/utoipa-redoc/src/actix.rs b/utoipa-redoc/src/actix.rs new file mode 100644 index 00000000..0d0be082 --- /dev/null +++ b/utoipa-redoc/src/actix.rs @@ -0,0 +1,26 @@ +#![cfg(feature = "actix-web")] + +use actix_web::dev::HttpServiceFactory; +use actix_web::guard::Get; +use actix_web::web::Data; +use actix_web::{HttpResponse, Resource, Responder}; + +use crate::{Redoc, Spec}; + +impl<'s, 'u, S: Spec> HttpServiceFactory for Redoc<'s, 'u, S> { + fn register(self, config: &mut actix_web::dev::AppService) { + let html = self.to_html(); + + async fn serve_redoc(redoc: Data) -> impl Responder { + HttpResponse::Ok() + .content_type("text/html") + .body(redoc.to_string()) + } + + Resource::new(self.url) + .guard(Get()) + .app_data(Data::new(html)) + .to(serve_redoc) + .register(config); + } +} diff --git a/utoipa-redoc/src/axum.rs b/utoipa-redoc/src/axum.rs new file mode 100644 index 00000000..474f45d9 --- /dev/null +++ b/utoipa-redoc/src/axum.rs @@ -0,0 +1,19 @@ +#![cfg(feature = "axum")] + +use axum::body::HttpBody; +use axum::response::Html; +use axum::{routing, Router}; + +use crate::{Redoc, Spec}; + +impl<'s, 'u, S: Spec, R, B> From> for Router +where + R: Clone + Send + Sync + 'static, + B: HttpBody + Send + 'static, + 's: 'static, +{ + fn from(value: Redoc<'s, 'u, S>) -> Self { + let html = value.to_html(); + Router::::new().route(value.url, routing::get(move || async { Html(html) })) + } +} diff --git a/utoipa-redoc/src/lib.rs b/utoipa-redoc/src/lib.rs new file mode 100644 index 00000000..eba575f9 --- /dev/null +++ b/utoipa-redoc/src/lib.rs @@ -0,0 +1,494 @@ +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![cfg_attr(doc_cfg, feature(doc_cfg))] +//! This crate works as a bridge between [utoipa](https://docs.rs/utoipa/latest/utoipa/) and [Redoc](https://redocly.com/) OpenAPI visualizer. +//! +//! Utoipa-redoc provides simple mechanism to transform OpenAPI spec resource to a servable HTML +//! file which can be served via [predefined framework integration][Self#examples] or used +//! [standalone][Self#using-standalone] and served manually. +//! +//! You may find fullsize examples from utoipa's Github [repository][examples]. +//! +//! # Crate Features +//! +//! * **actix-web** Allows serving [`Redoc`] via _**`actix-web`**_. +//! * **rocket** Allows serving [`Redoc`] via _**`rocket`**_. +//! * **axum** Allows serving [`Redoc`] via _**`axum`**_. +//! +//! # Install +//! +//! Use Redoc only without any boiler plate implementation. +//! ```toml +//! [dependencies] +//! utoipa-redoc = "0.1" +//! ``` +//! +//! Enable actix-web integration with Redoc. +//! ```toml +//! [dependencies] +//! utoipa-redoc = { version = "0.1", features = ["actix-web"] } +//! ``` +//! +//! # Using standalone +//! +//! Utoipa-redoc can be used standalone as simply as creating a new [`Redoc`] instance and then +//! serving it by what ever means available as `text/html` from http handler in your favourite web +//! framework. +//! +//! [`Redoc::to_html`] method can be used to convert the [`Redoc`] instance to a servable html +//! file. +//! ``` +//! # use utoipa_redoc::Redoc; +//! # use utoipa::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! let redoc = Redoc::new(ApiDoc::openapi()); +//! +//! // Then somewhere in your application that handles http operation. +//! // Make sure you return correct content type `text/html`. +//! let redoc_handler = move || { +//! redoc.to_html() +//! }; +//! ``` +//! +//! # Customization +//! +//! Utoipa-redoc enables full customizaton support for [Redoc][redoc] according to what can be +//! customized by modifying the HTML template and [configuration options][Self#configuration]. +//! +//! The default [HTML template][redoc_html_quickstart] can be fully overridden to ones liking with +//! [`Redoc::custom_html`] method. The HTML template **must** contain **`$spec`** and **`$config`** +//! variables which are replaced during [`Redoc::to_html`] evaluation. +//! +//! * **`$spec`** Will be the [`Spec`] that will be rendered via [Redoc][redoc]. +//! * **`$config`** Will be the current [`Config`]. By default this is [`EmptyConfig`]. +//! +//! _**Overiding the HTML template with a custom one.**_ +//! ```rust +//! # use utoipa_redoc::Redoc; +//! # use utoipa::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! let html = "..."; +//! Redoc::new(ApiDoc::openapi()).custom_html(html); +//! ``` +//! +//! # Configuration +//! +//! Redoc can be configured with JSON either inlined with the [`Redoc`] declaration or loaded from +//! user defined file with [`FileConfig`]. +//! +//! * [All supported Redoc configuration options][redoc_config]. +//! +//! _**Inlining the configuration.**_ +//! ```rust +//! # use utoipa_redoc::Redoc; +//! # use utoipa::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! Redoc::with_config(ApiDoc::openapi(), || json!({ "disableSearch": true })); +//! ``` +//! +//! _**Using [`FileConfig`].**_ +//! ```no_run +//! # use utoipa_redoc::{Redoc, FileConfig}; +//! # use utoipa::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! Redoc::with_config(ApiDoc::openapi(), FileConfig); +//! ``` +//! +//! Read more details in [`Config`]. +//! +//! # Examples +//! +//! _**Serve [`Redoc`] via `actix-web` framework.**_ +//! ```no_run +//! use actix_web::App; +//! use utoipa_redoc::{Redoc, Servable}; +//! +//! # use utoipa::OpenApi; +//! # use std::net::Ipv4Addr; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! App::new().service(Redoc::with_url("/redoc", ApiDoc::openapi())); +//! ``` +//! +//! _**Serve [`Redoc`] via `rocket` framework.**_ +//! ```no_run +//! # use rocket; +//! use utoipa_redoc::{Redoc, Servable}; +//! +//! # use utoipa::OpenApi; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! rocket::build() +//! .mount( +//! "/", +//! Redoc::with_url("/redoc", ApiDoc::openapi()), +//! ); +//! ``` +//! +//! _**Serve [`Redoc`] via `axum` framework.**_ +//! ```no_run +//! use axum::{Router, body::HttpBody}; +//! use utoipa_redoc::{Redoc, Servable}; +//! # use utoipa::OpenApi; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! # fn inner() +//! # where +//! # B: HttpBody + Send + 'static, +//! # S: Clone + Send + Sync + 'static, +//! # { +//! +//! let app = Router::::new() +//! .merge(Redoc::with_url("/redoc", ApiDoc::openapi())); +//! # } +//! ``` +//! +//! _**Use [`Redoc`] to serve OpenAPI spec from url.**_ +//! ``` +//! # use utoipa_redoc::Redoc; +//! Redoc::new( +//! "https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml"); +//! ``` +//! +//! _**Use [`Redoc`] to serve custom OpenAPI spec using serde's `json!()` macro.**_ +//! ```rust +//! # use utoipa_redoc::Redoc; +//! # use serde_json::json; +//! Redoc::new(json!({"openapi": "3.1.0"})); +//! ``` +//! +//! [redoc]: +//! [redoc_html_quickstart]: +//! [redoc_config]: +//! [examples]: + +use std::env; +use std::fs::OpenOptions; + +use serde::Serialize; +use serde_json::{json, Value}; +use utoipa::openapi::OpenApi; + +mod actix; +mod axum; +mod rocket; + +#[doc(hidden)] +const DEFAULT_HTML: &str = include_str!("../res/redoc.html"); + +/// Trait makes [`Redoc`] to accept an _`URL`_ the [Redoc][redoc] will be served via predefined web +/// server. +/// +/// This is used **only** with **`actix-web`**, **`rocket`** or **`axum`** since they have implicit +/// implementation for serving the [`Redoc`] via the _`URL`_. +/// +/// [redoc]: +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +#[cfg_attr( + doc_cfg, + doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))) +)] +pub trait Servable<'u, 's, S> +where + S: Spec, +{ + /// Consruct a new [`Servable`] instance of _`openapi`_ with given _`url`_. + /// + /// * **url** Must point to location where the [`Servable`] is served. + /// * **openapi** Is [`Spec`] that is served via this [`Servable`] from the _**url**_. + fn with_url(url: &'u str, openapi: S) -> Self + where + 'u: 's; + + /// Consruct a new [`Servable`] instance of _`openapi`_ with given _`url`_ and _`config`_. + /// + /// * **url** Must point to location where the [`Servable`] is served. + /// * **openapi** Is [`Spec`] that is served via this [`Servable`] from the _**url**_. + /// * **config** Is custom [`Config`] that is used to configure the [`Servable`]. + fn with_url_and_config(url: &'u str, openapi: S, config: C) -> Self + where + 'u: 's; +} + +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +impl<'u, 's, S: Spec> Servable<'u, 's, S> for Redoc<'u, 's, S> { + fn with_url(url: &'u str, openapi: S) -> Self + where + 'u: 's, + { + Self::with_url_and_config(url, openapi, EmptyConfig) + } + + fn with_url_and_config(url: &'u str, openapi: S, config: C) -> Self + where + 'u: 's, + { + Self { + url, + html: DEFAULT_HTML, + openapi, + config: config.load(), + } + } +} + +/// Is standalone instance of [Redoc UI][redoc]. +/// +/// This can be used together with predefined web framework integration or standalone with +/// framework of your choice. [`Redoc::to_html`] method will convert this [`Redoc`] instance to +/// servable HTML file. +/// +/// [redoc]: +#[non_exhaustive] +#[derive(Clone)] +pub struct Redoc<'s, 'u, S: Spec> { + #[allow(unused)] + url: &'u str, + html: &'s str, + openapi: S, + config: Value, +} + +impl<'s, 'u, S: Spec> Redoc<'s, 'u, S> { + /// Constructs a new [`Redoc`] instance for given _`openapi`_ [`Spec`]. + /// + /// This will create [`Redoc`] with [`EmptyConfig`]. + /// + /// # Examples + /// + /// _**Create new [`Redoc`] instance with [`EmptyConfig`].**_ + /// ``` + /// # use utoipa_redoc::Redoc; + /// # use serde_json::json; + /// Redoc::new(json!({"openapi": "3.1.0"})); + /// ``` + pub fn new(openapi: S) -> Self { + Self::with_config(openapi, EmptyConfig) + } + + /// Constructs a new [`Redoc`] instance for given _`openapi`_ [`Spec`] and _`config`_ [`Config`] of choise. + /// + /// # Examples + /// + /// _**Create new [`Redoc`] instance with [`FileConfig`].**_ + /// ```no_run + /// # use utoipa_redoc::{Redoc, FileConfig}; + /// # use serde_json::json; + /// Redoc::with_config(json!({"openapi": "3.1.0"}), FileConfig); + /// ``` + pub fn with_config(openapi: S, config: C) -> Self { + Self { + html: DEFAULT_HTML, + url: "", + openapi, + config: config.load(), + } + } + + /// Override the [ default HTML template][redoc_html_quickstart] with new one. See + /// [customization] for more details. + /// + /// [redoc_html_quickstart]: + /// [customization]: index.html#customization + pub fn custom_html(mut self, html: &'s str) -> Self { + self.html = html; + + self + } + + /// Converts this [`Redoc`] instance to servable HTML file. + /// + /// This will replace _**`$config`**_ variable placeholder with [`Config`] of this instance and + /// _**`$spec`**_ with [`Spec`] provided to this instance serializing it to JSON from the HTML + /// template used with the [`Redoc`]. If HTML template is not overridden with + /// [`Redoc::custom_html`] then the [default HTML template][redoc_html_quickstart] will be used. + /// + /// See more details in [customization][customization]. + /// + /// [redoc_html_quickstart]: + /// [customization]: index.html#customization + pub fn to_html(&self) -> String { + self.html + .replace("$config", &self.config.to_string()) + .replace( + "$spec", + &serde_json::to_string(&self.openapi).expect( + "Invalid OpenAPI spec, expected OpenApi, String, &str or serde_json::Value", + ), + ) + } +} + +/// Trait defines OpenAPI spec resource types supported by [`Redoc`]. +/// +/// By deafult this trait is implemented for [`utoipa::openapi::OpenApi`], [`String`], [`&str`] and +/// [`serde_json::Value`]. +/// +/// * **OpenApi** implementation allows using utoipa's OpenApi struct as a OpenAPI spec resource +/// for the [`Redoc`]. +/// * **String** and **&str** implementations allows defining HTTP URL for [`Redoc`] to load the +/// OpenAPI spec from. +/// * **Value** implementation enables the use of arbitrary JSON values with serde's `json!()` +/// macro as a OpenAPI spec for the [`Redoc`]. +/// +/// # Examples +/// +/// _**Use [`Redoc`] to serve utoipa's OpenApi.**_ +///```no_run +/// # use utoipa_redoc::Redoc; +/// # use utoipa::openapi::OpenApiBuilder; +/// # +/// Redoc::new(OpenApiBuilder::new().build()); +/// ``` +/// +/// _**Use [`Redoc`] to serve OpenAPI spec from url.**_ +/// ``` +/// # use utoipa_redoc::Redoc; +/// Redoc::new( +/// "https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml"); +/// ``` +/// +/// _**Use [`Redoc`] to serve custom OpenAPI spec using serde's `json!()` macro.**_ +/// ```rust +/// # use utoipa_redoc::Redoc; +/// # use serde_json::json; +/// Redoc::new(json!({"openapi": "3.1.0"})); +/// ``` +pub trait Spec: Serialize {} + +impl Spec for OpenApi {} + +impl Spec for String {} + +impl Spec for &str {} + +impl Spec for Value {} + +/// Trait defines configuration options for [`Redoc`]. +/// +/// There are 3 configuration methods [`EmptyConfig`], [`FileConfig`] and [`FnOnce`] closure +/// config. The [`Config`] must be able to load and serialize valid JSON. +/// +/// * **EmptyConfig** is the default config and serializes to empty JSON object _`{}`_. +/// * **FileConfig** Allows [`Redoc`] to be configred via user defined file which serializes to +/// JSON. +/// * **FnOnce** closure config allows inlining JSON serializable config directly to [`Redoc`] +/// declaration. +/// +/// Configuration format and allowed options can be found from Redocly's own API documentation. +/// +/// * [All supported Redoc configuration options][redoc_config]. +/// +/// **Note!** There is no validity check for configuration options and all options provided are +/// serialized as is to the [Redoc][redoc]. It is users own responsibility to check for possible +/// mispelled configuration options against the valid configuration options. +/// +/// # Examples +/// +/// _**Using [`FnOnce`] closure config.**_ +/// ```rust +/// # use utoipa_redoc::Redoc; +/// # use utoipa::OpenApi; +/// # use serde_json::json; +/// # #[derive(OpenApi)] +/// # #[openapi()] +/// # struct ApiDoc; +/// # +/// Redoc::with_config(ApiDoc::openapi(), || json!({ "disableSearch": true })); +/// ``` +/// +/// _**Using [`FileConfig`].**_ +/// ```no_run +/// # use utoipa_redoc::{Redoc, FileConfig}; +/// # use utoipa::OpenApi; +/// # use serde_json::json; +/// # #[derive(OpenApi)] +/// # #[openapi()] +/// # struct ApiDoc; +/// # +/// Redoc::with_config(ApiDoc::openapi(), FileConfig); +/// ``` +/// +/// [redoc]: +/// [redoc_config]: +pub trait Config { + /// Implementor must implement the logic which loads the configuration of choice and coverts it + /// to serde's [`serde_json::Value`]. + fn load(self) -> Value; +} + +impl S> Config for F { + fn load(self) -> Value { + json!(self()) + } +} + +/// Makes [`Redoc`] to load it's configuration from a user defined file. +/// +/// The config file must be defined via _**`UTOIPA_REDOC_CONFIG_FILE`**_ env variable for your +/// application. It can either be defined in runtime before the [`Redoc`] declaration or before +/// application starup or at compile time via `build.rs` file. +/// +/// The file must be located relative to your application runtime directory. +/// +/// The file must be loadable via [`Config`] and it must return a JSON object representing the +/// [Redoc configuration][redoc_config]. +/// +/// # Examples +/// +/// _**Using a `build.rs` file to define the config file.**_ +/// ```rust +/// # fn main() { +/// println!("cargo:rustc-env=UTOIPA_REDOC_CONFIG_FILE=redoc.config.json"); +/// # } +/// ``` +/// +/// _**Defining config file at application startup.**_ +/// ```bash +/// UTOIPA_REDOC_CONFIG_FILE=redoc.config.json cargo run +/// ``` +/// +/// [redoc_config]: +pub struct FileConfig; + +impl Config for FileConfig { + fn load(self) -> Value { + let path = env::var("UTOIPA_REDOC_CONFIG_FILE") + .expect("Missing `UTOIPA_REDOC_CONFIG_FILE` env variable, cannot load file config."); + + let file = OpenOptions::new() + .read(true) + .open(&path) + .unwrap_or_else(|_| panic!("File `{path}` is not readable or does not exist.")); + serde_json::from_reader(file).expect("Config file cannot be parsed to JSON") + } +} + +/// Is the default configuration and serializes to empty JSON object _`{}`_. +pub struct EmptyConfig; + +impl Config for EmptyConfig { + fn load(self) -> Value { + json!({}) + } +} diff --git a/utoipa-redoc/src/rocket.rs b/utoipa-redoc/src/rocket.rs new file mode 100644 index 00000000..a350a9e5 --- /dev/null +++ b/utoipa-redoc/src/rocket.rs @@ -0,0 +1,28 @@ +#![cfg(feature = "rocket")] + +use rocket::http::Method; +use rocket::response::content::RawHtml; +use rocket::route::{Handler, Outcome}; +use rocket::{Data, Request, Route}; + +use crate::{Redoc, Spec}; + +impl<'s, 'u, S: Spec> From> for Vec { + fn from(value: Redoc<'s, 'u, S>) -> Self { + vec![Route::new( + Method::Get, + value.url, + RedocHandler(value.to_html()), + )] + } +} + +#[derive(Clone)] +struct RedocHandler(String); + +#[rocket::async_trait] +impl Handler for RedocHandler { + async fn handle<'r>(&self, request: &'r Request<'_>, _: Data<'r>) -> Outcome<'r> { + Outcome::from(request, RawHtml(self.0.clone())) + } +}