zai_rs/knowledge/
document_upload_url.rs1use serde::{Deserialize, Serialize};
2use validator::Validate;
3
4use super::{DocumentSliceType, types::DocumentUrlUploadResponse};
5use crate::ZaiResult;
6use crate::client::ZaiClient;
7
8#[derive(Clone, Serialize, Deserialize, Validate)]
10pub struct DocumentUrlUploadDetail {
11 #[validate(url)]
13 pub url: String,
14 pub knowledge_type: DocumentSliceType,
16 #[serde(skip_serializing_if = "Option::is_none")]
18 pub custom_separator: Option<Vec<String>>,
19 #[serde(skip_serializing_if = "Option::is_none")]
21 #[validate(range(min = 20, max = 2000))]
22 pub sentence_size: Option<u32>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 #[validate(url)]
26 pub callback_url: Option<String>,
27 #[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 pub fn new(url: impl Into<String>) -> Self {
55 Self {
56 url: url.into(),
57 knowledge_type: DocumentSliceType::TitleParagraph,
60 custom_separator: None,
61 sentence_size: None,
62 callback_url: None,
63 callback_header: None,
64 }
65 }
66 pub fn with_knowledge_type(mut self, value: DocumentSliceType) -> Self {
68 self.knowledge_type = value;
69 self
70 }
71 pub fn with_custom_separator(mut self, seps: Vec<String>) -> Self {
73 self.custom_separator = Some(seps);
74 self
75 }
76 pub fn with_sentence_size(mut self, size: u32) -> Self {
78 self.sentence_size = Some(size);
79 self
80 }
81 pub fn with_callback_url(mut self, url: impl Into<String>) -> Self {
83 self.callback_url = Some(url.into());
84 self
85 }
86 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#[derive(Clone, Serialize, Deserialize, Validate)]
116pub struct DocumentUrlUploadBody {
117 #[validate(length(min = 1))]
119 #[validate(nested)]
120 pub upload_detail: Vec<DocumentUrlUploadDetail>,
121 #[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 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 pub fn add_detail(mut self, detail: DocumentUrlUploadDetail) -> Self {
146 self.upload_detail.push(detail);
147 self
148 }
149 pub fn add_url(mut self, url: impl Into<String>) -> Self {
151 self.upload_detail.push(DocumentUrlUploadDetail::new(url));
152 self
153 }
154}
155
156pub struct DocumentUrlUploadRequest {
161 body: DocumentUrlUploadBody,
162}
163
164impl DocumentUrlUploadRequest {
165 pub fn new(body: DocumentUrlUploadBody) -> Self {
167 Self { body }
168 }
169
170 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 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}