Skip to main content

zai_rs/knowledge/
document_upload_url.rs

1use serde::{Deserialize, Serialize};
2use validator::Validate;
3
4use super::{DocumentSliceType, types::DocumentUrlUploadResponse};
5use crate::ZaiResult;
6use crate::client::ZaiClient;
7
8/// Single URL upload detail
9#[derive(Clone, Serialize, Deserialize, Validate)]
10pub struct DocumentUrlUploadDetail {
11    /// Source URL to fetch
12    #[validate(url)]
13    pub url: String,
14    /// Slice type (`1`, `2`, `3`, `5`, `6`, or `7`).
15    pub knowledge_type: DocumentSliceType,
16    /// Custom separators
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub custom_separator: Option<Vec<String>>,
19    /// Custom slice size (`20..=2000`).
20    #[serde(skip_serializing_if = "Option::is_none")]
21    #[validate(range(min = 20, max = 2000))]
22    pub sentence_size: Option<u32>,
23    /// Callback URL
24    #[serde(skip_serializing_if = "Option::is_none")]
25    #[validate(url)]
26    pub callback_url: Option<String>,
27    /// Callback headers
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub callback_header: Option<std::collections::BTreeMap<String, String>>,
30}
31
32impl std::fmt::Debug for DocumentUrlUploadDetail {
33    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        formatter
35            .debug_struct("DocumentUrlUploadDetail")
36            .field("url", &"[REDACTED]")
37            .field("knowledge_type", &self.knowledge_type)
38            .field(
39                "custom_separator_count",
40                &self.custom_separator.as_ref().map(Vec::len),
41            )
42            .field("sentence_size", &self.sentence_size)
43            .field("callback_url_configured", &self.callback_url.is_some())
44            .field(
45                "callback_header_entries",
46                &self.callback_header.as_ref().map(|headers| headers.len()),
47            )
48            .finish()
49    }
50}
51
52impl DocumentUrlUploadDetail {
53    /// Create a new upload detail for the given source URL.
54    pub fn new(url: impl Into<String>) -> Self {
55        Self {
56            url: url.into(),
57            // The upstream schema requires a slice type; title/paragraph
58            // slicing is the broadest default for document URLs.
59            knowledge_type: DocumentSliceType::TitleParagraph,
60            custom_separator: None,
61            sentence_size: None,
62            callback_url: None,
63            callback_header: None,
64        }
65    }
66    /// Set the slice/knowledge type.
67    pub fn with_knowledge_type(mut self, value: DocumentSliceType) -> Self {
68        self.knowledge_type = value;
69        self
70    }
71    /// Set custom separators.
72    pub fn with_custom_separator(mut self, seps: Vec<String>) -> Self {
73        self.custom_separator = Some(seps);
74        self
75    }
76    /// Set the sentence size.
77    pub fn with_sentence_size(mut self, size: u32) -> Self {
78        self.sentence_size = Some(size);
79        self
80    }
81    /// Set the callback URL.
82    pub fn with_callback_url(mut self, url: impl Into<String>) -> Self {
83        self.callback_url = Some(url.into());
84        self
85    }
86    /// Set the callback headers.
87    pub fn with_callback_header(
88        mut self,
89        headers: std::collections::BTreeMap<String, String>,
90    ) -> Self {
91        self.callback_header = Some(headers);
92        self
93    }
94
95    fn validate_knowledge_type(&self) -> ZaiResult<()> {
96        if self.custom_separator.as_ref().is_some_and(|values| {
97            values.is_empty() || values.iter().any(|value| value.trim().is_empty())
98        }) {
99            return Err(crate::client::validation::invalid(
100                "custom_separator must contain at least one non-blank value",
101            ));
102        }
103        if (self.custom_separator.is_some() || self.sentence_size.is_some())
104            && self.knowledge_type != DocumentSliceType::Custom
105        {
106            return Err(crate::client::validation::invalid(
107                "custom_separator and sentence_size require knowledge_type=5",
108            ));
109        }
110        Ok(())
111    }
112}
113
114/// Upload URL request body
115#[derive(Clone, Serialize, Deserialize, Validate)]
116pub struct DocumentUrlUploadBody {
117    /// Upload detail list (at least 1)
118    #[validate(length(min = 1))]
119    #[validate(nested)]
120    pub upload_detail: Vec<DocumentUrlUploadDetail>,
121    /// Knowledge base id
122    #[validate(length(min = 1))]
123    pub knowledge_id: String,
124}
125
126impl std::fmt::Debug for DocumentUrlUploadBody {
127    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        formatter
129            .debug_struct("DocumentUrlUploadBody")
130            .field("upload_detail_count", &self.upload_detail.len())
131            .field("knowledge_id", &"[REDACTED]")
132            .finish()
133    }
134}
135
136impl DocumentUrlUploadBody {
137    /// Create a new upload body for the given knowledge base id.
138    pub fn new(knowledge_id: impl Into<String>) -> Self {
139        Self {
140            upload_detail: Vec::new(),
141            knowledge_id: knowledge_id.into(),
142        }
143    }
144    /// Append a fully-built [`DocumentUrlUploadDetail`].
145    pub fn add_detail(mut self, detail: DocumentUrlUploadDetail) -> Self {
146        self.upload_detail.push(detail);
147        self
148    }
149    /// Convenience: append a detail built from a single URL.
150    pub fn add_url(mut self, url: impl Into<String>) -> Self {
151        self.upload_detail.push(DocumentUrlUploadDetail::new(url));
152        self
153    }
154}
155
156/// Upload URL request (POST /llm-application/open/document/upload_url)
157///
158/// Credentials and transport live on the [`ZaiClient`], passed to
159/// [`send_via`](Self::send_via).
160pub struct DocumentUrlUploadRequest {
161    body: DocumentUrlUploadBody,
162}
163
164impl DocumentUrlUploadRequest {
165    /// Create a new upload-by-URL request with the given body.
166    pub fn new(body: DocumentUrlUploadBody) -> Self {
167        Self { body }
168    }
169
170    /// Validate identifiers, URLs, slice modes, and cross-field constraints.
171    pub fn validate(&self) -> ZaiResult<()> {
172        self.body.validate()?;
173        crate::client::validation::require_non_blank(&self.body.knowledge_id, "knowledge_id")?;
174        for detail in &self.body.upload_detail {
175            detail.validate_knowledge_type()?;
176        }
177        Ok(())
178    }
179
180    /// Validate and send via a [`ZaiClient`], returning the typed response.
181    pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<DocumentUrlUploadResponse> {
182        self.validate()?;
183        let route = crate::client::routes::DOCUMENTS_UPLOAD_URL;
184        let url = client.endpoints().resolve_route(route, &[])?;
185        client
186            .send_json::<_, DocumentUrlUploadResponse>(route.method(), url, &self.body)
187            .await
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn validates_required_ids_and_custom_slice_fields() {
197        let blank_id = DocumentUrlUploadRequest::new(
198            DocumentUrlUploadBody::new(" ").add_url("https://example.com/doc"),
199        );
200        assert!(blank_id.validate().is_err());
201
202        let wrong_mode =
203            DocumentUrlUploadRequest::new(DocumentUrlUploadBody::new("kb-1").add_detail(
204                DocumentUrlUploadDetail::new("https://example.com/doc").with_sentence_size(300),
205            ));
206        assert!(wrong_mode.validate().is_err());
207
208        let blank_separator = DocumentUrlUploadRequest::new(
209            DocumentUrlUploadBody::new("kb-1").add_detail(
210                DocumentUrlUploadDetail::new("https://example.com/doc")
211                    .with_knowledge_type(DocumentSliceType::Custom)
212                    .with_custom_separator(vec![" ".to_owned()]),
213            ),
214        );
215        assert!(blank_separator.validate().is_err());
216
217        let valid = DocumentUrlUploadRequest::new(
218            DocumentUrlUploadBody::new("kb-1").add_detail(
219                DocumentUrlUploadDetail::new("https://example.com/doc")
220                    .with_knowledge_type(DocumentSliceType::Custom)
221                    .with_sentence_size(300),
222            ),
223        );
224        assert!(valid.validate().is_ok());
225    }
226
227    #[test]
228    fn every_url_detail_serializes_the_required_typed_slice_mode() {
229        let body = DocumentUrlUploadBody::new("kb-1").add_url("https://example.com/doc");
230        let value = serde_json::to_value(body).unwrap();
231        assert_eq!(value["upload_detail"][0]["knowledge_type"], 1);
232        assert!(
233            serde_json::from_value::<DocumentUrlUploadDetail>(serde_json::json!({
234                "url": "https://example.com/doc"
235            }))
236            .is_err()
237        );
238        assert!(
239            serde_json::from_value::<DocumentUrlUploadDetail>(serde_json::json!({
240                "url": "https://example.com/doc",
241                "knowledge_type": 4
242            }))
243            .is_err()
244        );
245    }
246
247    #[test]
248    fn request_debug_redacts_urls_ids_separators_and_headers() {
249        let detail = DocumentUrlUploadDetail::new("https://private.example/document")
250            .with_knowledge_type(DocumentSliceType::Custom)
251            .with_custom_separator(vec!["private-separator".to_owned()])
252            .with_callback_url("https://private.example/callback")
253            .with_callback_header(std::collections::BTreeMap::from([(
254                "Authorization".to_owned(),
255                "private-token".to_owned(),
256            )]));
257        let detail_debug = format!("{detail:?}");
258        for secret in [
259            "private.example",
260            "private-separator",
261            "Authorization",
262            "private-token",
263        ] {
264            assert!(!detail_debug.contains(secret));
265        }
266
267        let body = DocumentUrlUploadBody::new("private-knowledge").add_detail(detail);
268        let body_debug = format!("{body:?}");
269        assert!(!body_debug.contains("private-knowledge"));
270        assert!(!body_debug.contains("private.example"));
271        assert!(body_debug.contains("upload_detail_count: 1"));
272    }
273}