1use crate::error::{ConduitError, Result};
4use crate::matching::MatchingContext;
5use crate::reports::ReportTemplate;
6use serde::Deserialize;
7use serde_json::Value;
8use time::OffsetDateTime;
9use time::format_description::well_known::Rfc3339;
10
11#[derive(Debug, Clone)]
12pub struct JobErrorData {
14 pub code: String,
16 pub message: String,
18 pub details: Option<Box<Value>>,
20 pub retryable: Option<bool>,
22}
23
24#[derive(Debug, Clone)]
25pub struct Usage {
27 pub credits_used: f64,
29 pub credits_net_used: f64,
31 pub credits_discounted: Option<f64>,
33 pub duration_ms: Option<f64>,
35 pub model_version: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum CreditReservationStatus {
42 Active,
44 Released,
46 Applied,
48}
49
50impl CreditReservationStatus {
51 fn parse(value: &str, name: &str) -> Result<Self> {
52 match value {
53 "active" => Ok(Self::Active),
54 "released" => Ok(Self::Released),
55 "applied" => Ok(Self::Applied),
56 _ => Err(ConduitError::invalid_response(format!(
57 "invalid {name}: unsupported reservation status"
58 ))),
59 }
60 }
61}
62
63#[derive(Debug, Clone)]
64pub struct JobCreditReservation {
66 pub reserved_credits: Option<f64>,
68 pub reservation_status: Option<CreditReservationStatus>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum ReceiptStatus {
75 Queued,
77 Running,
79}
80
81impl ReceiptStatus {
82 pub fn as_str(self) -> &'static str {
84 match self {
85 Self::Queued => "queued",
86 Self::Running => "running",
87 }
88 }
89
90 fn parse(value: &str, name: &str) -> Result<Self> {
91 match value {
92 "queued" => Ok(Self::Queued),
93 "running" => Ok(Self::Running),
94 _ => Err(ConduitError::invalid_response(format!(
95 "invalid {name}: unsupported receipt status"
96 ))),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum JobKind {
104 ReportGenerate,
106 MatchingGenerate,
108}
109
110impl JobKind {
111 fn parse(value: &str, name: &str) -> Result<Self> {
112 match value {
113 "report.generate" => Ok(Self::ReportGenerate),
114 "matching.generate" => Ok(Self::MatchingGenerate),
115 _ => Err(ConduitError::invalid_response(format!(
116 "invalid {name}: unsupported job type"
117 ))),
118 }
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum JobStatus {
125 Queued,
127 Running,
129 Succeeded,
131 Failed,
133 Canceled,
135}
136
137impl JobStatus {
138 pub fn as_str(self) -> &'static str {
140 match self {
141 Self::Queued => "queued",
142 Self::Running => "running",
143 Self::Succeeded => "succeeded",
144 Self::Failed => "failed",
145 Self::Canceled => "canceled",
146 }
147 }
148
149 pub fn is_terminal(self) -> bool {
151 matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
152 }
153
154 fn parse(value: &str, name: &str) -> Result<Self> {
155 match value {
156 "queued" => Ok(Self::Queued),
157 "running" => Ok(Self::Running),
158 "succeeded" => Ok(Self::Succeeded),
159 "failed" => Ok(Self::Failed),
160 "canceled" => Ok(Self::Canceled),
161 _ => Err(ConduitError::invalid_response(format!(
162 "invalid {name}: unsupported job status"
163 ))),
164 }
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum JobStage {
171 Uploaded,
173 Queued,
175 Transcoding,
177 Extracting,
179 Scoring,
181 Rendering,
183 Finalizing,
185}
186
187impl JobStage {
188 pub fn as_str(self) -> &'static str {
190 match self {
191 Self::Uploaded => "uploaded",
192 Self::Queued => "queued",
193 Self::Transcoding => "transcoding",
194 Self::Extracting => "extracting",
195 Self::Scoring => "scoring",
196 Self::Rendering => "rendering",
197 Self::Finalizing => "finalizing",
198 }
199 }
200
201 fn parse(value: &str, name: &str) -> Result<Self> {
202 match value {
203 "uploaded" => Ok(Self::Uploaded),
204 "queued" => Ok(Self::Queued),
205 "transcoding" => Ok(Self::Transcoding),
206 "extracting" => Ok(Self::Extracting),
207 "scoring" => Ok(Self::Scoring),
208 "rendering" => Ok(Self::Rendering),
209 "finalizing" => Ok(Self::Finalizing),
210 _ => Err(ConduitError::invalid_response(format!(
211 "invalid {name}: unsupported job stage"
212 ))),
213 }
214 }
215}
216
217fn parse_job_stage(value: &str) -> Option<JobStage> {
218 JobStage::parse(value, "job.stage").ok()
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum JobEventKind {
224 Status,
226 Stage,
228 Terminal,
230}
231
232impl JobEventKind {
233 pub fn as_str(self) -> &'static str {
235 match self {
236 Self::Status => "status",
237 Self::Stage => "stage",
238 Self::Terminal => "terminal",
239 }
240 }
241}
242
243#[derive(Debug, Clone)]
244pub struct Job {
246 pub id: String,
248 pub kind: JobKind,
250 pub status: JobStatus,
252 pub created_at: OffsetDateTime,
254 pub updated_at: OffsetDateTime,
256 pub stage: Option<JobStage>,
258 pub progress: Option<f64>,
260 pub report_id: Option<String>,
262 pub matching_id: Option<String>,
264 pub usage: Option<Usage>,
266 pub credits: Option<JobCreditReservation>,
268 pub released_credits: Option<f64>,
270 pub error: Option<JobErrorData>,
272 pub request_id: Option<String>,
274}
275
276#[derive(Debug, Clone)]
277pub struct JobEvent {
279 pub kind: JobEventKind,
281 pub job: Job,
283 pub stage: Option<JobStage>,
285 pub progress: Option<f64>,
287}
288
289#[derive(Debug, Clone)]
290pub struct ReportOutputData {
292 pub template: ReportTemplate,
294 pub markdown: Option<String>,
296 pub json: Option<Value>,
298 pub report_url: Option<String>,
300}
301
302#[derive(Debug, Clone)]
303pub struct Report {
305 pub id: String,
307 pub created_at: OffsetDateTime,
309 pub output: ReportOutputData,
311 pub job_id: Option<String>,
313 pub label: Option<String>,
315 pub entity_id: Option<String>,
317 pub entity_label: Option<String>,
319 pub media_id: Option<String>,
321}
322
323#[derive(Debug, Clone)]
324pub struct MatchingResolvedSubject {
326 pub source: Value,
328 pub entity_id: Option<String>,
330 pub resolved_label: Option<String>,
332}
333
334#[derive(Debug, Clone)]
335pub struct MatchingOutputData {
337 pub markdown: Option<String>,
339 pub json: Option<Value>,
341}
342
343#[derive(Debug, Clone)]
344pub struct Matching {
346 pub id: String,
348 pub created_at: OffsetDateTime,
350 pub context: MatchingContext,
352 pub output: MatchingOutputData,
354 pub job_id: Option<String>,
356 pub label: Option<String>,
358 pub target: Option<MatchingResolvedSubject>,
360 pub group: Vec<MatchingResolvedSubject>,
362}
363
364#[derive(Debug, Clone)]
365pub struct MediaObject {
367 pub media_id: String,
369 pub created_at: OffsetDateTime,
371 pub content_type: String,
373 pub label: String,
375 pub size_bytes: Option<u64>,
377 pub duration_seconds: Option<f64>,
379}
380
381#[derive(Debug, Clone)]
382pub struct MediaRetention {
384 pub expires_at: Option<OffsetDateTime>,
386 pub days_remaining: Option<u64>,
388 pub locked: bool,
390}
391
392#[derive(Debug, Clone)]
393pub struct MediaFile {
395 pub media_id: String,
397 pub created_at: OffsetDateTime,
399 pub content_type: String,
401 pub has_reports: bool,
403 pub label: String,
405 pub processing_status: String,
407 pub last_used_at: Option<OffsetDateTime>,
409 pub retention: MediaRetention,
411 pub size_bytes: Option<u64>,
413 pub duration_seconds: Option<f64>,
415}
416
417#[derive(Debug, Clone, Deserialize)]
418pub struct FileDeleteReceipt {
420 #[serde(rename = "mediaId")]
421 pub media_id: String,
423 pub deleted: bool,
425}
426
427#[derive(Debug, Clone, Deserialize)]
428pub struct RetentionLockResult {
430 #[serde(rename = "mediaId")]
431 pub media_id: String,
433 #[serde(rename = "retentionLock")]
434 pub retention_lock: bool,
436 pub message: String,
438}
439
440#[derive(Debug, Clone)]
441pub struct ListFilesResponse {
443 pub files: Vec<MediaFile>,
445 pub has_more: bool,
447 pub next_cursor: Option<String>,
449}
450
451#[derive(Debug, Clone)]
452pub struct Entity {
454 pub id: String,
456 pub created_at: OffsetDateTime,
458 pub label: Option<String>,
460 pub media_count: f64,
462 pub last_seen_at: Option<OffsetDateTime>,
464}
465
466#[derive(Debug, Clone)]
467pub struct ListEntitiesResponse {
469 pub entities: Vec<Entity>,
471 pub has_more: bool,
473 pub next_cursor: Option<String>,
475}
476
477#[derive(Debug, Clone)]
478pub(crate) struct JobReceipt {
479 pub job_id: String,
480 pub status: ReceiptStatus,
481 pub stage: Option<JobStage>,
482 pub estimated_wait_sec: Option<f64>,
483}
484
485#[derive(Debug, Deserialize)]
486struct JobWire {
487 id: String,
488 #[serde(rename = "type")]
489 kind: String,
490 status: String,
491 #[serde(rename = "createdAt")]
492 created_at: String,
493 #[serde(rename = "updatedAt")]
494 updated_at: String,
495 stage: Option<String>,
496 progress: Option<f64>,
497 #[serde(rename = "reportId")]
498 report_id: Option<String>,
499 #[serde(rename = "matchingId")]
500 matching_id: Option<String>,
501 usage: Option<UsageWire>,
502 credits: Option<JobCreditsWire>,
503 #[serde(rename = "releasedCredits")]
504 released_credits: Option<f64>,
505 error: Option<JobErrorWire>,
506 #[serde(rename = "requestId")]
507 request_id: Option<String>,
508}
509
510#[derive(Debug, Deserialize)]
511struct JobErrorWire {
512 code: String,
513 message: String,
514 details: Option<Value>,
515 retryable: Option<bool>,
516}
517
518#[derive(Debug, Deserialize)]
519struct UsageWire {
520 #[serde(rename = "creditsUsed")]
521 credits_used: f64,
522 #[serde(rename = "creditsNetUsed")]
523 credits_net_used: f64,
524 #[serde(rename = "creditsDiscounted")]
525 credits_discounted: Option<f64>,
526 #[serde(rename = "durationMs")]
527 duration_ms: Option<f64>,
528 #[serde(rename = "modelVersion")]
529 model_version: Option<String>,
530}
531
532#[derive(Debug, Deserialize)]
533struct JobCreditsWire {
534 #[serde(rename = "reservedCredits")]
535 reserved_credits: Option<f64>,
536 #[serde(rename = "reservationStatus")]
537 reservation_status: Option<String>,
538}
539
540#[derive(Debug, Deserialize)]
541struct JobReceiptWire {
542 #[serde(rename = "jobId")]
543 job_id: String,
544 status: String,
545 stage: Option<String>,
546 #[serde(rename = "estimatedWaitSec")]
547 estimated_wait_sec: Option<f64>,
548}
549
550#[derive(Debug, Deserialize)]
551struct ReportWire {
552 id: String,
553 #[serde(rename = "createdAt")]
554 created_at: String,
555 #[serde(rename = "jobId")]
556 job_id: Option<String>,
557 label: Option<String>,
558 entity: Option<ReportEntityWire>,
559 media: Option<ReportMediaWire>,
560 output: ReportOutputWire,
561 markdown: Option<String>,
562 json: Option<Value>,
563}
564
565#[derive(Debug, Deserialize)]
566struct ReportEntityWire {
567 id: String,
568 label: Option<String>,
569}
570
571#[derive(Debug, Deserialize)]
572struct ReportMediaWire {
573 #[serde(rename = "mediaId")]
574 media_id: Option<String>,
575 url: Option<String>,
576}
577
578#[derive(Debug, Deserialize)]
579struct ReportOutputWire {
580 template: String,
581}
582
583#[derive(Debug, Deserialize)]
584struct MatchingWire {
585 id: String,
586 #[serde(rename = "createdAt")]
587 created_at: String,
588 context: String,
589 #[serde(rename = "jobId")]
590 job_id: Option<String>,
591 label: Option<String>,
592 target: Option<MatchingSubjectWire>,
593 group: Option<Vec<MatchingSubjectWire>>,
594 markdown: Option<String>,
595 json: Option<Value>,
596}
597
598#[derive(Debug, Deserialize)]
599struct MatchingSubjectWire {
600 source: Value,
601 #[serde(rename = "entityId")]
602 entity_id: Option<String>,
603 #[serde(rename = "resolvedLabel")]
604 resolved_label: Option<String>,
605}
606
607#[derive(Debug, Deserialize)]
608struct MediaObjectWire {
609 #[serde(rename = "mediaId")]
610 media_id: String,
611 #[serde(rename = "createdAt")]
612 created_at: String,
613 #[serde(rename = "contentType")]
614 content_type: String,
615 label: String,
616 #[serde(rename = "sizeBytes")]
617 size_bytes: Option<u64>,
618 #[serde(rename = "durationSeconds")]
619 duration_seconds: Option<f64>,
620}
621
622#[derive(Debug, Deserialize)]
623struct MediaFileWire {
624 #[serde(rename = "mediaId")]
625 media_id: String,
626 #[serde(rename = "createdAt")]
627 created_at: String,
628 #[serde(rename = "contentType")]
629 content_type: String,
630 #[serde(rename = "hasReports")]
631 has_reports: bool,
632 label: String,
633 #[serde(rename = "processingStatus")]
634 processing_status: String,
635 #[serde(rename = "lastUsedAt")]
636 last_used_at: Option<String>,
637 retention: MediaRetentionWire,
638 #[serde(rename = "sizeBytes")]
639 size_bytes: Option<u64>,
640 #[serde(rename = "durationSeconds")]
641 duration_seconds: Option<f64>,
642}
643
644#[derive(Debug, Deserialize)]
645struct MediaRetentionWire {
646 #[serde(rename = "expiresAt")]
647 expires_at: Option<String>,
648 #[serde(rename = "daysRemaining")]
649 days_remaining: Option<u64>,
650 locked: bool,
651}
652
653#[derive(Debug, Deserialize)]
654struct ListFilesWire {
655 files: Vec<MediaFileWire>,
656 #[serde(rename = "hasMore")]
657 has_more: bool,
658 #[serde(rename = "nextCursor")]
659 next_cursor: Option<String>,
660}
661
662#[derive(Debug, Deserialize)]
663struct EntityWire {
664 id: String,
665 #[serde(rename = "createdAt")]
666 created_at: String,
667 label: Option<String>,
668 #[serde(rename = "mediaCount")]
669 media_count: f64,
670 #[serde(rename = "lastSeenAt")]
671 last_seen_at: Option<String>,
672}
673
674#[derive(Debug, Deserialize)]
675struct ListEntitiesWire {
676 entities: Vec<EntityWire>,
677 #[serde(rename = "hasMore")]
678 has_more: bool,
679 cursor: Option<String>,
680}
681
682pub(crate) fn parse_job(payload: &[u8]) -> Result<Job> {
683 let wire: JobWire = decode_json(payload)?;
684 Ok(Job {
685 id: response_string(&wire.id, "job.id")?,
686 kind: JobKind::parse(&wire.kind, "job.type")?,
687 status: JobStatus::parse(&wire.status, "job.status")?,
688 created_at: response_datetime(&wire.created_at, "job.createdAt")?,
689 updated_at: response_datetime(&wire.updated_at, "job.updatedAt")?,
690 stage: wire
691 .stage
692 .as_deref()
693 .filter(|value| !value.trim().is_empty())
694 .and_then(parse_job_stage),
695 progress: wire.progress,
696 report_id: wire.report_id.filter(|value| !value.trim().is_empty()),
697 matching_id: wire.matching_id.filter(|value| !value.trim().is_empty()),
698 usage: wire.usage.map(|usage| Usage {
699 credits_used: usage.credits_used,
700 credits_net_used: usage.credits_net_used,
701 credits_discounted: usage.credits_discounted,
702 duration_ms: usage.duration_ms,
703 model_version: usage.model_version.filter(|value| !value.trim().is_empty()),
704 }),
705 credits: wire
706 .credits
707 .map(|credits| -> Result<JobCreditReservation> {
708 Ok(JobCreditReservation {
709 reserved_credits: credits.reserved_credits,
710 reservation_status: credits
711 .reservation_status
712 .as_deref()
713 .filter(|value| !value.trim().is_empty())
714 .map(|value| {
715 CreditReservationStatus::parse(value, "job.credits.reservationStatus")
716 })
717 .transpose()?,
718 })
719 })
720 .transpose()?,
721 released_credits: wire.released_credits,
722 error: wire.error.map(|error| JobErrorData {
723 code: error.code,
724 message: error.message,
725 details: error.details.map(Box::new),
726 retryable: error.retryable,
727 }),
728 request_id: wire.request_id.filter(|value| !value.trim().is_empty()),
729 })
730}
731
732pub(crate) fn parse_job_receipt(payload: &[u8]) -> Result<JobReceipt> {
733 let wire: JobReceiptWire = decode_json(payload)?;
734 Ok(JobReceipt {
735 job_id: response_string(&wire.job_id, "jobReceipt.jobId")?,
736 status: ReceiptStatus::parse(&wire.status, "jobReceipt.status")?,
737 stage: wire
738 .stage
739 .as_deref()
740 .filter(|value| !value.trim().is_empty())
741 .and_then(parse_job_stage),
742 estimated_wait_sec: wire.estimated_wait_sec,
743 })
744}
745
746pub(crate) fn parse_report(payload: &[u8]) -> Result<Report> {
747 let wire: ReportWire = decode_json(payload)?;
748 let media = wire.media.unwrap_or(ReportMediaWire {
749 media_id: None,
750 url: None,
751 });
752 Ok(Report {
753 id: response_string(&wire.id, "report.id")?,
754 created_at: response_datetime(&wire.created_at, "report.createdAt")?,
755 output: ReportOutputData {
756 template: ReportTemplate::parse(&wire.output.template, "report.output.template")?,
757 markdown: wire.markdown.filter(|value| !value.trim().is_empty()),
758 json: wire.json,
759 report_url: media.url.filter(|value| !value.trim().is_empty()),
760 },
761 job_id: wire.job_id.filter(|value| !value.trim().is_empty()),
762 label: wire.label.filter(|value| !value.trim().is_empty()),
763 entity_id: wire
764 .entity
765 .as_ref()
766 .and_then(|entity| (!entity.id.trim().is_empty()).then(|| entity.id.clone())),
767 entity_label: wire
768 .entity
769 .and_then(|entity| entity.label.filter(|value| !value.trim().is_empty())),
770 media_id: media.media_id.filter(|value| !value.trim().is_empty()),
771 })
772}
773
774pub(crate) fn parse_matching(payload: &[u8]) -> Result<Matching> {
775 let wire: MatchingWire = decode_json(payload)?;
776 Ok(Matching {
777 id: response_string(&wire.id, "matching.id")?,
778 created_at: response_datetime(&wire.created_at, "matching.createdAt")?,
779 context: MatchingContext::parse(&wire.context, "matching.context")?,
780 output: MatchingOutputData {
781 markdown: wire.markdown.filter(|value| !value.trim().is_empty()),
782 json: wire.json,
783 },
784 job_id: wire.job_id.filter(|value| !value.trim().is_empty()),
785 label: wire.label.filter(|value| !value.trim().is_empty()),
786 target: wire.target.map(parse_matching_subject),
787 group: wire
788 .group
789 .unwrap_or_default()
790 .into_iter()
791 .map(parse_matching_subject)
792 .collect(),
793 })
794}
795
796pub(crate) fn parse_media_object(payload: &[u8]) -> Result<MediaObject> {
797 let wire: MediaObjectWire = decode_json(payload)?;
798 Ok(MediaObject {
799 media_id: response_string(&wire.media_id, "media.mediaId")?,
800 created_at: response_datetime(&wire.created_at, "media.createdAt")?,
801 content_type: response_string(&wire.content_type, "media.contentType")?,
802 label: response_string(&wire.label, "media.label")?,
803 size_bytes: wire.size_bytes,
804 duration_seconds: wire.duration_seconds,
805 })
806}
807
808pub(crate) fn parse_media_file(payload: &[u8]) -> Result<MediaFile> {
809 let wire: MediaFileWire = decode_json(payload)?;
810 parse_media_file_wire(wire)
811}
812
813fn parse_media_file_wire(wire: MediaFileWire) -> Result<MediaFile> {
814 Ok(MediaFile {
815 media_id: response_string(&wire.media_id, "media.mediaId")?,
816 created_at: response_datetime(&wire.created_at, "media.createdAt")?,
817 content_type: response_string(&wire.content_type, "media.contentType")?,
818 has_reports: wire.has_reports,
819 label: response_string(&wire.label, "media.label")?,
820 processing_status: response_string(&wire.processing_status, "media.processingStatus")?,
821 last_used_at: optional_datetime(wire.last_used_at, "media.lastUsedAt")?,
822 retention: MediaRetention {
823 expires_at: optional_datetime(wire.retention.expires_at, "media.retention.expiresAt")?,
824 days_remaining: wire.retention.days_remaining,
825 locked: wire.retention.locked,
826 },
827 size_bytes: wire.size_bytes,
828 duration_seconds: wire.duration_seconds,
829 })
830}
831
832pub(crate) fn parse_list_files(payload: &[u8]) -> Result<ListFilesResponse> {
833 let wire: ListFilesWire = decode_json(payload)?;
834 let mut files = Vec::with_capacity(wire.files.len());
835 for file in wire.files {
836 files.push(parse_media_file_wire(file)?);
837 }
838 Ok(ListFilesResponse {
839 files,
840 has_more: wire.has_more,
841 next_cursor: wire.next_cursor.filter(|value| !value.trim().is_empty()),
842 })
843}
844
845pub(crate) fn parse_delete_receipt(payload: &[u8]) -> Result<FileDeleteReceipt> {
846 let receipt: FileDeleteReceipt = decode_json(payload)?;
847 response_string(&receipt.media_id, "delete.mediaId")?;
848 Ok(receipt)
849}
850
851pub(crate) fn parse_retention_lock(payload: &[u8]) -> Result<RetentionLockResult> {
852 let result: RetentionLockResult = decode_json(payload)?;
853 response_string(&result.media_id, "retention.mediaId")?;
854 response_string(&result.message, "retention.message")?;
855 Ok(result)
856}
857
858pub(crate) fn parse_entity(payload: &[u8]) -> Result<Entity> {
859 let wire: EntityWire = decode_json(payload)?;
860 parse_entity_wire(wire)
861}
862
863fn parse_entity_wire(wire: EntityWire) -> Result<Entity> {
864 Ok(Entity {
865 id: response_string(&wire.id, "entity.id")?,
866 created_at: response_datetime(&wire.created_at, "entity.createdAt")?,
867 label: wire.label.filter(|value| !value.trim().is_empty()),
868 media_count: wire.media_count,
869 last_seen_at: optional_datetime(wire.last_seen_at, "entity.lastSeenAt")?,
870 })
871}
872
873pub(crate) fn parse_list_entities(payload: &[u8]) -> Result<ListEntitiesResponse> {
874 let wire: ListEntitiesWire = decode_json(payload)?;
875 let mut entities = Vec::with_capacity(wire.entities.len());
876 for entity in wire.entities {
877 entities.push(parse_entity_wire(entity)?);
878 }
879 Ok(ListEntitiesResponse {
880 entities,
881 has_more: wire.has_more,
882 next_cursor: wire.cursor.filter(|value| !value.trim().is_empty()),
883 })
884}
885
886fn parse_matching_subject(wire: MatchingSubjectWire) -> MatchingResolvedSubject {
887 MatchingResolvedSubject {
888 source: wire.source,
889 entity_id: wire.entity_id.filter(|value| !value.trim().is_empty()),
890 resolved_label: wire.resolved_label.filter(|value| !value.trim().is_empty()),
891 }
892}
893
894fn response_string(value: &str, name: &str) -> Result<String> {
895 let trimmed = value.trim();
896 if trimmed.is_empty() {
897 return Err(ConduitError::invalid_response(format!(
898 "invalid {name}: expected string"
899 )));
900 }
901 Ok(trimmed.to_string())
902}
903
904fn response_datetime(value: &str, name: &str) -> Result<OffsetDateTime> {
905 let value = response_string(value, name)?;
906 OffsetDateTime::parse(&value, &Rfc3339).map_err(|error| {
907 ConduitError::invalid_response(format!("invalid {name}: expected ISO8601 string"))
908 .with_source(error)
909 })
910}
911
912fn optional_datetime(value: Option<String>, name: &str) -> Result<Option<OffsetDateTime>> {
913 value
914 .filter(|value| !value.trim().is_empty())
915 .map(|value| response_datetime(&value, name))
916 .transpose()
917}
918
919fn decode_json<T>(payload: &[u8]) -> Result<T>
920where
921 T: for<'de> Deserialize<'de>,
922{
923 serde_json::from_slice(payload)
924 .map_err(|error| ConduitError::invalid_response("invalid JSON response").with_source(error))
925}