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