Skip to main content

pagesift_api/
fields.rs

1//! 请求体里的字段名:哪些是某个类型认得的,哪些没人认。
2//!
3//! serde 派生的 `Deserialize` 会把结构体的全部字段名(重命名之后的)交给
4//! `Deserializer::deserialize_struct`。这里写一个只做这一件事的 Deserializer:收下字段表就报错返回,
5//! 不真的解析任何东西。这样字段表和结构体定义永远一致,不用手写第二份。
6//!
7//! 用法有两种:
8//! - pagesift 自己的入口(MCP、Python、Node 的 JSON 形参)用 [`reject_unknown`]:有未知字段就拒收,
9//!   错误里列出字段名;
10//! - 兼容 Firecrawl 的 `/v1`、`/v2` 用 [`unknown`]:照常接收,把没人认的字段名写进
11//!   `warnings`(代码 `ignored_fields`)。
12
13use std::fmt;
14
15use serde::de::{self, Deserialize, Deserializer, Visitor};
16use serde_json::{Map, Value};
17
18use crate::error::ApiError;
19
20/// 警告代码:请求里有字段被忽略(兼容接口照常接收,但如实列出)。
21pub const IGNORED_FIELDS: &str = "ignored_fields";
22
23/// 客户端自报身份的字段(Firecrawl SDK 每次都带):不是选项,不影响结果,也不算“被忽略”。
24pub const CLIENT_IDENTITY: &[&str] = &["origin", "integration"];
25
26#[derive(Debug)]
27enum Probe {
28    Fields(&'static [&'static str]),
29    Other,
30}
31
32impl fmt::Display for Probe {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str("field probe")
35    }
36}
37
38impl std::error::Error for Probe {}
39
40impl de::Error for Probe {
41    fn custom<T: fmt::Display>(_: T) -> Self {
42        Probe::Other
43    }
44}
45
46struct FieldProbe;
47
48impl<'de> Deserializer<'de> for FieldProbe {
49    type Error = Probe;
50
51    fn deserialize_any<V: Visitor<'de>>(self, _: V) -> Result<V::Value, Probe> {
52        Err(Probe::Other)
53    }
54
55    fn deserialize_struct<V: Visitor<'de>>(
56        self,
57        _name: &'static str,
58        fields: &'static [&'static str],
59        _: V,
60    ) -> Result<V::Value, Probe> {
61        Err(Probe::Fields(fields))
62    }
63
64    serde::forward_to_deserialize_any! {
65        bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string
66        bytes byte_buf option unit unit_struct newtype_struct seq tuple
67        tuple_struct map enum identifier ignored_any
68    }
69}
70
71/// `T` 认得的顶层字段名(serde 重命名之后的)。`T` 必须是不带 `flatten` 的派生结构体,
72/// 否则返回空表(带 `flatten` 的结构体走的是 map,没有字段表)。
73pub fn names<'de, T: Deserialize<'de>>() -> &'static [&'static str] {
74    match T::deserialize(FieldProbe) {
75        Err(Probe::Fields(f)) => f,
76        _ => &[],
77    }
78}
79
80/// 对象里不在 `known` 中的键(按 `serde_json::Map` 的键序,即字典序)。
81pub fn unknown(obj: &Map<String, Value>, known: &[&[&str]]) -> Vec<String> {
82    obj.keys()
83        .filter(|k| !known.iter().any(|set| set.contains(&k.as_str())))
84        .cloned()
85        .collect()
86}
87
88/// 有未知字段就拒收,错误里列出全部未知字段名。
89pub fn reject_unknown(obj: &Map<String, Value>, known: &[&[&str]]) -> Result<(), ApiError> {
90    let bad = unknown(obj, known);
91    if bad.is_empty() {
92        return Ok(());
93    }
94    Err(ApiError::invalid(format!(
95        "unknown field{} {}",
96        if bad.len() == 1 { "" } else { "s" },
97        bad.iter()
98            .map(|b| format!("`{b}`"))
99            .collect::<Vec<_>>()
100            .join(", ")
101    ))
102    .with_details(serde_json::json!({ "unknownFields": bad })))
103}
104
105/// `ignored_fields: a, b` 形式的警告;没有被忽略的字段时返回 `None`。
106pub fn ignored_warning(ignored: &[String]) -> Option<String> {
107    (!ignored.is_empty()).then(|| format!("{IGNORED_FIELDS}: {}", ignored.join(", ")))
108}
109
110/// 单页抓取请求(`url` 加 `ScrapeOptions` 的全部字段)认得的字段。
111// [推导] 三组:url、ScrapeOptions 的字段、客户端身份
112pub fn scrape_request() -> [&'static [&'static str]; 3] {
113    [
114        &["url"],
115        names::<crate::types::ScrapeOptions>(),
116        CLIENT_IDENTITY,
117    ]
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::types::{CrawlRequest, MapRequest, ScrapeOptions};
124    use serde_json::json;
125
126    #[test]
127    fn captures_renamed_field_names() {
128        let f = names::<ScrapeOptions>();
129        assert!(f.contains(&"onlyMainContent") && f.contains(&"ifNoneMatch"));
130        assert!(!f.contains(&"only_main_content"));
131        assert!(names::<CrawlRequest>().contains(&"scrapeOptions"));
132        assert!(names::<MapRequest>().contains(&"includeSubdomains"));
133    }
134
135    #[test]
136    fn batch_own_fields_match_the_struct() {
137        use crate::types::BatchScrapeRequest;
138        let b: BatchScrapeRequest = serde_json::from_value(json!({"urls": ["u"]})).unwrap();
139        let v = serde_json::to_value(&b).unwrap();
140        let mut own: Vec<&str> = v
141            .as_object()
142            .unwrap()
143            .keys()
144            .map(String::as_str)
145            .filter(|k| !names::<ScrapeOptions>().contains(k))
146            .collect();
147        own.sort_unstable();
148        let mut want = BatchScrapeRequest::OWN_FIELDS.to_vec();
149        want.sort_unstable();
150        assert_eq!(own, want);
151    }
152
153    #[test]
154    fn unknown_lists_every_stray_key() {
155        let body = json!({"url": "u", "formats": [], "zeroDataRetention": true, "origin": "sdk", "lockdown": 1});
156        let obj = body.as_object().unwrap();
157        assert_eq!(
158            unknown(obj, &scrape_request()),
159            ["lockdown", "zeroDataRetention"]
160        );
161        let err = reject_unknown(obj, &scrape_request()).unwrap_err();
162        assert!(err.message.contains("`lockdown`, `zeroDataRetention`"));
163        assert_eq!(
164            ignored_warning(&["a".into(), "b".into()]).unwrap(),
165            "ignored_fields: a, b"
166        );
167    }
168}