Skip to main content

rustlavel_openapi/
docs.rs

1//! The human-readable documentation page.
2//!
3//! Rendered from the same document the JSON endpoint serves, by a page that
4//! carries its own styles and script — no CDN, so the docs work on a machine
5//! with no internet and inside a private network.
6
7use crate::Info;
8
9/// Build the page that reads the document at `document_path`.
10pub fn page(info: &Info, document_path: &str) -> String {
11    format!(
12        r#"<!doctype html>
13<html lang="en">
14<head>
15<meta charset="utf-8">
16<meta name="viewport" content="width=device-width, initial-scale=1">
17<title>{title} — API</title>
18<style>{CSS}</style>
19</head>
20<body>
21<main>
22  <header>
23    <h1>{title}</h1>
24    <p class="version">Version {version}</p>
25    {description}
26    <p class="source">Machine-readable: <a href="{document_path}">{document_path}</a></p>
27  </header>
28  <div id="operations" class="loading">Loading the API description…</div>
29</main>
30<script>
31const DOCUMENT_URL = "{document_path}";
32
33function escapeHtml(value) {{
34  return String(value).replace(/[&<>"']/g, c => (
35    {{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}}[c]
36  ));
37}}
38
39function render(document) {{
40  const paths = document.paths || {{}};
41  const groups = new Map();
42
43  for (const [path, operations] of Object.entries(paths)) {{
44    for (const [method, operation] of Object.entries(operations)) {{
45      const tag = (operation.tags && operation.tags[0]) || 'General';
46      if (!groups.has(tag)) groups.set(tag, []);
47      groups.get(tag).push({{ path, method, operation }});
48    }}
49  }}
50
51  if (groups.size === 0) {{
52    return '<p class="empty">No operations are documented yet. '
53         + 'Add <code>.describe("…")</code> to a route under the API prefix.</p>';
54  }}
55
56  const sections = [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
57  return sections.map(([tag, entries]) => {{
58    const rows = entries
59      .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
60      .map(({{ path, method, operation }}) => {{
61        const parameters = (operation.parameters || []).map(p =>
62          `<li><code>${{escapeHtml(p.name)}}</code>`
63          + `<span class="where">${{escapeHtml(p.in)}}</span>`
64          + (p.required ? '<span class="required">required</span>' : '')
65          + (p.description ? `<span class="note">${{escapeHtml(p.description)}}</span>` : '')
66          + '</li>'
67        ).join('');
68
69        const responses = Object.entries(operation.responses || {{}}).map(([status, r]) =>
70          `<li><span class="status s${{status[0]}}">${{escapeHtml(status)}}</span>`
71          + `<span class="note">${{escapeHtml(r.description || '')}}</span></li>`
72        ).join('');
73
74        return `<article class="op${{operation.deprecated ? ' deprecated' : ''}}">
75          <div class="line">
76            <span class="method m-${{escapeHtml(method)}}">${{escapeHtml(method.toUpperCase())}}</span>
77            <code class="path">${{escapeHtml(path)}}</code>
78            ${{operation.deprecated ? '<span class="tag-deprecated">deprecated</span>' : ''}}
79          </div>
80          ${{operation.summary ? `<p class="summary">${{escapeHtml(operation.summary)}}</p>` : ''}}
81          <div class="detail">
82            ${{parameters ? `<div><h4>Parameters</h4><ul class="params">${{parameters}}</ul></div>` : ''}}
83            ${{responses ? `<div><h4>Responses</h4><ul class="responses">${{responses}}</ul></div>` : ''}}
84          </div>
85        </article>`;
86      }}).join('');
87
88    return `<section><h2>${{escapeHtml(tag)}}</h2>${{rows}}</section>`;
89  }}).join('');
90}}
91
92(async () => {{
93  const target = document.getElementById('operations');
94  try {{
95    const response = await fetch(DOCUMENT_URL);
96    const source = await response.json();
97    target.classList.remove('loading');
98    target.innerHTML = render(source);
99  }} catch (error) {{
100    target.textContent = 'Could not load ' + DOCUMENT_URL + ': ' + error;
101  }}
102}})();
103</script>
104</body>
105</html>"#,
106        title = escape(&info.title),
107        version = escape(&info.version),
108        description = info
109            .description
110            .as_ref()
111            .map(|d| format!("<p class=\"description\">{}</p>", escape(d)))
112            .unwrap_or_default(),
113        // The page reads the same document the JSON endpoint serves, so there
114        // is one source of truth rather than two that can disagree.
115        document_path = escape(document_path),
116    )
117}
118
119fn escape(value: &str) -> String {
120    let mut out = String::with_capacity(value.len());
121    for ch in value.chars() {
122        match ch {
123            '&' => out.push_str("&amp;"),
124            '<' => out.push_str("&lt;"),
125            '>' => out.push_str("&gt;"),
126            '"' => out.push_str("&quot;"),
127            '\'' => out.push_str("&#39;"),
128            c => out.push(c),
129        }
130    }
131    out
132}
133
134const CSS: &str = r#"
135:root { color-scheme: light dark; --bg:#faf9f7; --fg:#1c1b1a; --muted:#6b6864; --line:#e5e2dd;
136        --accent:#b4483c; --panel:#fff; --code:#f4f2ef; }
137@media (prefers-color-scheme: dark) {
138  :root { --bg:#181716; --fg:#eceae7; --muted:#9a958e; --line:#2e2c29; --accent:#e0796c;
139          --panel:#201f1d; --code:#252321; }
140}
141* { box-sizing: border-box; }
142body { margin:0; background:var(--bg); color:var(--fg);
143       font:15px/1.6 ui-sans-serif,-apple-system,'Segoe UI',sans-serif; }
144main { max-width: 880px; margin: 0 auto; padding: 48px 24px 96px; }
145header { border-bottom:1px solid var(--line); padding-bottom:24px; margin-bottom:32px; }
146h1 { font-size:28px; margin:0 0 4px; font-weight:650; }
147.version { margin:0; color:var(--muted); font-size:13px; }
148.description { margin:12px 0 0; }
149.source { margin:12px 0 0; font-size:13px; color:var(--muted); }
150.source a { color:var(--accent); }
151h2 { font-size:13px; text-transform:uppercase; letter-spacing:.08em; color:var(--muted);
152     margin:32px 0 12px; font-weight:600; }
153h4 { font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted);
154     margin:0 0 6px; font-weight:600; }
155.op { background:var(--panel); border:1px solid var(--line); border-radius:8px;
156      padding:16px 20px; margin-bottom:10px; }
157.op.deprecated { opacity:.6; }
158.line { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
159.method { font-size:11px; font-weight:700; letter-spacing:.05em; padding:3px 8px;
160          border-radius:4px; color:#fff; min-width:56px; text-align:center; }
161.m-get { background:#3d7ea6; } .m-post { background:#4a8c5f; } .m-put { background:#a8813c; }
162.m-patch { background:#8a6bab; } .m-delete { background:var(--accent); }
163.path { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:14px; }
164.tag-deprecated { font-size:11px; color:var(--accent); border:1px solid var(--accent);
165                  border-radius:4px; padding:1px 6px; }
166.summary { margin:8px 0 0; color:var(--fg); }
167.detail { display:flex; gap:40px; flex-wrap:wrap; margin-top:14px; }
168.detail:empty { display:none; }
169ul { list-style:none; margin:0; padding:0; font-size:13px; }
170li { padding:2px 0; display:flex; align-items:baseline; gap:8px; }
171code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--code);
172       padding:1px 5px; border-radius:3px; font-size:13px; }
173.where { color:var(--muted); font-size:11px; }
174.required { color:var(--accent); font-size:11px; }
175.note { color:var(--muted); }
176.status { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-weight:600; min-width:34px; }
177.s2 { color:#4a8c5f; } .s3 { color:#a8813c; } .s4, .s5 { color:var(--accent); }
178.loading, .empty { color:var(--muted); }
179"#;
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn the_page_names_the_api_and_points_at_the_document() {
187        let info = Info { title: "Orders API".into(), version: "2.1".into(), ..Info::default() };
188        let page = page(&info, "/openapi.json");
189
190        assert!(page.contains("<title>Orders API — API</title>"));
191        assert!(page.contains("Version 2.1"));
192        assert!(page.contains(r#"href="/openapi.json""#));
193        assert!(page.contains(r#"const DOCUMENT_URL = "/openapi.json";"#));
194    }
195
196    #[test]
197    fn it_carries_its_own_styles_and_script() {
198        let page = page(&Info::default(), "/openapi.json");
199
200        // A machine inside a private network has no CDN to reach.
201        assert!(!page.contains("http://") && !page.contains("https://"));
202        assert!(page.contains("<style>"));
203        assert!(page.contains("prefers-color-scheme: dark"));
204    }
205
206    #[test]
207    fn a_title_with_markup_is_escaped() {
208        let info = Info { title: "<script>alert(1)</script>".into(), ..Info::default() };
209        let page = page(&info, "/openapi.json");
210
211        assert!(!page.contains("<script>alert(1)</script>"));
212        assert!(page.contains("&lt;script&gt;"));
213    }
214}