use serde::{Deserialize, Serialize};
use validator::Validate;
use super::{DocumentSliceType, types::DocumentUrlUploadResponse};
use crate::ZaiResult;
use crate::client::ZaiClient;
#[derive(Clone, Serialize, Deserialize, Validate)]
pub struct DocumentUrlUploadDetail {
#[validate(url)]
pub url: String,
pub knowledge_type: DocumentSliceType,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_separator: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(range(min = 20, max = 2000))]
pub sentence_size: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(url)]
pub callback_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_header: Option<std::collections::BTreeMap<String, String>>,
}
impl std::fmt::Debug for DocumentUrlUploadDetail {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("DocumentUrlUploadDetail")
.field("url", &"[REDACTED]")
.field("knowledge_type", &self.knowledge_type)
.field(
"custom_separator_count",
&self.custom_separator.as_ref().map(Vec::len),
)
.field("sentence_size", &self.sentence_size)
.field("callback_url_configured", &self.callback_url.is_some())
.field(
"callback_header_entries",
&self.callback_header.as_ref().map(|headers| headers.len()),
)
.finish()
}
}
impl DocumentUrlUploadDetail {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
knowledge_type: DocumentSliceType::TitleParagraph,
custom_separator: None,
sentence_size: None,
callback_url: None,
callback_header: None,
}
}
pub fn with_knowledge_type(mut self, value: DocumentSliceType) -> Self {
self.knowledge_type = value;
self
}
pub fn with_custom_separator(mut self, seps: Vec<String>) -> Self {
self.custom_separator = Some(seps);
self
}
pub fn with_sentence_size(mut self, size: u32) -> Self {
self.sentence_size = Some(size);
self
}
pub fn with_callback_url(mut self, url: impl Into<String>) -> Self {
self.callback_url = Some(url.into());
self
}
pub fn with_callback_header(
mut self,
headers: std::collections::BTreeMap<String, String>,
) -> Self {
self.callback_header = Some(headers);
self
}
fn validate_knowledge_type(&self) -> ZaiResult<()> {
if self.custom_separator.as_ref().is_some_and(|values| {
values.is_empty() || values.iter().any(|value| value.trim().is_empty())
}) {
return Err(crate::client::validation::invalid(
"custom_separator must contain at least one non-blank value",
));
}
if (self.custom_separator.is_some() || self.sentence_size.is_some())
&& self.knowledge_type != DocumentSliceType::Custom
{
return Err(crate::client::validation::invalid(
"custom_separator and sentence_size require knowledge_type=5",
));
}
Ok(())
}
}
#[derive(Clone, Serialize, Deserialize, Validate)]
pub struct DocumentUrlUploadBody {
#[validate(length(min = 1))]
#[validate(nested)]
pub upload_detail: Vec<DocumentUrlUploadDetail>,
#[validate(length(min = 1))]
pub knowledge_id: String,
}
impl std::fmt::Debug for DocumentUrlUploadBody {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("DocumentUrlUploadBody")
.field("upload_detail_count", &self.upload_detail.len())
.field("knowledge_id", &"[REDACTED]")
.finish()
}
}
impl DocumentUrlUploadBody {
pub fn new(knowledge_id: impl Into<String>) -> Self {
Self {
upload_detail: Vec::new(),
knowledge_id: knowledge_id.into(),
}
}
pub fn add_detail(mut self, detail: DocumentUrlUploadDetail) -> Self {
self.upload_detail.push(detail);
self
}
pub fn add_url(mut self, url: impl Into<String>) -> Self {
self.upload_detail.push(DocumentUrlUploadDetail::new(url));
self
}
}
pub struct DocumentUrlUploadRequest {
body: DocumentUrlUploadBody,
}
impl DocumentUrlUploadRequest {
pub fn new(body: DocumentUrlUploadBody) -> Self {
Self { body }
}
pub fn validate(&self) -> ZaiResult<()> {
self.body.validate()?;
crate::client::validation::require_non_blank(&self.body.knowledge_id, "knowledge_id")?;
for detail in &self.body.upload_detail {
detail.validate_knowledge_type()?;
}
Ok(())
}
pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<DocumentUrlUploadResponse> {
self.validate()?;
let route = crate::client::routes::DOCUMENTS_UPLOAD_URL;
let url = client.endpoints().resolve_route(route, &[])?;
client
.send_json::<_, DocumentUrlUploadResponse>(route.method(), url, &self.body)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_required_ids_and_custom_slice_fields() {
let blank_id = DocumentUrlUploadRequest::new(
DocumentUrlUploadBody::new(" ").add_url("https://example.com/doc"),
);
assert!(blank_id.validate().is_err());
let wrong_mode =
DocumentUrlUploadRequest::new(DocumentUrlUploadBody::new("kb-1").add_detail(
DocumentUrlUploadDetail::new("https://example.com/doc").with_sentence_size(300),
));
assert!(wrong_mode.validate().is_err());
let blank_separator = DocumentUrlUploadRequest::new(
DocumentUrlUploadBody::new("kb-1").add_detail(
DocumentUrlUploadDetail::new("https://example.com/doc")
.with_knowledge_type(DocumentSliceType::Custom)
.with_custom_separator(vec![" ".to_owned()]),
),
);
assert!(blank_separator.validate().is_err());
let valid = DocumentUrlUploadRequest::new(
DocumentUrlUploadBody::new("kb-1").add_detail(
DocumentUrlUploadDetail::new("https://example.com/doc")
.with_knowledge_type(DocumentSliceType::Custom)
.with_sentence_size(300),
),
);
assert!(valid.validate().is_ok());
}
#[test]
fn every_url_detail_serializes_the_required_typed_slice_mode() {
let body = DocumentUrlUploadBody::new("kb-1").add_url("https://example.com/doc");
let value = serde_json::to_value(body).unwrap();
assert_eq!(value["upload_detail"][0]["knowledge_type"], 1);
assert!(
serde_json::from_value::<DocumentUrlUploadDetail>(serde_json::json!({
"url": "https://example.com/doc"
}))
.is_err()
);
assert!(
serde_json::from_value::<DocumentUrlUploadDetail>(serde_json::json!({
"url": "https://example.com/doc",
"knowledge_type": 4
}))
.is_err()
);
}
#[test]
fn request_debug_redacts_urls_ids_separators_and_headers() {
let detail = DocumentUrlUploadDetail::new("https://private.example/document")
.with_knowledge_type(DocumentSliceType::Custom)
.with_custom_separator(vec!["private-separator".to_owned()])
.with_callback_url("https://private.example/callback")
.with_callback_header(std::collections::BTreeMap::from([(
"Authorization".to_owned(),
"private-token".to_owned(),
)]));
let detail_debug = format!("{detail:?}");
for secret in [
"private.example",
"private-separator",
"Authorization",
"private-token",
] {
assert!(!detail_debug.contains(secret));
}
let body = DocumentUrlUploadBody::new("private-knowledge").add_detail(detail);
let body_debug = format!("{body:?}");
assert!(!body_debug.contains("private-knowledge"));
assert!(!body_debug.contains("private.example"));
assert!(body_debug.contains("upload_detail_count: 1"));
}
}