]> git.proxmox.com Git - proxmox.git/blob - proxmox/src/api/format.rs
api: implement schema doc generator for PropertyStrings
[proxmox.git] / proxmox / src / api / format.rs
1 //! Module to generate and format API Documenation
2
3 use anyhow::{bail, Error};
4
5 use std::io::Write;
6
7 use crate::api::router::ReturnType;
8 use crate::api::schema::*;
9 use crate::api::{ApiHandler, ApiMethod};
10
11 /// Enumerate different styles to display parameters/properties.
12 #[derive(Copy, Clone, PartialEq)]
13 pub enum ParameterDisplayStyle {
14 /// Used for properties in configuration files: ``key:``
15 Config,
16 /// Used for PropertyStings properties in configuration files
17 ConfigSub,
18 /// Used for command line options: ``--key``
19 Arg,
20 /// Used for command line options passed as arguments: ``<key>``
21 Fixed,
22 }
23
24 /// CLI usage information format.
25 #[derive(Copy, Clone, PartialEq)]
26 pub enum DocumentationFormat {
27 /// Text, command line only (one line).
28 Short,
29 /// Text, list all options.
30 Long,
31 /// Text, include description.
32 Full,
33 /// Like full, but in reStructuredText format.
34 ReST,
35 }
36
37 /// Line wrapping to form simple list of paragraphs.
38 pub fn wrap_text(
39 initial_indent: &str,
40 subsequent_indent: &str,
41 text: &str,
42 columns: usize,
43 ) -> String {
44 let wrapper1 = textwrap::Wrapper::new(columns)
45 .initial_indent(initial_indent)
46 .subsequent_indent(subsequent_indent);
47
48 let wrapper2 = textwrap::Wrapper::new(columns)
49 .initial_indent(subsequent_indent)
50 .subsequent_indent(subsequent_indent);
51
52 text.split("\n\n")
53 .map(|p| p.trim())
54 .filter(|p| !p.is_empty())
55 .fold(String::new(), |mut acc, p| {
56 if acc.is_empty() {
57 acc.push_str(&wrapper1.wrap(p).join("\n"));
58 } else {
59 acc.push_str(&wrapper2.wrap(p).join("\n"));
60 }
61 acc.push_str("\n\n");
62 acc
63 })
64 }
65
66 #[test]
67 fn test_wrap_text() {
68 let text = "Command. This may be a list in order to spefify nested sub-commands.";
69 let expect = " Command. This may be a list in order to spefify nested sub-\n commands.\n\n";
70
71 let indent = " ";
72 let wrapped = wrap_text(indent, indent, text, 80);
73
74 assert_eq!(wrapped, expect);
75 }
76
77 /// Helper to format the type text
78 ///
79 /// The result is a short string including important constraints, for
80 /// example ``<integer> (0 - N)``.
81 pub fn get_schema_type_text(schema: &Schema, _style: ParameterDisplayStyle) -> String {
82 match schema {
83 Schema::Null => String::from("<null>"), // should not happen
84 Schema::String(string_schema) => {
85 match string_schema {
86 StringSchema { type_text: Some(type_text), .. } => {
87 String::from(*type_text)
88 }
89 StringSchema { format: Some(ApiStringFormat::Enum(variants)), .. } => {
90 let list: Vec<String> = variants.iter().map(|e| String::from(e.value)).collect();
91 list.join("|")
92 }
93 // displaying regex add more confision than it helps
94 //StringSchema { format: Some(ApiStringFormat::Pattern(const_regex)), .. } => {
95 // format!("/{}/", const_regex.regex_string)
96 //}
97 StringSchema { format: Some(ApiStringFormat::PropertyString(sub_schema)), .. } => {
98 get_property_string_type_text(sub_schema)
99 }
100 _ => String::from("<string>")
101 }
102 }
103 Schema::Boolean(_) => String::from("<boolean>"),
104 Schema::Integer(integer_schema) => match (integer_schema.minimum, integer_schema.maximum) {
105 (Some(min), Some(max)) => format!("<integer> ({} - {})", min, max),
106 (Some(min), None) => format!("<integer> ({} - N)", min),
107 (None, Some(max)) => format!("<integer> (-N - {})", max),
108 _ => String::from("<integer>"),
109 },
110 Schema::Number(number_schema) => match (number_schema.minimum, number_schema.maximum) {
111 (Some(min), Some(max)) => format!("<number> ({} - {})", min, max),
112 (Some(min), None) => format!("<number> ({} - N)", min),
113 (None, Some(max)) => format!("<number> (-N - {})", max),
114 _ => String::from("<number>"),
115 },
116 Schema::Object(_) => String::from("<object>"),
117 Schema::Array(_) => String::from("<array>"),
118 Schema::AllOf(_) => String::from("<object>"),
119 }
120 }
121
122 /// Helper to format an object property, including name, type and description.
123 pub fn get_property_description(
124 name: &str,
125 schema: &Schema,
126 style: ParameterDisplayStyle,
127 format: DocumentationFormat,
128 ) -> String {
129 let type_text = get_schema_type_text(schema, style);
130
131 let (descr, default) = match schema {
132 Schema::Null => ("null", None),
133 Schema::String(ref schema) => (schema.description, schema.default.map(|v| v.to_owned())),
134 Schema::Boolean(ref schema) => (schema.description, schema.default.map(|v| v.to_string())),
135 Schema::Integer(ref schema) => (schema.description, schema.default.map(|v| v.to_string())),
136 Schema::Number(ref schema) => (schema.description, schema.default.map(|v| v.to_string())),
137 Schema::Object(ref schema) => (schema.description, None),
138 Schema::AllOf(ref schema) => (schema.description, None),
139 Schema::Array(ref schema) => (schema.description, None),
140 };
141
142 let default_text = match default {
143 Some(text) => format!(" (default={})", text),
144 None => String::new(),
145 };
146
147 if format == DocumentationFormat::ReST {
148 let mut text = match style {
149 ParameterDisplayStyle::Config => {
150 // reST definition list format
151 format!("``{}`` : ``{}{}``\n ", name, type_text, default_text)
152 }
153 ParameterDisplayStyle::ConfigSub => {
154 // reST definition list format
155 format!("``{}`` = ``{}{}``\n ", name, type_text, default_text)
156 }
157 ParameterDisplayStyle::Arg => {
158 // reST option list format
159 format!("``--{}`` ``{}{}``\n ", name, type_text, default_text)
160 }
161 ParameterDisplayStyle::Fixed => {
162 format!("``<{}>`` : ``{}{}``\n ", name, type_text, default_text)
163 }
164 };
165
166 text.push_str(&wrap_text("", " ", descr, 80));
167 text.push('\n');
168
169 text
170 } else {
171 let display_name = match style {
172 ParameterDisplayStyle::Config => format!("{}:", name),
173 ParameterDisplayStyle::ConfigSub => format!("{}=", name),
174 ParameterDisplayStyle::Arg => format!("--{}", name),
175 ParameterDisplayStyle::Fixed => format!("<{}>", name),
176 };
177
178 let mut text = format!(" {:-10} {}{}", display_name, type_text, default_text);
179 let indent = " ";
180 text.push('\n');
181 text.push_str(&wrap_text(indent, indent, descr, 80));
182
183 text
184 }
185 }
186
187 fn get_simply_type_text(
188 schema: &Schema,
189 list_enums: bool,
190 ) -> String {
191
192 match schema {
193 Schema::Null => String::from("<null>"), // should not happen
194 Schema::Boolean(_) => String::from("<1|0>"),
195 Schema::Integer(_) => String::from("<integer>"),
196 Schema::Number(_) => String::from("<number>"),
197 Schema::String(string_schema) => {
198 match string_schema {
199 StringSchema { type_text: Some(type_text), .. } => {
200 String::from(*type_text)
201 }
202 StringSchema { format: Some(ApiStringFormat::Enum(variants)), .. } => {
203 if list_enums && variants.len() <= 3 {
204 let list: Vec<String> = variants.iter().map(|e| String::from(e.value)).collect();
205 list.join("|")
206 } else {
207 String::from("<enum>")
208 }
209 }
210 _ => String::from("<string>"),
211 }
212 }
213 _ => panic!("get_simply_type_text: expected simply type"),
214 }
215 }
216
217 fn get_object_type_text(object_schema: &ObjectSchema) -> String {
218
219 let mut parts = Vec::new();
220
221 let mut add_part = |name, optional, schema| {
222 let tt = get_simply_type_text(schema, false);
223 let text = if parts.is_empty() {
224 format!("{}={}", name, tt)
225 } else {
226 format!(",{}={}", name, tt)
227 };
228 if optional {
229 parts.push(format!("[{}]", text));
230 } else {
231 parts.push(text);
232 }
233 };
234
235 // add default key first
236 if let Some(ref default_key) = object_schema.default_key {
237 let (optional, schema) = object_schema.lookup(default_key).unwrap();
238 add_part(default_key, optional, schema);
239 }
240
241 // add required keys
242 for (name, optional, schema) in object_schema.properties {
243 if *optional { continue; }
244 if let Some(ref default_key) = object_schema.default_key {
245 if name == default_key { continue; }
246 }
247 add_part(name, *optional, schema);
248 }
249
250 // add options keys
251 for (name, optional, schema) in object_schema.properties {
252 if !*optional { continue; }
253 if let Some(ref default_key) = object_schema.default_key {
254 if name == default_key { continue; }
255 }
256 add_part(name, *optional, schema);
257 }
258
259 let mut type_text = String::new();
260 type_text.push('[');
261 type_text.push_str(&parts.join(" "));
262 type_text.push(']');
263 type_text
264 }
265
266 fn get_property_string_type_text(
267 schema: &Schema,
268 ) -> String {
269
270 match schema {
271 Schema::Object(object_schema) => {
272 get_object_type_text(object_schema)
273 }
274 Schema::Array(array_schema) => {
275 let item_type = get_simply_type_text(array_schema.items, true);
276 format!("[{}, ...]", item_type)
277 }
278 _ => panic!("get_property_string_type_text: expected array or object"),
279 }
280 }
281
282 /// Generate ReST Documentaion for enumeration.
283 pub fn dump_enum_properties(schema: &Schema) -> Result<String, Error> {
284
285 let mut res = String::new();
286
287 if let Schema::String(StringSchema {
288 format: Some(ApiStringFormat::Enum(variants)), ..
289 }) = schema {
290 for item in variants.iter() {
291 res.push_str(&format!(":``{}``: ", item.value));
292 let descr = wrap_text("", " ", item.description, 80);
293 res.push_str(&descr);
294 res.push('\n');
295 }
296 return Ok(res);
297 }
298
299 bail!("dump_enum_properties failed - not an enum");
300 }
301
302 /// Generate ReST Documentaion for objects
303 pub fn dump_api_parameters<I>(
304 param: &dyn ObjectSchemaType<PropertyIter = I>,
305 indent: &str,
306 style: ParameterDisplayStyle,
307 skip: &[&str],
308 ) -> String
309 where I: Iterator<Item = &'static SchemaPropertyEntry>,
310 {
311 let mut res = wrap_text(indent, indent, param.description(), 80);
312
313 let next_indent = format!(" {}", indent);
314
315 let mut required_list: Vec<String> = Vec::new();
316 let mut optional_list: Vec<String> = Vec::new();
317
318 for (prop, optional, schema) in param.properties() {
319
320 if skip.iter().find(|n| n == &prop).is_some() { continue; }
321
322 let mut param_descr = get_property_description(
323 prop,
324 &schema,
325 style,
326 DocumentationFormat::ReST,
327 );
328
329 if !indent.is_empty() {
330 param_descr = format!("{}{}", indent, param_descr); // indent first line
331 param_descr = param_descr.replace("\n", &format!("\n{}", indent)); // indent rest
332 }
333
334 if style == ParameterDisplayStyle::Config {
335 match schema {
336 Schema::String(StringSchema { format: Some(ApiStringFormat::PropertyString(sub_schema)), .. }) => {
337 match sub_schema {
338 Schema::Object(object_schema) => {
339 let sub_text = dump_api_parameters(
340 object_schema, &next_indent, ParameterDisplayStyle::ConfigSub, &[]);
341 param_descr.push_str(&sub_text);
342 }
343 Schema::Array(_) => {
344 // do nothing - description should explain the list type
345 }
346 _ => unreachable!(),
347 }
348 }
349 _ => { /* do nothing */ }
350 }
351 }
352 if *optional {
353 optional_list.push(param_descr);
354 } else {
355 required_list.push(param_descr);
356 }
357 }
358
359 if !required_list.is_empty() {
360 if style != ParameterDisplayStyle::ConfigSub {
361 res.push_str("\n*Required properties:*\n\n");
362 }
363
364 for text in required_list {
365 res.push_str(&text);
366 res.push('\n');
367 }
368 }
369
370 if !optional_list.is_empty() {
371 if style != ParameterDisplayStyle::ConfigSub {
372 res.push_str("\n*Optional properties:*\n\n");
373 }
374
375 for text in optional_list {
376 res.push_str(&text);
377 res.push('\n');
378 }
379 }
380
381 res
382 }
383
384 fn dump_api_return_schema(
385 returns: &ReturnType,
386 style: ParameterDisplayStyle,
387 ) -> String {
388 let schema = &returns.schema;
389
390 let mut res = if returns.optional {
391 "*Returns* (optionally): ".to_string()
392 } else {
393 "*Returns*: ".to_string()
394 };
395
396 let type_text = get_schema_type_text(schema, style);
397 res.push_str(&format!("**{}**\n\n", type_text));
398
399 match schema {
400 Schema::Null => {
401 return res;
402 }
403 Schema::Boolean(schema) => {
404 let description = wrap_text("", "", schema.description, 80);
405 res.push_str(&description);
406 }
407 Schema::Integer(schema) => {
408 let description = wrap_text("", "", schema.description, 80);
409 res.push_str(&description);
410 }
411 Schema::Number(schema) => {
412 let description = wrap_text("", "", schema.description, 80);
413 res.push_str(&description);
414 }
415 Schema::String(schema) => {
416 let description = wrap_text("", "", schema.description, 80);
417 res.push_str(&description);
418 }
419 Schema::Array(schema) => {
420 let description = wrap_text("", "", schema.description, 80);
421 res.push_str(&description);
422 }
423 Schema::Object(obj_schema) => {
424 res.push_str(&dump_api_parameters(obj_schema, "", style, &[]));
425 }
426 Schema::AllOf(all_of_schema) => {
427 res.push_str(&dump_api_parameters(all_of_schema, "", style, &[]));
428 }
429 }
430
431 res.push('\n');
432
433 res
434 }
435
436 fn dump_method_definition(method: &str, path: &str, def: Option<&ApiMethod>) -> Option<String> {
437 let style = ParameterDisplayStyle::Config;
438 match def {
439 None => None,
440 Some(api_method) => {
441 let param_descr = dump_api_parameters(&api_method.parameters, "", style, &[]);
442
443 let return_descr = dump_api_return_schema(&api_method.returns, style);
444
445 let mut method = method;
446
447 if let ApiHandler::AsyncHttp(_) = api_method.handler {
448 method = if method == "POST" { "UPLOAD" } else { method };
449 method = if method == "GET" { "DOWNLOAD" } else { method };
450 }
451
452 let res = format!(
453 "**{} {}**\n\n{}\n\n{}",
454 method, path, param_descr, return_descr
455 );
456 Some(res)
457 }
458 }
459 }
460
461 /// Generate ReST Documentaion for a complete API defined by a ``Router``.
462 pub fn dump_api(
463 output: &mut dyn Write,
464 router: &crate::api::Router,
465 path: &str,
466 mut pos: usize,
467 ) -> Result<(), Error> {
468 use crate::api::SubRoute;
469
470 let mut cond_print = |x| -> Result<_, Error> {
471 if let Some(text) = x {
472 if pos > 0 {
473 writeln!(output, "-----\n")?;
474 }
475 writeln!(output, "{}", text)?;
476 pos += 1;
477 }
478 Ok(())
479 };
480
481 cond_print(dump_method_definition("GET", path, router.get))?;
482 cond_print(dump_method_definition("POST", path, router.post))?;
483 cond_print(dump_method_definition("PUT", path, router.put))?;
484 cond_print(dump_method_definition("DELETE", path, router.delete))?;
485
486 match &router.subroute {
487 None => return Ok(()),
488 Some(SubRoute::MatchAll { router, param_name }) => {
489 let sub_path = if path == "." {
490 format!("<{}>", param_name)
491 } else {
492 format!("{}/<{}>", path, param_name)
493 };
494 dump_api(output, router, &sub_path, pos)?;
495 }
496 Some(SubRoute::Map(dirmap)) => {
497 //let mut keys: Vec<&String> = map.keys().collect();
498 //keys.sort_unstable_by(|a, b| a.cmp(b));
499 for (key, sub_router) in dirmap.iter() {
500 let sub_path = if path == "." {
501 (*key).to_string()
502 } else {
503 format!("{}/{}", path, key)
504 };
505 dump_api(output, sub_router, &sub_path, pos)?;
506 }
507 }
508 }
509
510 Ok(())
511 }