1use 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}