Skip to main content

document_svg/document/
graphql.rs

1//! Bounded GraphQL Schema Definition Language (SDL) previews.
2//!
3//! The SDL describes a type system, while resolvers and execution live outside
4//! the document. This adapter renders type definitions and field signatures as
5//! inert rows; no introspection, resolver, query, mutation, or subscription is run.
6
7use std::path::Path;
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::document::html::{HtmlBlock, render_blocks_to_pages};
11use crate::error::{Error, Result};
12use crate::table::{TableAlign, TableData};
13
14const MAX_GRAPHQL_BYTES: u64 = 64 * 1024 * 1024;
15const MAX_GRAPHQL_LINES: usize = 500_000;
16const MAX_GRAPHQL_LINE_BYTES: usize = 1024 * 1024;
17const MAX_GRAPHQL_DEFINITIONS: usize = 100_000;
18const MAX_GRAPHQL_FIELDS: usize = 300_000;
19const MAX_GRAPHQL_STRING_BYTES: usize = 2 * 1024 * 1024;
20const MAX_GRAPHQL_TEXT_BYTES: usize = 64 * 1024 * 1024;
21
22pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
23    let text = String::from_utf8_lossy(prefix);
24    text.lines().any(|line| {
25        let line = line.trim_start();
26        [
27            "type ",
28            "interface ",
29            "input ",
30            "enum ",
31            "scalar ",
32            "union ",
33            "schema {",
34            "directive @",
35        ]
36        .iter()
37        .any(|needle| line.starts_with(needle))
38    })
39}
40
41struct GraphqlPageSink<'a> {
42    inner: &'a mut dyn PageConsumer,
43    warnings: &'a [String],
44}
45
46impl PageConsumer for GraphqlPageSink<'_> {
47    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
48        page.source_format = "graphql".into();
49        if page.title.is_empty() {
50            page.title = "GraphQL schema".into();
51        }
52        page.description = "GraphQL SDL definitions are rendered inertly; resolvers and operations are not executed".into();
53        for warning in self.warnings {
54            page.warn(warning.clone());
55        }
56        self.inner.consume(page)
57    }
58}
59
60pub(crate) fn convert(
61    path: &Path,
62    options: &ConvertOptions,
63    sink: &mut dyn PageConsumer,
64) -> Result<Vec<String>> {
65    let bytes = read_limited_file(
66        path,
67        options.max_input_bytes.min(MAX_GRAPHQL_BYTES),
68        "GraphQL SDL input",
69    )?;
70    let text = String::from_utf8(bytes)
71        .map_err(|error| Error::InvalidInput(format!("GraphQL SDL must be UTF-8: {error}")))?;
72    let (table, metadata, warnings) = parse_graphql(&text)?;
73    let blocks = vec![
74        HtmlBlock::Heading {
75            level: 1,
76            text: "GraphQL schema".into(),
77        },
78        HtmlBlock::Paragraph { text: metadata },
79        HtmlBlock::Table(table),
80    ];
81    let mut page_sink = GraphqlPageSink {
82        inner: sink,
83        warnings: &warnings,
84    };
85    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
86    Ok(warnings)
87}
88
89#[derive(Default)]
90struct Definition {
91    kind: String,
92    name: String,
93    details: String,
94    fields: Vec<Vec<String>>,
95}
96
97fn parse_graphql(text: &str) -> Result<(TableData, String, Vec<String>)> {
98    if text.len() as u64 > MAX_GRAPHQL_BYTES || text.len() > MAX_GRAPHQL_TEXT_BYTES {
99        return Err(Error::LimitExceeded(format!(
100            "GraphQL SDL exceeds {MAX_GRAPHQL_BYTES} bytes"
101        )));
102    }
103    let mut definitions = 0usize;
104    let mut fields = 0usize;
105    let mut rows = Vec::new();
106    let mut current = None::<Definition>;
107    let mut brace_depth = 0usize;
108    let mut paren_depth = 0usize;
109    let mut pending = String::new();
110    let mut unsupported = 0usize;
111    for (line_number, raw) in text.lines().enumerate() {
112        if line_number >= MAX_GRAPHQL_LINES {
113            return Err(Error::LimitExceeded(format!(
114                "GraphQL SDL exceeds {MAX_GRAPHQL_LINES} lines"
115            )));
116        }
117        if raw.len() > MAX_GRAPHQL_LINE_BYTES {
118            return Err(Error::LimitExceeded(format!(
119                "GraphQL line {} exceeds {MAX_GRAPHQL_LINE_BYTES} bytes",
120                line_number + 1
121            )));
122        }
123        let line = strip_comment(raw).trim().to_owned();
124        if line.is_empty() || line.starts_with("\"\"\"") || line.starts_with('"') {
125            continue;
126        }
127        let content = if pending.is_empty() {
128            line
129        } else {
130            format!("{pending} {line}")
131        };
132        paren_depth += content
133            .chars()
134            .filter(|character| *character == '(')
135            .count();
136        paren_depth = paren_depth.saturating_sub(
137            content
138                .chars()
139                .filter(|character| *character == ')')
140                .count(),
141        );
142        if paren_depth > 0 {
143            pending = content;
144            continue;
145        }
146        pending.clear();
147        if brace_depth == 0 {
148            if let Some((kind, name, details, rest, opens, closes)) = definition_header(&content) {
149                definitions += 1;
150                if definitions > MAX_GRAPHQL_DEFINITIONS {
151                    return Err(Error::LimitExceeded(format!(
152                        "GraphQL definitions exceed {MAX_GRAPHQL_DEFINITIONS}"
153                    )));
154                }
155                let mut definition = Definition {
156                    kind,
157                    name,
158                    details,
159                    fields: Vec::new(),
160                };
161                brace_depth = opens.saturating_sub(closes);
162                if !rest.is_empty() {
163                    fields += parse_fields(&rest, &mut definition, &mut rows)?;
164                }
165                if brace_depth == 0 {
166                    flush_definition(&mut definition, &mut rows);
167                } else {
168                    current = Some(definition);
169                }
170                continue;
171            }
172            if let Some((kind, name, details)) = standalone_definition(&content) {
173                definitions += 1;
174                rows.push(vec![
175                    truncate(&name),
176                    kind,
177                    String::new(),
178                    truncate(&details),
179                ]);
180                continue;
181            }
182            if content.starts_with("extend ") || content.starts_with("schema ") {
183                unsupported += 1;
184            }
185            continue;
186        }
187        let closes = content.matches('}').count();
188        let opens = content.matches('{').count();
189        let body = content.replace(['{', '}'], " ");
190        if let Some(definition) = current.as_mut() {
191            fields += parse_fields(&body, definition, &mut rows)?;
192            brace_depth = brace_depth.saturating_add(opens).saturating_sub(closes);
193            if brace_depth == 0 {
194                let mut complete = current.take().expect("definition exists");
195                flush_definition(&mut complete, &mut rows);
196            }
197        } else {
198            brace_depth = brace_depth.saturating_add(opens).saturating_sub(closes);
199        }
200    }
201    if current.is_some() || brace_depth != 0 || paren_depth != 0 {
202        return Err(Error::InvalidInput(
203            "GraphQL SDL has an unclosed definition or argument list".into(),
204        ));
205    }
206    if rows.is_empty() {
207        return Err(Error::InvalidInput(
208            "GraphQL SDL contains no type definitions or fields".into(),
209        ));
210    }
211    let mut warnings = vec!["GraphQL SDL descriptions, directives, default values, URLs, schema extensions and custom scalars are displayed inertly; no introspection, resolver, query, mutation or subscription is executed".into()];
212    if unsupported > 0 {
213        warnings.push(format!(
214            "{unsupported} GraphQL extension/unsupported top-level line(s) were omitted"
215        ));
216    }
217    Ok((
218        TableData {
219            headers: vec![
220                "Type".into(),
221                "Kind".into(),
222                "Field".into(),
223                "Signature".into(),
224            ],
225            rows,
226            alignments: vec![TableAlign::Left; 4],
227            raw_source: String::new(),
228        },
229        format!("Definitions: {definitions}\nFields: {fields}"),
230        warnings,
231    ))
232}
233
234fn definition_header(content: &str) -> Option<(String, String, String, String, usize, usize)> {
235    let open = content.find('{');
236    let (header, rest) = open.map_or((content, ""), |index| {
237        (&content[..index], &content[index + 1..])
238    });
239    let mut tokens = header.split_whitespace();
240    let mut kind = tokens.next()?.to_owned();
241    if kind == "extend" {
242        kind = format!("extend {}", tokens.next()?);
243    }
244    if !matches!(
245        kind.as_str(),
246        "type" | "interface" | "input" | "enum" | "schema"
247    ) && !kind.starts_with("extend ")
248    {
249        return None;
250    }
251    let name = if kind == "schema" {
252        "(schema)".to_owned()
253    } else {
254        tokens.next()?.to_owned()
255    };
256    let details = tokens.collect::<Vec<_>>().join(" ");
257    Some((
258        kind,
259        name,
260        details,
261        rest.trim_end_matches('}').trim().to_owned(),
262        content.matches('{').count(),
263        content.matches('}').count(),
264    ))
265}
266
267fn standalone_definition(content: &str) -> Option<(String, String, String)> {
268    let mut tokens = content.split_whitespace();
269    let kind = tokens.next()?;
270    if kind == "scalar" || kind == "union" {
271        return Some((
272            kind.to_owned(),
273            tokens.next()?.trim_start_matches('@').to_owned(),
274            tokens.collect::<Vec<_>>().join(" "),
275        ));
276    }
277    if kind == "directive" {
278        let token = tokens.next()?.trim_start_matches('@');
279        let (name, suffix) = token
280            .split_once('(')
281            .map_or((token, ""), |(name, rest)| (name, rest));
282        let details = if suffix.is_empty() {
283            tokens.collect::<Vec<_>>().join(" ")
284        } else {
285            format!("({suffix} {}", tokens.collect::<Vec<_>>().join(" "))
286        };
287        return Some((kind.to_owned(), name.to_owned(), details));
288    }
289    None
290}
291
292#[allow(clippy::ptr_arg)]
293fn parse_fields(
294    content: &str,
295    definition: &mut Definition,
296    rows: &mut Vec<Vec<String>>,
297) -> Result<usize> {
298    let mut count = 0usize;
299    for line in content.lines() {
300        let line = line.trim().trim_matches(',');
301        if line.is_empty() || line.starts_with('@') || line.starts_with("\"\"") {
302            continue;
303        }
304        if definition.kind == "enum" {
305            let name = line.split_whitespace().next().unwrap_or_default();
306            if !is_name(name) {
307                continue;
308            }
309            definition.fields.push(vec![
310                definition.name.clone(),
311                definition.kind.clone(),
312                name.to_owned(),
313                truncate(line),
314            ]);
315            count += 1;
316            continue;
317        }
318        let Some(colon) = line.find(':') else {
319            continue;
320        };
321        let left = line[..colon].trim();
322        let right = line[colon + 1..].trim();
323        let field_name = left.split(['(', ' ', '=']).next().unwrap_or_default();
324        if !is_name(field_name) || right.is_empty() {
325            continue;
326        }
327        definition.fields.push(vec![
328            definition.name.clone(),
329            definition.kind.clone(),
330            field_name.to_owned(),
331            truncate(&format!("{left}: {right}")),
332        ]);
333        count += 1;
334        if count > MAX_GRAPHQL_FIELDS
335            || rows.len().saturating_add(definition.fields.len()) > MAX_GRAPHQL_FIELDS
336        {
337            return Err(Error::LimitExceeded(format!(
338                "GraphQL fields exceed {MAX_GRAPHQL_FIELDS}"
339            )));
340        }
341    }
342    Ok(count)
343}
344
345fn flush_definition(definition: &mut Definition, rows: &mut Vec<Vec<String>>) {
346    if definition.fields.is_empty() {
347        rows.push(vec![
348            truncate(&definition.name),
349            definition.kind.clone(),
350            String::new(),
351            truncate(&definition.details),
352        ]);
353    } else {
354        rows.append(&mut definition.fields);
355    }
356}
357
358fn is_name(value: &str) -> bool {
359    !value.is_empty()
360        && value.chars().enumerate().all(|(index, character)| {
361            character == '_'
362                || character.is_ascii_alphanumeric()
363                    && (index > 0 || character.is_ascii_alphabetic())
364        })
365}
366
367fn strip_comment(line: &str) -> &str {
368    let mut quoted = false;
369    let mut escaped = false;
370    for (index, character) in line.char_indices() {
371        if quoted {
372            if escaped {
373                escaped = false;
374            } else if character == '\\' {
375                escaped = true;
376            } else if character == '"' {
377                quoted = false;
378            }
379        } else if character == '"' {
380            quoted = true;
381        } else if character == '#' {
382            return &line[..index];
383        }
384    }
385    line
386}
387
388fn truncate(value: &str) -> String {
389    if value.len() <= MAX_GRAPHQL_STRING_BYTES {
390        return value.to_owned();
391    }
392    let mut end = MAX_GRAPHQL_STRING_BYTES;
393    while !value.is_char_boundary(end) {
394        end -= 1;
395    }
396    format!("{}…", &value[..end])
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    #[test]
403    fn previews_graphql_types_and_fields_inertly() {
404        let source = "type Query {\n  pets(limit: Int = 10): [Pet!]!\n}\ntype Pet implements Node {\n  id: ID!\n  name: String @deprecated(reason: \"old\")\n}\ninterface Node { id: ID! }\nenum Color { RED GREEN }\nscalar Date\ndirective @auth on FIELD_DEFINITION\n";
405        let (table, metadata, warnings) = parse_graphql(source).unwrap();
406        assert!(metadata.contains("Definitions"));
407        assert!(table.rows.iter().any(|row| row[2] == "pets"));
408        assert!(table.rows.iter().any(|row| row[1] == "scalar"));
409        assert!(warnings.iter().any(|warning| warning.contains("executed")));
410    }
411    #[test]
412    fn rejects_unclosed_definition() {
413        assert!(parse_graphql("type Query { hello: String\n").is_err());
414    }
415}