utoipa_rapidoc/
lib.rs

1#![warn(missing_docs)]
2#![warn(rustdoc::broken_intra_doc_links)]
3#![cfg_attr(doc_cfg, feature(doc_cfg))]
4//! This crate works as a bridge between [utoipa](https://docs.rs/utoipa/latest/utoipa/) and [RapiDoc](https://rapidocweb.com/) OpenAPI visualizer.
5//!
6//! Utoipa-rapidoc provides simple mechanism to transform OpenAPI spec resource to a servable HTML
7//! file which can be served via [predefined framework integration][Self#examples] or used
8//! [standalone][Self#using-standalone] and served manually.
9//!
10//! You may find fullsize examples from utoipa's Github [repository][examples].
11//!
12//! # Crate Features
13//!
14//! * **actix-web** Allows serving [`RapiDoc`] via _**`actix-web`**_.
15//! * **rocket** Allows serving [`RapiDoc`] via _**`rocket`**_.
16//! * **axum** Allows serving [`RapiDoc`] via _**`axum`**_.
17//!
18//! # Install
19//!
20//! Use RapiDoc only without any boiler plate implementation.
21//! ```toml
22//! [dependencies]
23//! utoipa-rapidoc = "6"
24//! ```
25//!
26//! Enable actix-web integration with RapiDoc.
27//! ```toml
28//! [dependencies]
29//! utoipa-rapidoc = { version = "6", features = ["actix-web"] }
30//! ```
31//!
32//! # Using standalone
33//!
34//! Utoipa-rapidoc can be used standalone as simply as creating a new [`RapiDoc`] instance and then
35//! serving it by what ever means available as `text/html` from http handler in your favourite web
36//! framework.
37//!
38//! [`RapiDoc::to_html`] method can be used to convert the [`RapiDoc`] instance to a servable html
39//! file.
40//! ```
41//! # use utoipa_rapidoc::RapiDoc;
42//! # use utoipa::OpenApi;
43//! # use serde_json::json;
44//! # #[derive(OpenApi)]
45//! # #[openapi()]
46//! # struct ApiDoc;
47//! #
48//! let rapidoc = RapiDoc::new("/api-docs/openapi.json");
49//!
50//! // Then somewhere in your application that handles http operation.
51//! // Make sure you return correct content type `text/html`.
52//! let rapidoc_handler = move || {
53//!     rapidoc.to_html()
54//! };
55//! ```
56//!
57//! # Customization
58//!
59//! Utoipa-rapidoc can be customized and configured only via [`RapiDoc::custom_html`] method. This
60//! method empowers users to use a custom HTML template to modify the looks of the RapiDoc UI.
61//!
62//! * [All allowed RapiDoc configuration options][rapidoc_api]
63//! * [Default HTML template][rapidoc_quickstart]
64//!
65//! The template should contain _**`$specUrl`**_ variable which will be replaced with user defined
66//! OpenAPI spec url provided with [`RapiDoc::new`] function when creating a new [`RapiDoc`]
67//! instance. Variable will be replaced during [`RapiDoc::to_html`] function execution.
68//!
69//! _**Overriding the HTML template with a custom one.**_
70//! ```rust
71//! # use utoipa_rapidoc::RapiDoc;
72//! # use utoipa::OpenApi;
73//! # use serde_json::json;
74//! # #[derive(OpenApi)]
75//! # #[openapi()]
76//! # struct ApiDoc;
77//! #
78//! let html = "...";
79//! RapiDoc::new("/api-docs/openapi.json").custom_html(html);
80//! ```
81//!
82//! # Examples
83//!
84//! _**Serve [`RapiDoc`] via `actix-web` framework.**_
85//! ```no_run
86//! use actix_web::App;
87//! use utoipa_rapidoc::RapiDoc;
88//!
89//! # use utoipa::OpenApi;
90//! # use std::net::Ipv4Addr;
91//! # #[derive(OpenApi)]
92//! # #[openapi()]
93//! # struct ApiDoc;
94//! App::new()
95//!     .service(
96//!         RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc")
97//!     );
98//! ```
99//!
100//! _**Serve [`RapiDoc`] via `rocket` framework.**_
101//! ```no_run
102//! # use rocket;
103//! use utoipa_rapidoc::RapiDoc;
104//!
105//! # use utoipa::OpenApi;
106//! # #[derive(OpenApi)]
107//! # #[openapi()]
108//! # struct ApiDoc;
109//! rocket::build()
110//!     .mount(
111//!         "/",
112//!         RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc"),
113//!     );
114//! ```
115//!
116//! _**Serve [`RapiDoc`] via `axum` framework.**_
117//! ```no_run
118//! use axum::Router;
119//! use utoipa_rapidoc::RapiDoc;
120//! # use utoipa::OpenApi;
121//! # #[derive(OpenApi)]
122//! # #[openapi()]
123//! # struct ApiDoc;
124//! #
125//! # fn inner<S>()
126//! # where
127//! #     S: Clone + Send + Sync + 'static,
128//! # {
129//!
130//! let app = Router::<S>::new()
131//!     .merge(
132//!         RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc")
133//!     );
134//! # }
135//! ```
136//!
137//! [rapidoc_api]: <https://rapidocweb.com/api.html>
138//! [examples]: <https://github.com/juhaku/utoipa/tree/master/examples>
139//! [rapidoc_quickstart]: <https://rapidocweb.com/quickstart.html>
140
141use std::borrow::Cow;
142
143const DEFAULT_HTML: &str = include_str!("../res/rapidoc.html");
144
145/// Is [RapiDoc][rapidoc] UI.
146///
147/// This is an entry point for serving [RapiDoc][rapidoc] via predefined framework integration or
148/// in standalone fashion by calling [`RapiDoc::to_html`] within custom HTTP handler handles
149/// serving the [RapiDoc][rapidoc] UI. See more at [running standalone][standalone]
150///
151/// [rapidoc]: <https://rapidocweb.com>
152/// [standalone]: index.html#using-standalone
153#[non_exhaustive]
154pub struct RapiDoc {
155    #[allow(unused)]
156    path: Cow<'static, str>,
157    spec_url: Cow<'static, str>,
158    html: Cow<'static, str>,
159    #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))]
160    openapi: Option<utoipa::openapi::OpenApi>,
161}
162
163impl RapiDoc {
164    /// Construct a new [`RapiDoc`] that points to given `spec_url`. Spec url must be valid URL and
165    /// available for RapiDoc to consume.
166    ///
167    /// # Examples
168    ///
169    /// _**Create new [`RapiDoc`].**_
170    ///
171    /// ```
172    /// # use utoipa_rapidoc::RapiDoc;
173    /// RapiDoc::new("https://petstore3.swagger.io/api/v3/openapi.json");
174    /// ```
175    pub fn new<U: Into<Cow<'static, str>>>(spec_url: U) -> Self {
176        Self {
177            path: Cow::Borrowed(""),
178            spec_url: spec_url.into(),
179            html: Cow::Borrowed(DEFAULT_HTML),
180            #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))]
181            openapi: None,
182        }
183    }
184
185    /// Construct a new [`RapiDoc`] with given `spec_url` and `openapi`. The spec url must point to
186    /// the location where the `openapi` will be served.
187    ///
188    /// [`RapiDoc`] is only able to create endpoint that serves the `openapi` JSON for predefined
189    /// frameworks. _**For other frameworks such endpoint must be created manually.**_
190    ///
191    /// # Examples
192    ///
193    /// _**Create new [`RapiDoc`].**_
194    ///
195    /// ```
196    /// # use utoipa_rapidoc::RapiDoc;
197    /// # use utoipa::OpenApi;
198    /// # #[derive(OpenApi)]
199    /// # #[openapi()]
200    /// # struct ApiDoc;
201    /// RapiDoc::with_openapi(
202    ///     "/api-docs/openapi.json",
203    ///     ApiDoc::openapi()
204    /// );
205    /// ```
206    #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))]
207    #[cfg_attr(
208        doc_cfg,
209        doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum")))
210    )]
211    pub fn with_openapi<U: Into<Cow<'static, str>>>(
212        spec_url: U,
213        openapi: utoipa::openapi::OpenApi,
214    ) -> Self {
215        Self {
216            path: Cow::Borrowed(""),
217            spec_url: spec_url.into(),
218            html: Cow::Borrowed(DEFAULT_HTML),
219            openapi: Some(openapi),
220        }
221    }
222
223    /// Construct a new [`RapiDoc`] with given `url`, `spec_url` and `openapi`. The `url` defines
224    /// the location where the RapiDoc UI will be served. The spec url must point to the location
225    /// where the `openapi` will be served.
226    ///
227    /// [`RapiDoc`] is only able to create an endpoint that serves the `openapi` JSON for predefined
228    /// frameworks. _**For other frameworks such an endpoint must be created manually.**_
229    ///
230    /// # Examples
231    ///
232    /// _**Create new [`RapiDoc`] with custom location.**_
233    ///
234    /// ```
235    /// # use utoipa_rapidoc::RapiDoc;
236    /// # use utoipa::OpenApi;
237    /// # #[derive(OpenApi)]
238    /// # #[openapi()]
239    /// # struct ApiDoc;
240    /// RapiDoc::with_url(
241    ///     "/rapidoc",
242    ///     "/api-docs/openapi.json",
243    ///     ApiDoc::openapi()
244    /// );
245    /// ```
246    #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))]
247    #[cfg_attr(
248        doc_cfg,
249        doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum")))
250    )]
251    pub fn with_url<U: Into<Cow<'static, str>>, S: Into<Cow<'static, str>>>(
252        url: U,
253        spec_url: S,
254        openapi: utoipa::openapi::OpenApi,
255    ) -> Self {
256        Self {
257            path: url.into(),
258            spec_url: spec_url.into(),
259            html: Cow::Borrowed(DEFAULT_HTML),
260            openapi: Some(openapi),
261        }
262    }
263
264    /// Override the [default HTML template][rapidoc_quickstart] with new one. See
265    /// [customization] for more details.
266    ///
267    /// [rapidoc_quickstart]: <https://rapidocweb.com/quickstart.html>
268    /// [customization]: index.html#customization
269    pub fn custom_html<H: Into<Cow<'static, str>>>(mut self, html: H) -> Self {
270        self.html = html.into();
271
272        self
273    }
274
275    /// Add `path` the [`RapiDoc`] will be served from.
276    ///
277    /// # Examples
278    ///
279    /// _**Make [`RapiDoc`] servable from `/rapidoc` path.**_
280    /// ```
281    /// # use utoipa_rapidoc::RapiDoc;
282    ///
283    /// RapiDoc::new("https://petstore3.swagger.io/api/v3/openapi.json")
284    ///     .path("/rapidoc");
285    /// ```
286    #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))]
287    pub fn path<U: Into<Cow<'static, str>>>(mut self, path: U) -> Self {
288        self.path = path.into();
289
290        self
291    }
292
293    /// Converts this [`RapiDoc`] instance to servable HTML file.
294    ///
295    /// This will replace _**`$specUrl`**_ variable placeholder with the spec
296    /// url provided to the [`RapiDoc`] instance. If HTML template is not overridden with
297    /// [`RapiDoc::custom_html`] then the [default HTML template][rapidoc_quickstart]
298    /// will be used.
299    ///
300    /// See more details in [customization][customization].
301    ///
302    /// [rapidoc_quickstart]: <https://rapidocweb.com/quickstart.html>
303    /// [customization]: index.html#customization
304    pub fn to_html(&self) -> String {
305        self.html.replace("$specUrl", self.spec_url.as_ref())
306    }
307}
308
309mod actix {
310    #![cfg(feature = "actix-web")]
311
312    use actix_web::dev::HttpServiceFactory;
313    use actix_web::guard::Get;
314    use actix_web::web::Data;
315    use actix_web::{HttpResponse, Resource, Responder};
316
317    use crate::RapiDoc;
318
319    impl HttpServiceFactory for RapiDoc {
320        fn register(self, config: &mut actix_web::dev::AppService) {
321            let html = self.to_html();
322
323            async fn serve_rapidoc(rapidoc: Data<String>) -> impl Responder {
324                HttpResponse::Ok()
325                    .content_type("text/html")
326                    .body(rapidoc.to_string())
327            }
328
329            Resource::new(self.path.as_ref())
330                .guard(Get())
331                .app_data(Data::new(html))
332                .to(serve_rapidoc)
333                .register(config);
334
335            if let Some(openapi) = self.openapi {
336                async fn serve_openapi(openapi: Data<String>) -> impl Responder {
337                    HttpResponse::Ok()
338                        .content_type("application/json")
339                        .body(openapi.into_inner().to_string())
340                }
341
342                Resource::new(self.spec_url.as_ref())
343                    .guard(Get())
344                    .app_data(Data::new(
345                        openapi.to_json().expect("Should serialize to JSON"),
346                    ))
347                    .to(serve_openapi)
348                    .register(config);
349            }
350        }
351    }
352}
353
354mod axum {
355    #![cfg(feature = "axum")]
356
357    use axum::response::Html;
358    use axum::{routing, Json, Router};
359
360    use crate::RapiDoc;
361
362    impl<R> From<RapiDoc> for Router<R>
363    where
364        R: Clone + Send + Sync + 'static,
365    {
366        fn from(value: RapiDoc) -> Self {
367            let html = value.to_html();
368            let openapi = value.openapi;
369
370            let path = value.path.as_ref();
371            let path = if path.is_empty() { "/" } else { path };
372            let mut router =
373                Router::<R>::new().route(path, routing::get(move || async { Html(html) }));
374
375            if let Some(openapi) = openapi {
376                router = router.route(
377                    value.spec_url.as_ref(),
378                    routing::get(move || async { Json(openapi) }),
379                );
380            }
381
382            router
383        }
384    }
385}
386
387mod rocket {
388    #![cfg(feature = "rocket")]
389
390    use rocket::http::Method;
391    use rocket::response::content::RawHtml;
392    use rocket::route::{Handler, Outcome};
393    use rocket::serde::json::Json;
394    use rocket::{Data, Request, Route};
395
396    use crate::RapiDoc;
397
398    impl From<RapiDoc> for Vec<Route> {
399        fn from(value: RapiDoc) -> Self {
400            let mut routes = vec![Route::new(
401                Method::Get,
402                value.path.as_ref(),
403                RapiDocHandler(value.to_html()),
404            )];
405
406            if let Some(openapi) = value.openapi {
407                routes.push(Route::new(
408                    Method::Get,
409                    value.spec_url.as_ref(),
410                    OpenApiHandler(openapi),
411                ));
412            }
413
414            routes
415        }
416    }
417
418    #[derive(Clone)]
419    struct RapiDocHandler(String);
420
421    #[rocket::async_trait]
422    impl Handler for RapiDocHandler {
423        async fn handle<'r>(&self, request: &'r Request<'_>, _: Data<'r>) -> Outcome<'r> {
424            Outcome::from(request, RawHtml(self.0.clone()))
425        }
426    }
427
428    #[derive(Clone)]
429    struct OpenApiHandler(utoipa::openapi::OpenApi);
430
431    #[rocket::async_trait]
432    impl Handler for OpenApiHandler {
433        async fn handle<'r>(&self, request: &'r Request<'_>, _: Data<'r>) -> Outcome<'r> {
434            Outcome::from(request, Json(self.0.clone()))
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    #[test]
442    #[cfg(feature = "axum")]
443    fn test_axum_with_empty_path() {
444        use ::axum::Router;
445        use utoipa::OpenApi;
446
447        use super::RapiDoc;
448
449        #[derive(utoipa::OpenApi)]
450        #[openapi()]
451        struct ApiDoc;
452
453        let _: Router = Router::new().merge(RapiDoc::with_openapi("/rapidoc", ApiDoc::openapi()));
454    }
455}