Skip to main content

document_svg/document/
jsonapi.rs

1//! Bounded JSON:API 1.1 resource-document previews.
2//!
3//! JSON:API documents contain primary data, optional included resources,
4//! relationships, links and metadata. This adapter renders resource type/id and
5//! structural counts only; attributes, link values and meta/error payloads are
6//! omitted and no API endpoint is contacted.
7
8use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_JSONAPI_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_JSONAPI_DEPTH: usize = 100;
18const MAX_JSONAPI_VALUES: usize = 300_000;
19const MAX_JSONAPI_RESOURCES: usize = 100_000;
20const MAX_JSONAPI_ROWS: usize = 200_000;
21const MAX_JSONAPI_STRING_BYTES: usize = 2 * 1024 * 1024;
22const MAX_JSONAPI_DISPLAY_BYTES: usize = 512;
23
24pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
25    let text = String::from_utf8_lossy(prefix);
26    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
27    if !trimmed.starts_with('{') {
28        return false;
29    }
30    let Ok(Value::Object(object)) = serde_json::from_str::<Value>(trimmed) else {
31        return text.contains("\"data\"")
32            && (text.contains("\"relationships\"") || text.contains("\"included\""));
33    };
34    let Some(data) = object.get("data") else {
35        return object
36            .get("errors")
37            .and_then(Value::as_array)
38            .is_some_and(|errors| !errors.is_empty());
39    };
40    let valid_resource = |resource: &serde_json::Map<String, Value>| {
41        resource.get("type").and_then(Value::as_str).is_some()
42            && (resource.get("id").and_then(Value::as_str).is_some()
43                || resource.get("lid").and_then(Value::as_str).is_some())
44    };
45    data.as_object().is_some_and(valid_resource)
46        || data.as_array().is_some_and(|items| {
47            items
48                .iter()
49                .any(|item| item.as_object().is_some_and(valid_resource))
50        })
51}
52
53struct JsonApiPageSink<'a> {
54    inner: &'a mut dyn PageConsumer,
55    warnings: &'a [String],
56}
57
58impl PageConsumer for JsonApiPageSink<'_> {
59    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
60        page.source_format = "json-api".into();
61        if page.title.is_empty() {
62            page.title = "JSON:API 1.1".into();
63        }
64        page.description =
65            "JSON:API resource identifiers and relationship structure are rendered inertly; attribute values, links and API operations are not displayed or resolved".into();
66        for warning in self.warnings {
67            page.warn(warning.clone());
68        }
69        self.inner.consume(page)
70    }
71}
72
73pub(crate) fn convert(
74    path: &Path,
75    options: &ConvertOptions,
76    sink: &mut dyn PageConsumer,
77) -> Result<Vec<String>> {
78    let bytes = read_limited_file(
79        path,
80        options.max_input_bytes.min(MAX_JSONAPI_BYTES),
81        "JSON:API input",
82    )?;
83    let text = String::from_utf8(bytes)
84        .map_err(|error| Error::InvalidInput(format!("JSON:API must be UTF-8 JSON: {error}")))?;
85    let (table, metadata, warnings) = parse(&text)?;
86    let blocks = vec![
87        HtmlBlock::Heading {
88            level: 1,
89            text: "JSON:API 1.1".into(),
90        },
91        HtmlBlock::Paragraph { text: metadata },
92        HtmlBlock::Table(table),
93    ];
94    let mut page_sink = JsonApiPageSink {
95        inner: sink,
96        warnings: &warnings,
97    };
98    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
99    Ok(warnings)
100}
101
102#[derive(Default)]
103struct Summary {
104    rows: Vec<Vec<String>>,
105    primary: usize,
106    included: usize,
107    relationships: usize,
108    attributes: usize,
109    errors: usize,
110}
111
112fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
113    if text.len() as u64 > MAX_JSONAPI_BYTES {
114        return Err(Error::LimitExceeded(format!(
115            "JSON:API exceeds {MAX_JSONAPI_BYTES} bytes"
116        )));
117    }
118    preflight_depth(text)?;
119    let value: Value = serde_json::from_str(text)
120        .map_err(|error| Error::InvalidInput(format!("invalid JSON:API: {error}")))?;
121    let mut values = 0usize;
122    count_values(&value, 0, &mut values)?;
123    let root = value
124        .as_object()
125        .ok_or_else(|| Error::InvalidInput("JSON:API root must be an object".into()))?;
126    let primary_count = root.get("data").map_or(0, |data| match data {
127        Value::Array(items) => items.len(),
128        Value::Null => 0,
129        _ => 1,
130    });
131    let included_count = root
132        .get("included")
133        .and_then(Value::as_array)
134        .map_or(0, Vec::len);
135    if primary_count.saturating_add(included_count) > MAX_JSONAPI_RESOURCES {
136        return Err(Error::LimitExceeded(format!(
137            "JSON:API resources exceed {MAX_JSONAPI_RESOURCES}"
138        )));
139    }
140    if root.contains_key("data") && root.contains_key("errors") {
141        return Err(Error::InvalidInput(
142            "JSON:API data and errors members must not coexist".into(),
143        ));
144    }
145    if root.contains_key("included") && !root.contains_key("data") {
146        return Err(Error::InvalidInput(
147            "JSON:API included requires a data member".into(),
148        ));
149    }
150    for key in ["jsonapi", "meta", "links"] {
151        if let Some(value) = root.get(key)
152            && !value.is_object()
153        {
154            return Err(Error::InvalidInput(format!(
155                "JSON:API top-level {key} must be an object"
156            )));
157        }
158    }
159    let mut summary = Summary::default();
160    let mut warnings = Vec::new();
161    if let Some(data) = root.get("data") {
162        match data {
163            Value::Null => {}
164            Value::Object(_) => {
165                summary.primary = 1;
166                add_resource(data, "primary", &mut summary)?;
167            }
168            Value::Array(items) => {
169                summary.primary = items.len();
170                for item in items {
171                    add_resource(item, "primary", &mut summary)?;
172                }
173            }
174            _ => {
175                return Err(Error::InvalidInput(
176                    "JSON:API data must be an object, array or null".into(),
177                ));
178            }
179        }
180    }
181    if let Some(included) = root.get("included") {
182        let items = included
183            .as_array()
184            .ok_or_else(|| Error::InvalidInput("JSON:API included must be an array".into()))?;
185        summary.included = items.len();
186        for item in items {
187            add_resource(item, "included", &mut summary)?;
188        }
189    }
190    if let Some(errors) = root.get("errors") {
191        let errors = errors
192            .as_array()
193            .ok_or_else(|| Error::InvalidInput("JSON:API errors must be an array".into()))?;
194        summary.errors = errors.len();
195        if summary.primary == 0 && summary.included == 0 && errors.is_empty() {
196            return Err(Error::InvalidInput(
197                "JSON:API errors must not be empty".into(),
198            ));
199        }
200    }
201    if summary.rows.is_empty() && summary.errors > 0 {
202        summary.rows.push(vec![
203            "errors".into(),
204            "—".into(),
205            "—".into(),
206            "—".into(),
207            format!("{} error objects", summary.errors),
208        ]);
209    }
210    if summary.rows.is_empty() && root.contains_key("data") {
211        summary.rows.push(vec![
212            "primary".into(),
213            "—".into(),
214            "—".into(),
215            "0".into(),
216            "no primary resources".into(),
217        ]);
218    }
219    if summary.rows.is_empty() {
220        if root.get("meta").is_none() {
221            return Err(Error::InvalidInput(
222                "JSON:API requires data, errors, meta or an extension member".into(),
223            ));
224        }
225        summary.rows.push(vec![
226            "meta".into(),
227            "—".into(),
228            "—".into(),
229            "—".into(),
230            "metadata omitted".into(),
231        ]);
232    }
233    let metadata = format!(
234        "Primary resources: {}\nIncluded resources: {}\nRelationships: {}\nAttribute keys: {}\nErrors: {}\nTop-level links: {}",
235        summary.primary,
236        summary.included,
237        summary.relationships,
238        summary.attributes,
239        summary.errors,
240        usize::from(root.contains_key("links"))
241    );
242    warnings.push("JSON:API attributes, relationship linkage values, meta/error details, link URLs and JSON:API extensions are omitted; no endpoint, URL or API operation is accessed".into());
243    warnings.push("JSON:API resource type/id pairs are displayed as inert identifiers; compound-document linkage is counted but not dereferenced or validated against a server".into());
244    Ok((
245        TableData {
246            headers: vec![
247                "Kind".into(),
248                "Type / ID".into(),
249                "Status".into(),
250                "Attrs".into(),
251                "Relationships".into(),
252            ],
253            rows: summary.rows,
254            alignments: vec![TableAlign::Left; 5],
255            raw_source: String::new(),
256        },
257        metadata,
258        warnings,
259    ))
260}
261
262fn add_resource(value: &Value, kind: &str, summary: &mut Summary) -> Result<()> {
263    let object = value.as_object().ok_or_else(|| {
264        Error::InvalidInput(format!("JSON:API {kind} resource must be an object"))
265    })?;
266    let resource_type = required_string(object, "type", kind)?;
267    let identifier = object
268        .get("id")
269        .or_else(|| object.get("lid"))
270        .and_then(Value::as_str)
271        .ok_or_else(|| {
272            Error::InvalidInput(format!(
273                "JSON:API {kind} resource requires string id or lid"
274            ))
275        })?;
276    if identifier.is_empty() {
277        return Err(Error::InvalidInput(format!(
278            "JSON:API {kind} resource id/lid must not be empty"
279        )));
280    }
281    let attributes = object.get("attributes").map_or(Ok(0), |value| {
282        value.as_object().map(|map| map.len()).ok_or_else(|| {
283            Error::InvalidInput(format!("JSON:API {kind} attributes must be an object"))
284        })
285    })?;
286    let relationships = object.get("relationships").map_or(Ok(0), |value| {
287        value.as_object().map(|map| map.len()).ok_or_else(|| {
288            Error::InvalidInput(format!("JSON:API {kind} relationships must be an object"))
289        })
290    })?;
291    summary.attributes = summary.attributes.saturating_add(attributes);
292    summary.relationships = summary.relationships.saturating_add(relationships);
293    let status = object
294        .get("meta")
295        .and_then(Value::as_object)
296        .and_then(|meta| meta.get("status"))
297        .and_then(Value::as_str)
298        .map_or_else(|| "—".into(), truncate);
299    push_row(
300        summary,
301        vec![
302            kind.into(),
303            truncate(&format!("{resource_type}/{identifier}")),
304            status,
305            attributes.to_string(),
306            relationships.to_string(),
307        ],
308    )
309}
310
311fn required_string<'a>(
312    object: &'a serde_json::Map<String, Value>,
313    key: &str,
314    kind: &str,
315) -> Result<&'a str> {
316    let value = object
317        .get(key)
318        .and_then(Value::as_str)
319        .ok_or_else(|| Error::InvalidInput(format!("JSON:API {kind} requires string {key}")))?;
320    if value.is_empty() {
321        return Err(Error::InvalidInput(format!(
322            "JSON:API {kind} {key} must not be empty"
323        )));
324    }
325    Ok(value)
326}
327
328fn push_row(summary: &mut Summary, row: Vec<String>) -> Result<()> {
329    if summary.rows.len() >= MAX_JSONAPI_ROWS {
330        return Err(Error::LimitExceeded(format!(
331            "JSON:API rows exceed {MAX_JSONAPI_ROWS}"
332        )));
333    }
334    summary.rows.push(row);
335    Ok(())
336}
337
338fn truncate(value: &str) -> String {
339    if value.len() <= MAX_JSONAPI_DISPLAY_BYTES {
340        return value.to_owned();
341    }
342    let mut end = MAX_JSONAPI_DISPLAY_BYTES;
343    while !value.is_char_boundary(end) {
344        end -= 1;
345    }
346    format!("{}…", &value[..end])
347}
348
349fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
350    if depth > MAX_JSONAPI_DEPTH {
351        return Err(Error::LimitExceeded(format!(
352            "JSON:API nesting exceeds {MAX_JSONAPI_DEPTH} levels"
353        )));
354    }
355    *count = count.saturating_add(1);
356    if *count > MAX_JSONAPI_VALUES {
357        return Err(Error::LimitExceeded(format!(
358            "JSON:API contains more than {MAX_JSONAPI_VALUES} values"
359        )));
360    }
361    match value {
362        Value::Array(values) => {
363            for item in values {
364                count_values(item, depth + 1, count)?;
365            }
366        }
367        Value::Object(map) => {
368            for item in map.values() {
369                count_values(item, depth + 1, count)?;
370            }
371        }
372        Value::String(value) if value.len() > MAX_JSONAPI_STRING_BYTES => {
373            return Err(Error::LimitExceeded(format!(
374                "JSON:API string exceeds {MAX_JSONAPI_STRING_BYTES} bytes"
375            )));
376        }
377        _ => {}
378    }
379    Ok(())
380}
381
382fn preflight_depth(text: &str) -> Result<()> {
383    let mut depth = 0usize;
384    let mut quoted = false;
385    let mut escaped = false;
386    for byte in text.bytes() {
387        if quoted {
388            if escaped {
389                escaped = false;
390            } else if byte == b'\\' {
391                escaped = true;
392            } else if byte == b'"' {
393                quoted = false;
394            }
395            continue;
396        }
397        match byte {
398            b'"' => quoted = true,
399            b'{' | b'[' => {
400                depth += 1;
401                if depth > MAX_JSONAPI_DEPTH {
402                    return Err(Error::LimitExceeded(format!(
403                        "JSON:API nesting exceeds {MAX_JSONAPI_DEPTH} levels"
404                    )));
405                }
406            }
407            b'}' | b']' => depth = depth.saturating_sub(1),
408            _ => {}
409        }
410    }
411    Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn recognizes_json_api_resource_document() {
420        assert!(looks_like_prefix(
421            br#"{"data":{"type":"articles","id":"1","relationships":{}}}"#
422        ));
423        assert!(!looks_like_prefix(br#"{"data":{"title":"plain JSON"}}"#));
424    }
425
426    #[test]
427    fn summarizes_compound_document_without_attribute_values() {
428        let (table, metadata, warnings) = parse(
429            r#"{"jsonapi":{"version":"1.1"},"data":{"type":"articles","id":"1","attributes":{"title":"secret title"},"relationships":{"author":{"data":{"type":"people","id":"9"}}}},"included":[{"type":"people","id":"9","attributes":{"name":"private"}}],"links":{"self":"https://private.invalid/articles/1?token=secret"}}"#,
430        ).unwrap();
431        assert_eq!(table.rows.len(), 2);
432        assert!(metadata.contains("Primary resources: 1"));
433        assert!(metadata.contains("Included resources: 1"));
434        assert!(
435            !table
436                .rows
437                .iter()
438                .flatten()
439                .any(|value| value.contains("secret") || value.contains("private"))
440        );
441        assert!(
442            warnings
443                .iter()
444                .any(|warning| warning.contains("attributes"))
445        );
446    }
447
448    #[test]
449    fn accepts_errors_only_and_empty_primary_data_documents() {
450        let (errors_table, _, _) =
451            parse(r#"{"errors":[{"status":"404","title":"secret error"}]}"#).unwrap();
452        assert_eq!(errors_table.rows[0][0], "errors");
453        let (empty_table, _, _) = parse(r#"{"data":[]}"#).unwrap();
454        assert_eq!(empty_table.rows[0][0], "primary");
455    }
456}