rustlavel_openapi/
docs.rs1use crate::Info;
8
9pub 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 {{'&':'&','<':'<','>':'>','"':'"',"'":'''}}[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 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("&"),
124 '<' => out.push_str("<"),
125 '>' => out.push_str(">"),
126 '"' => out.push_str("""),
127 '\'' => out.push_str("'"),
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 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("<script>"));
213 }
214}