Skip to main content

document_svg/document/
openapi.rs

1//! Bounded OpenAPI/Swagger description previews.
2//!
3//! OpenAPI documents are interface descriptions, not executable API clients.
4//! This adapter renders the document metadata and operation inventory as an
5//! inert table. Servers, external documentation, examples, and `$ref` values
6//! are displayed as text; no URL, reference, callback, or security scheme is
7//! fetched or executed.
8
9use 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    // Parse with the bounded YAML event preview first. This validates aliases,
284    // tags, depth, line length, and document limits without expanding aliases.
285    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}