Skip to main content

dioxus_openapi/
runtime.rs

1//! Server-only OpenAPI assembly + Scalar HTML helper.
2
3use utoipa::openapi::{
4    info::InfoBuilder,
5    path::{PathItem, PathItemBuilder, PathsBuilder},
6    schema::ComponentsBuilder,
7    tag::TagBuilder,
8    OpenApi, OpenApiBuilder,
9};
10
11/// One documented endpoint, registered by [`crate::api_get`] / [`crate::api_post`].
12///
13/// `append` folds the path into a [`PathsBuilder`]; `schemas` contributes
14/// component schemas referenced by that path (request bodies, responses).
15pub struct RegisteredPath {
16    pub append: fn(PathsBuilder) -> PathsBuilder,
17    pub schemas: fn(
18        &mut Vec<(
19            String,
20            utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
21        )>,
22    ),
23}
24
25inventory::collect!(RegisteredPath);
26
27/// Tag metadata for the top-level OpenAPI `tags` array (sidebar descriptions).
28#[derive(Debug, Clone, Copy)]
29pub struct TagMeta {
30    pub name: &'static str,
31    pub description: Option<&'static str>,
32}
33
34/// Document-level OpenAPI info + optional tag descriptions.
35#[derive(Debug, Clone, Copy)]
36pub struct SpecOptions {
37    pub title: &'static str,
38    pub version: &'static str,
39    pub description: Option<&'static str>,
40    pub tags: &'static [TagMeta],
41}
42
43/// Like [`PathsBuilder::path_from`], but also copies tags from
44/// [`utoipa::__dev::Tags`] onto the operation.
45///
46/// utoipa stores path tags on a separate trait; the `OpenApi` derive merges
47/// them when listing `paths(...)`. Inventory registration must do the same
48/// or Scalar dumps every operation under the first sidebar tag.
49pub fn append_tagged_path<P>(paths: PathsBuilder) -> PathsBuilder
50where
51    P: utoipa::Path,
52    P: for<'t> utoipa::__dev::Tags<'t>,
53{
54    let mut operation = P::operation();
55    let tags: Vec<String> = P::tags().into_iter().map(str::to_string).collect();
56    if !tags.is_empty() {
57        operation.tags = Some(tags);
58    }
59
60    let methods = P::methods();
61    let path_item = if methods.len() == 1 {
62        PathItem::new(
63            methods
64                .into_iter()
65                .next()
66                .expect("path must declare at least one method"),
67            operation,
68        )
69    } else {
70        methods
71            .into_iter()
72            .fold(PathItemBuilder::new(), |item, method| {
73                item.operation(method, operation.clone())
74            })
75            .build()
76    };
77
78    paths.path(P::path(), path_item)
79}
80
81/// Build the full OpenAPI document from all inventory-registered paths.
82pub fn build_openapi(opts: &SpecOptions) -> OpenApi {
83    let mut paths = PathsBuilder::new();
84    let mut schema_list: Vec<(
85        String,
86        utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
87    )> = Vec::new();
88
89    for reg in inventory::iter::<RegisteredPath> {
90        paths = (reg.append)(paths);
91        (reg.schemas)(&mut schema_list);
92    }
93
94    // Deduplicate schemas by name (same type referenced from many paths).
95    let mut seen = std::collections::HashSet::new();
96    schema_list.retain(|(name, _)| seen.insert(name.clone()));
97
98    let mut info = InfoBuilder::new()
99        .title(opts.title)
100        .version(opts.version);
101    if let Some(desc) = opts.description {
102        info = info.description(Some(desc));
103    }
104
105    let tags: Vec<_> = opts
106        .tags
107        .iter()
108        .map(|t| {
109            let mut b = TagBuilder::new().name(t.name);
110            if let Some(d) = t.description {
111                b = b.description(Some(d));
112            }
113            b.build()
114        })
115        .collect();
116
117    OpenApiBuilder::new()
118        .info(info.build())
119        .paths(paths.build())
120        .components(Some(
121            ComponentsBuilder::new()
122                .schemas_from_iter(schema_list)
123                .build(),
124        ))
125        .tags(if tags.is_empty() { None } else { Some(tags) })
126        .build()
127}
128
129/// Options for the Scalar interactive API reference HTML shell.
130#[derive(Debug, Clone)]
131pub struct ScalarOptions<'a> {
132    pub title: &'a str,
133    /// URL the browser fetches for the OpenAPI JSON (e.g. `/api/openapi.json`).
134    pub openapi_url: &'a str,
135    /// Extra CSS injected into the page (and as Scalar `customCss`).
136    /// Typically theme tokens (`include_str!` of Tailwind) + a small bridge.
137    pub custom_css: &'a str,
138    pub dark_mode: bool,
139    pub hide_agent: bool,
140    pub hide_mcp: bool,
141    pub hide_client_button: bool,
142    pub hide_clients: bool,
143}
144
145impl Default for ScalarOptions<'static> {
146    fn default() -> Self {
147        Self {
148            title: "API Reference",
149            openapi_url: "/api/openapi.json",
150            custom_css: "",
151            dark_mode: true,
152            hide_agent: true,
153            hide_mcp: true,
154            hide_client_button: true,
155            hide_clients: true,
156        }
157    }
158}
159
160/// Build a standalone Scalar HTML document for `GET /api/docs` (or similar).
161pub fn scalar_html(opts: &ScalarOptions<'_>) -> String {
162    let css_for_js = opts
163        .custom_css
164        .replace('\\', "\\\\")
165        .replace('`', "\\`")
166        .replace("${", "\\${");
167
168    let dark_class = if opts.dark_mode { "dark-mode" } else { "" };
169    let color_scheme = if opts.dark_mode { "dark" } else { "light" };
170
171    format!(
172        r#"<!doctype html>
173<html class="{dark_class}">
174<head>
175    <title>{title}</title>
176    <meta charset="utf-8"/>
177    <meta name="viewport" content="width=device-width, initial-scale=1"/>
178    <meta name="color-scheme" content="{color_scheme}"/>
179    <style>{css}</style>
180</head>
181<body>
182    <div id="app"></div>
183    <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
184    <script>
185        Scalar.createApiReference('#app', {{
186            url: '{openapi_url}',
187            theme: 'none',
188            darkMode: {dark_mode},
189            forceDarkModeState: '{force_dark}',
190            hideDarkModeToggle: {hide_toggle},
191            withDefaultFonts: false,
192            agent: {{ disabled: {hide_agent} }},
193            mcp: {{ disabled: {hide_mcp} }},
194            hideClientButton: {hide_client_button},
195            hiddenClients: {hide_clients},
196            customCss: `{css_js}`,
197        }});
198    </script>
199</body>
200</html>
201"#,
202        dark_class = dark_class,
203        title = opts.title,
204        color_scheme = color_scheme,
205        css = opts.custom_css,
206        openapi_url = opts.openapi_url,
207        dark_mode = opts.dark_mode,
208        force_dark = if opts.dark_mode { "dark" } else { "light" },
209        hide_toggle = opts.dark_mode,
210        hide_agent = opts.hide_agent,
211        hide_mcp = opts.hide_mcp,
212        hide_client_button = opts.hide_client_button,
213        hide_clients = opts.hide_clients,
214        css_js = css_for_js,
215    )
216}