use std::fmt;
use serde::de::{self, Deserialize, Deserializer, Visitor};
use serde_json::{Map, Value};
use crate::error::ApiError;
pub const IGNORED_FIELDS: &str = "ignored_fields";
pub const CLIENT_IDENTITY: &[&str] = &["origin", "integration"];
#[derive(Debug)]
enum Probe {
Fields(&'static [&'static str]),
Other,
}
impl fmt::Display for Probe {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("field probe")
}
}
impl std::error::Error for Probe {}
impl de::Error for Probe {
fn custom<T: fmt::Display>(_: T) -> Self {
Probe::Other
}
}
struct FieldProbe;
impl<'de> Deserializer<'de> for FieldProbe {
type Error = Probe;
fn deserialize_any<V: Visitor<'de>>(self, _: V) -> Result<V::Value, Probe> {
Err(Probe::Other)
}
fn deserialize_struct<V: Visitor<'de>>(
self,
_name: &'static str,
fields: &'static [&'static str],
_: V,
) -> Result<V::Value, Probe> {
Err(Probe::Fields(fields))
}
serde::forward_to_deserialize_any! {
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string
bytes byte_buf option unit unit_struct newtype_struct seq tuple
tuple_struct map enum identifier ignored_any
}
}
pub fn names<'de, T: Deserialize<'de>>() -> &'static [&'static str] {
match T::deserialize(FieldProbe) {
Err(Probe::Fields(f)) => f,
_ => &[],
}
}
pub fn unknown(obj: &Map<String, Value>, known: &[&[&str]]) -> Vec<String> {
obj.keys()
.filter(|k| !known.iter().any(|set| set.contains(&k.as_str())))
.cloned()
.collect()
}
pub fn reject_unknown(obj: &Map<String, Value>, known: &[&[&str]]) -> Result<(), ApiError> {
let bad = unknown(obj, known);
if bad.is_empty() {
return Ok(());
}
Err(ApiError::invalid(format!(
"unknown field{} {}",
if bad.len() == 1 { "" } else { "s" },
bad.iter()
.map(|b| format!("`{b}`"))
.collect::<Vec<_>>()
.join(", ")
))
.with_details(serde_json::json!({ "unknownFields": bad })))
}
pub fn ignored_warning(ignored: &[String]) -> Option<String> {
(!ignored.is_empty()).then(|| format!("{IGNORED_FIELDS}: {}", ignored.join(", ")))
}
pub fn scrape_request() -> [&'static [&'static str]; 3] {
[
&["url"],
names::<crate::types::ScrapeOptions>(),
CLIENT_IDENTITY,
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CrawlRequest, MapRequest, ScrapeOptions};
use serde_json::json;
#[test]
fn captures_renamed_field_names() {
let f = names::<ScrapeOptions>();
assert!(f.contains(&"onlyMainContent") && f.contains(&"ifNoneMatch"));
assert!(!f.contains(&"only_main_content"));
assert!(names::<CrawlRequest>().contains(&"scrapeOptions"));
assert!(names::<MapRequest>().contains(&"includeSubdomains"));
}
#[test]
fn batch_own_fields_match_the_struct() {
use crate::types::BatchScrapeRequest;
let b: BatchScrapeRequest = serde_json::from_value(json!({"urls": ["u"]})).unwrap();
let v = serde_json::to_value(&b).unwrap();
let mut own: Vec<&str> = v
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.filter(|k| !names::<ScrapeOptions>().contains(k))
.collect();
own.sort_unstable();
let mut want = BatchScrapeRequest::OWN_FIELDS.to_vec();
want.sort_unstable();
assert_eq!(own, want);
}
#[test]
fn unknown_lists_every_stray_key() {
let body = json!({"url": "u", "formats": [], "zeroDataRetention": true, "origin": "sdk", "lockdown": 1});
let obj = body.as_object().unwrap();
assert_eq!(
unknown(obj, &scrape_request()),
["lockdown", "zeroDataRetention"]
);
let err = reject_unknown(obj, &scrape_request()).unwrap_err();
assert!(err.message.contains("`lockdown`, `zeroDataRetention`"));
assert_eq!(
ignored_warning(&["a".into(), "b".into()]).unwrap(),
"ignored_fields: a, b"
);
}
}