1use std::collections::BTreeSet;
10use std::path::Path;
11
12use serde_json::Value;
13
14use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
15use crate::document::html::{HtmlBlock, render_blocks_to_pages};
16use crate::error::{Error, Result};
17use crate::table::{TableAlign, TableData};
18
19const MAX_OPENAPI_BYTES: u64 = 64 * 1024 * 1024;
20const MAX_OPENAPI_YAML_BYTES: u64 = 16 * 1024 * 1024;
21const MAX_OPENAPI_DEPTH: usize = 100;
22const MAX_OPENAPI_VALUES: usize = 300_000;
23const MAX_OPENAPI_ROWS: usize = 200_000;
24const MAX_OPENAPI_TEXT_BYTES: usize = 64 * 1024 * 1024;
25const MAX_OPENAPI_STRING_BYTES: usize = 2 * 1024 * 1024;
26const HTTP_METHODS: &[&str] = &[
27 "get", "put", "post", "delete", "options", "head", "patch", "trace", "connect",
28];
29
30pub(crate) fn looks_like_json_prefix(prefix: &[u8]) -> bool {
31 let text = String::from_utf8_lossy(prefix);
32 let trimmed = text.trim_start_matches('\u{feff}').trim_start();
33 if !trimmed.starts_with('{') {
34 return false;
35 }
36 has_version_key(trimmed)
37}
38
39pub(crate) fn looks_like_yaml_prefix(prefix: &[u8]) -> bool {
40 let Ok(text) = std::str::from_utf8(prefix) else {
41 return false;
42 };
43 text.lines().any(|line| {
44 let trimmed = line.trim();
45 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---" {
46 return false;
47 }
48 let indent = line.len() - line.trim_start().len();
49 indent == 0 && (trimmed.starts_with("openapi:") || trimmed.starts_with("swagger:"))
50 })
51}
52
53fn has_version_key(text: &str) -> bool {
54 text.lines().take(80).any(|line| {
55 let line = line.trim_start();
56 line.starts_with("\"openapi\"") || line.starts_with("\"swagger\"")
57 })
58}
59
60struct OpenApiPageSink<'a> {
61 inner: &'a mut dyn PageConsumer,
62 warnings: &'a [String],
63}
64
65impl PageConsumer for OpenApiPageSink<'_> {
66 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
67 page.source_format = "openapi".into();
68 if page.title.is_empty() {
69 page.title = "OpenAPI description".into();
70 }
71 page.description =
72 "OpenAPI metadata and operations are rendered inertly; references and servers are not resolved".into();
73 for warning in self.warnings {
74 page.warn(warning.clone());
75 }
76 self.inner.consume(page)
77 }
78}
79
80pub(crate) fn convert(
81 path: &Path,
82 options: &ConvertOptions,
83 sink: &mut dyn PageConsumer,
84) -> Result<Vec<String>> {
85 let bytes = read_limited_file(
86 path,
87 options.max_input_bytes.min(MAX_OPENAPI_BYTES),
88 "OpenAPI input",
89 )?;
90 let text = String::from_utf8(bytes)
91 .map_err(|error| Error::InvalidInput(format!("OpenAPI input must be UTF-8: {error}")))?;
92 let (table, metadata, warnings) = if text
93 .trim_start_matches('\u{feff}')
94 .trim_start()
95 .starts_with('{')
96 {
97 parse_json(&text)?
98 } else {
99 parse_yaml(&text)?
100 };
101 let mut blocks = Vec::new();
102 blocks.push(HtmlBlock::Heading {
103 level: 1,
104 text: "OpenAPI description".into(),
105 });
106 if !metadata.is_empty() {
107 blocks.push(HtmlBlock::Paragraph { text: metadata });
108 }
109 blocks.push(HtmlBlock::Table(table));
110 let mut page_sink = OpenApiPageSink {
111 inner: sink,
112 warnings: &warnings,
113 };
114 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
115 Ok(warnings)
116}
117
118fn parse_json(text: &str) -> Result<(TableData, String, Vec<String>)> {
119 if text.len() > MAX_OPENAPI_TEXT_BYTES {
120 return Err(Error::LimitExceeded(format!(
121 "OpenAPI input exceeds {MAX_OPENAPI_TEXT_BYTES} rendered text bytes"
122 )));
123 }
124 preflight_depth(text)?;
125 let value: Value = serde_json::from_str(text)
126 .map_err(|error| Error::InvalidInput(format!("invalid OpenAPI JSON: {error}")))?;
127 let mut count = 0usize;
128 count_values(&value, 0, &mut count)?;
129 summarize_value(&value)
130}
131
132fn summarize_value(value: &Value) -> Result<(TableData, String, Vec<String>)> {
133 let object = value
134 .as_object()
135 .ok_or_else(|| Error::InvalidInput("OpenAPI root must be a mapping/object".into()))?;
136 let version = string_field(object, "openapi").or_else(|| string_field(object, "swagger"));
137 let Some(version) = version else {
138 return Err(Error::InvalidInput(
139 "OpenAPI document requires an openapi or swagger version field".into(),
140 ));
141 };
142 let info = object.get("info").and_then(Value::as_object);
143 let title = info
144 .and_then(|v| string_field(v, "title"))
145 .unwrap_or_default();
146 let api_version = info
147 .and_then(|v| string_field(v, "version"))
148 .unwrap_or_default();
149 let description = info
150 .and_then(|v| string_field(v, "description"))
151 .unwrap_or_default();
152 let servers = object
153 .get("servers")
154 .and_then(Value::as_array)
155 .map(|items| {
156 items
157 .iter()
158 .filter_map(|item| item.as_object().and_then(|v| string_field(v, "url")))
159 .collect::<Vec<_>>()
160 })
161 .unwrap_or_default();
162 let security_count = object
163 .get("components")
164 .and_then(Value::as_object)
165 .and_then(|v| v.get("securitySchemes"))
166 .and_then(Value::as_object)
167 .map_or(0, |v| v.len());
168 let schema_count = object
169 .get("components")
170 .and_then(Value::as_object)
171 .and_then(|v| v.get("schemas"))
172 .and_then(Value::as_object)
173 .map_or(0, |v| v.len());
174
175 let mut warnings = vec![
176 "OpenAPI `$ref`, externalDocs, server URLs, callbacks, links, examples, and security schemes are displayed inertly and are never fetched or executed".into(),
177 ];
178 if object.contains_key("$ref") || text_contains_ref(value) {
179 warnings.push("OpenAPI reference(s) were retained as text and not resolved".into());
180 }
181 let paths = object.get("paths").and_then(Value::as_object);
182 let mut rows = Vec::new();
183 if let Some(paths) = paths {
184 for (path, item) in paths {
185 if rows.len() >= MAX_OPENAPI_ROWS {
186 return Err(Error::LimitExceeded(format!(
187 "OpenAPI preview exceeds {MAX_OPENAPI_ROWS} operations"
188 )));
189 }
190 let Some(item) = item.as_object() else {
191 continue;
192 };
193 for method in HTTP_METHODS {
194 let Some(operation) = item.get(*method).and_then(Value::as_object) else {
195 continue;
196 };
197 let operation_id = string_field(operation, "operationId").unwrap_or_default();
198 let summary = string_field(operation, "summary")
199 .or_else(|| string_field(operation, "description"))
200 .unwrap_or_default();
201 let responses = operation
202 .get("responses")
203 .and_then(Value::as_object)
204 .map(|v| v.keys().cloned().collect::<Vec<_>>().join(", "))
205 .unwrap_or_default();
206 let tags = operation
207 .get("tags")
208 .and_then(Value::as_array)
209 .map(|v| {
210 v.iter()
211 .filter_map(Value::as_str)
212 .collect::<Vec<_>>()
213 .join(", ")
214 })
215 .unwrap_or_default();
216 let parameters = operation
217 .get("parameters")
218 .and_then(Value::as_array)
219 .map_or(0, Vec::len)
220 .to_string();
221 let mut operation_label = operation_id;
222 if !tags.is_empty() {
223 operation_label.push_str(&format!(" [tags: {tags}]"));
224 }
225 if parameters != "0" {
226 operation_label.push_str(&format!(" [params: {parameters}]"));
227 }
228 rows.push(vec![
229 format!("{} {path}", method.to_ascii_uppercase()),
230 operation_label,
231 truncate_display(&summary),
232 responses,
233 ]);
234 }
235 }
236 }
237 if rows.is_empty() {
238 warnings.push("OpenAPI document contains no HTTP path operations".into());
239 }
240 let mut metadata_parts = vec![format!("Specification: {version}")];
241 if !title.is_empty() {
242 metadata_parts.push(format!("Title: {title}"));
243 }
244 if !api_version.is_empty() {
245 metadata_parts.push(format!("API version: {api_version}"));
246 }
247 if !description.is_empty() {
248 metadata_parts.push(format!("Description: {}", truncate_display(&description)));
249 }
250 if !servers.is_empty() {
251 metadata_parts.push(format!(
252 "Servers: {}",
253 servers
254 .into_iter()
255 .map(|s| truncate_display(&s))
256 .collect::<Vec<_>>()
257 .join(", ")
258 ));
259 }
260 metadata_parts.push(format!(
261 "Components: {schema_count} schema(s), {security_count} security scheme(s)"
262 ));
263 let table = TableData {
264 headers: vec![
265 "Endpoint".into(),
266 "Operation".into(),
267 "Summary".into(),
268 "Responses".into(),
269 ],
270 rows,
271 alignments: vec![TableAlign::Left; 4],
272 raw_source: String::new(),
273 };
274 Ok((table, metadata_parts.join("\n"), warnings))
275}
276
277fn parse_yaml(text: &str) -> Result<(TableData, String, Vec<String>)> {
278 if text.len() as u64 > MAX_OPENAPI_YAML_BYTES {
279 return Err(Error::LimitExceeded(format!(
280 "OpenAPI YAML exceeds {MAX_OPENAPI_YAML_BYTES} bytes"
281 )));
282 }
283 crate::document::yaml::parse_yaml_blocks(text)?;
286 let mut version = String::new();
287 let mut title = String::new();
288 let mut api_version = String::new();
289 let mut description = String::new();
290 let mut servers = Vec::new();
291 let mut rows = Vec::new();
292 let mut section = String::new();
293 let mut current_path = String::new();
294 let mut current_method = String::new();
295 let mut operation_id = String::new();
296 let mut summary = String::new();
297 let mut tags = String::new();
298 let mut responses = BTreeSet::new();
299 let mut parameter_count = 0usize;
300 let mut in_responses = false;
301 let mut pending_server = false;
302 for line in text.lines() {
303 let trimmed = line.trim();
304 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---" || trimmed == "..." {
305 continue;
306 }
307 let indent = line.len() - line.trim_start().len();
308 let Some((raw_key, raw_value)) = trimmed.split_once(':') else {
309 continue;
310 };
311 let key = raw_key.trim().trim_matches(['"', '\'']);
312 let value = scalar(raw_value);
313 if indent == 0 {
314 flush_yaml_operation(
315 &mut rows,
316 &mut current_path,
317 &mut current_method,
318 &mut operation_id,
319 &mut summary,
320 &mut responses,
321 &mut tags,
322 &mut parameter_count,
323 );
324 if key == "openapi" || key == "swagger" {
325 version = value;
326 }
327 section = key.to_owned();
328 current_path.clear();
329 current_method.clear();
330 in_responses = false;
331 continue;
332 }
333 if section == "info" && indent >= 2 {
334 match key {
335 "title" => title = value,
336 "version" => api_version = value,
337 "description" => description = value,
338 _ => {}
339 }
340 } else if section == "servers" {
341 if trimmed.starts_with("- url:") {
342 servers.push(scalar(trimmed.trim_start_matches("- url:")));
343 pending_server = false;
344 } else if key == "url" {
345 servers.push(value);
346 pending_server = false;
347 } else if trimmed.starts_with("-") && trimmed.contains("url:") {
348 pending_server = true;
349 } else if pending_server && !value.is_empty() {
350 servers.push(value);
351 pending_server = false;
352 }
353 } else if section == "paths" {
354 if indent <= 2 && key.starts_with('/') {
355 flush_yaml_operation(
356 &mut rows,
357 &mut current_path,
358 &mut current_method,
359 &mut operation_id,
360 &mut summary,
361 &mut responses,
362 &mut tags,
363 &mut parameter_count,
364 );
365 current_path = key.to_owned();
366 in_responses = false;
367 } else if indent <= 4 && HTTP_METHODS.contains(&key.to_ascii_lowercase().as_str()) {
368 flush_yaml_operation(
369 &mut rows,
370 &mut current_path,
371 &mut current_method,
372 &mut operation_id,
373 &mut summary,
374 &mut responses,
375 &mut tags,
376 &mut parameter_count,
377 );
378 current_method = key.to_ascii_uppercase();
379 in_responses = false;
380 } else if current_method.is_empty() {
381 continue;
382 } else if key == "responses" {
383 in_responses = true;
384 } else if in_responses && is_response_key(key) {
385 responses.insert(key.to_owned());
386 } else if key == "operationId" {
387 operation_id = value;
388 } else if key == "summary" || key == "description" {
389 if summary.is_empty() {
390 summary = value;
391 }
392 } else if key == "tags" {
393 tags = value;
394 } else if key == "parameters" && trimmed.starts_with("parameters:") {
395 parameter_count = parameter_count.saturating_add(1);
396 }
397 }
398 }
399 flush_yaml_operation(
400 &mut rows,
401 &mut current_path,
402 &mut current_method,
403 &mut operation_id,
404 &mut summary,
405 &mut responses,
406 &mut tags,
407 &mut parameter_count,
408 );
409 if version.is_empty() {
410 return Err(Error::InvalidInput(
411 "OpenAPI YAML requires an openapi or swagger version field".into(),
412 ));
413 }
414 let mut warnings = vec!["OpenAPI `$ref`, externalDocs, server URLs, callbacks, links, examples, and security schemes are displayed inertly and are never fetched or executed".into()];
415 if text.contains("$ref:") {
416 warnings.push("OpenAPI reference(s) were retained as text and not resolved".into());
417 }
418 if rows.is_empty() {
419 warnings.push("OpenAPI document contains no HTTP path operations".into());
420 }
421 let metadata = format!(
422 "Specification: {version}{}{}{}{}",
423 if title.is_empty() {
424 String::new()
425 } else {
426 format!("\nTitle: {title}")
427 },
428 if api_version.is_empty() {
429 String::new()
430 } else {
431 format!("\nAPI version: {api_version}")
432 },
433 if description.is_empty() {
434 String::new()
435 } else {
436 format!("\nDescription: {}", truncate_display(&description))
437 },
438 if servers.is_empty() {
439 String::new()
440 } else {
441 format!(
442 "\nServers: {}",
443 servers
444 .into_iter()
445 .map(|s| truncate_display(&s))
446 .collect::<Vec<_>>()
447 .join(", ")
448 )
449 }
450 );
451 Ok((
452 TableData {
453 headers: vec![
454 "Endpoint".into(),
455 "Operation".into(),
456 "Summary".into(),
457 "Responses".into(),
458 ],
459 rows,
460 alignments: vec![TableAlign::Left; 4],
461 raw_source: String::new(),
462 },
463 metadata,
464 warnings,
465 ))
466}
467
468#[allow(clippy::ptr_arg, clippy::too_many_arguments)]
469fn flush_yaml_operation(
470 rows: &mut Vec<Vec<String>>,
471 path: &mut String,
472 method: &mut String,
473 operation_id: &mut String,
474 summary: &mut String,
475 responses: &mut BTreeSet<String>,
476 tags: &mut String,
477 parameter_count: &mut usize,
478) {
479 if path.is_empty() || method.is_empty() {
480 return;
481 }
482 if rows.len() < MAX_OPENAPI_ROWS {
483 rows.push(vec![
484 format!("{method} {path}"),
485 {
486 let mut label = operation_id.clone();
487 if !tags.is_empty() {
488 label.push_str(&format!(" [tags: {tags}]"));
489 }
490 if *parameter_count > 0 {
491 label.push_str(&format!(" [params: {}]", *parameter_count));
492 }
493 label
494 },
495 truncate_display(summary),
496 responses.iter().cloned().collect::<Vec<_>>().join(", "),
497 ]);
498 }
499 method.clear();
500 operation_id.clear();
501 summary.clear();
502 responses.clear();
503 tags.clear();
504 *parameter_count = 0;
505}
506
507fn scalar(value: &str) -> String {
508 let value = value.trim();
509 let value = value.split_once(" #").map_or(value, |(v, _)| v).trim();
510 value.trim_matches(['"', '\'']).to_owned()
511}
512
513fn is_response_key(key: &str) -> bool {
514 key == "default"
515 || (key.len() == 3 && key.bytes().all(|byte| byte.is_ascii_digit()))
516 || (key.len() == 3 && key.ends_with("XX") && key.as_bytes()[0].is_ascii_digit())
517}
518
519fn string_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
520 object
521 .get(key)
522 .and_then(Value::as_str)
523 .map(ToOwned::to_owned)
524}
525
526fn truncate_display(value: &str) -> String {
527 if value.len() <= MAX_OPENAPI_STRING_BYTES {
528 return value.to_owned();
529 }
530 let mut end = MAX_OPENAPI_STRING_BYTES;
531 while !value.is_char_boundary(end) {
532 end -= 1;
533 }
534 format!("{}…", &value[..end])
535}
536
537fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
538 if depth > MAX_OPENAPI_DEPTH {
539 return Err(Error::LimitExceeded(format!(
540 "OpenAPI nesting exceeds {MAX_OPENAPI_DEPTH} levels"
541 )));
542 }
543 *count = count.saturating_add(1);
544 if *count > MAX_OPENAPI_VALUES {
545 return Err(Error::LimitExceeded(format!(
546 "OpenAPI document contains more than {MAX_OPENAPI_VALUES} values"
547 )));
548 }
549 match value {
550 Value::Array(values) => {
551 for item in values {
552 count_values(item, depth + 1, count)?;
553 }
554 }
555 Value::Object(map) => {
556 for item in map.values() {
557 count_values(item, depth + 1, count)?;
558 }
559 }
560 Value::String(text) if text.len() > MAX_OPENAPI_STRING_BYTES => {
561 return Err(Error::LimitExceeded(format!(
562 "OpenAPI string exceeds {MAX_OPENAPI_STRING_BYTES} bytes"
563 )));
564 }
565 _ => {}
566 }
567 Ok(())
568}
569
570fn preflight_depth(text: &str) -> Result<()> {
571 let mut depth = 0usize;
572 let mut quoted = false;
573 let mut escaped = false;
574 for byte in text.bytes() {
575 if quoted {
576 if escaped {
577 escaped = false;
578 } else if byte == b'\\' {
579 escaped = true;
580 } else if byte == b'"' {
581 quoted = false;
582 }
583 continue;
584 }
585 match byte {
586 b'"' => quoted = true,
587 b'{' | b'[' => {
588 depth += 1;
589 if depth > MAX_OPENAPI_DEPTH {
590 return Err(Error::LimitExceeded(format!(
591 "OpenAPI nesting exceeds {MAX_OPENAPI_DEPTH} levels"
592 )));
593 }
594 }
595 b'}' | b']' => depth = depth.saturating_sub(1),
596 _ => {}
597 }
598 }
599 Ok(())
600}
601
602fn text_contains_ref(value: &Value) -> bool {
603 match value {
604 Value::Object(map) => {
605 map.keys().any(|key| key == "$ref") || map.values().any(text_contains_ref)
606 }
607 Value::Array(values) => values.iter().any(text_contains_ref),
608 _ => false,
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 #[test]
617 fn previews_json_operations_and_keeps_refs_inert() {
618 let source = r#"{"openapi":"3.0.3","info":{"title":"Catalog","version":"1"},"servers":[{"url":"https://api.example.test"}],"paths":{"/pets":{"get":{"operationId":"listPets","summary":"List pets","responses":{"200":{"description":"ok"}},"parameters":[{"name":"limit"}]}}},"components":{"schemas":{"Pet":{}},"securitySchemes":{"bearer":{}}}}"#;
619 let (table, metadata, warnings) = parse_json(source).unwrap();
620 assert!(metadata.contains("Catalog"));
621 assert_eq!(table.rows[0][0], "GET /pets");
622 assert!(table.rows[0][1].contains("listPets"));
623 assert!(
624 warnings
625 .iter()
626 .any(|warning| warning.contains("never fetched"))
627 );
628 }
629
630 #[test]
631 fn previews_yaml_operations() {
632 let source = "openapi: 3.1.0\ninfo:\n title: Catalog\n version: '2'\npaths:\n /pets:\n get:\n operationId: listPets\n summary: List pets\n responses:\n '200':\n description: ok\n";
633 let (table, metadata, _) = parse_yaml(source).unwrap();
634 assert!(metadata.contains("Catalog"));
635 assert_eq!(table.rows[0][0], "GET /pets");
636 assert!(table.rows[0][1].contains("listPets"));
637 assert!(table.rows[0][3].contains("200"));
638 }
639
640 #[test]
641 fn rejects_non_openapi_json() {
642 assert!(parse_json("{\"title\":\"ordinary\"}").is_err());
643 }
644}