1use crate::authz::{Role, WhoamiReply};
15use crate::browse::{ProjectionInfo, ProjectionPage, SchemaInfo, SchemaPage};
16use crate::control::{Projection, ProjectionBinding, SchemaSource, SourceSelector};
17use crate::filter::{
18 ConsumerFilter, FilterBinding, FilterBindingPage, FilterDetail, FilterMutationOutcome,
19 FilterMutationRequest, FilterPage, FilterPreview, FilterPreviewRequest, FilterRevisionPage,
20 FilterTestResult, FilterValidation,
21};
22use crate::fork::ForkInfo;
23use crate::graph::GraphQuery;
24use crate::http::{
25 self, Capabilities, CasCommittedView, ClientMetadataListView, ClientsQuery, DecodeRecordBody,
26 DeletedManyView, ErrorBody, FilterBindingListQuery, FilterListQuery, FilterRevisionListQuery,
27 FilterTestBody, ForkCreateBody, ForkPutBody, GraphNeighborsQuery, GraphResultView, KvCasQuery,
28 KvPageView, KvPutQuery, KvScanQuery, OperationQuery, ProjectionListQuery, PromotedView,
29 RemoveBindingBody, SchemaListQuery, SessionChangesQuery, SessionEventsQuery, SessionLinksQuery,
30 SessionStateQuery, SessionsQuery,
31};
32use crate::kv::{CasExpect, KvNamespaceInfo};
33use crate::query::{Query, QueryResult};
34use crate::result::ResultCode;
35use serde::Serialize;
36use serde::de::DeserializeOwned;
37use std::num::NonZeroU128;
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
41#[strum(serialize_all = "UPPERCASE")]
42pub enum Method {
43 Get,
44 Post,
45 Put,
46 Delete,
47}
48
49impl Method {
50 pub fn as_str(self) -> &'static str {
52 self.into()
53 }
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct HttpRequest {
63 pub method: Method,
64 pub path: String,
65 pub body: Option<Vec<u8>>,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq, Default)]
73pub struct HttpResponse {
74 pub status: u16,
75 pub headers: Vec<(String, String)>,
76 pub body: Vec<u8>,
77}
78
79impl HttpResponse {
80 pub fn new(status: u16, body: Vec<u8>) -> Self {
82 Self {
83 status,
84 headers: Vec::new(),
85 body,
86 }
87 }
88
89 #[must_use]
91 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
92 self.headers.push((name.into(), value.into()));
93 self
94 }
95
96 pub fn header(&self, name: &str) -> Option<&str> {
99 self.headers
100 .iter()
101 .find(|(key, _)| key.eq_ignore_ascii_case(name))
102 .map(|(_, value)| value.as_str())
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct KvValue {
113 pub value: Vec<u8>,
114 pub expires_at_micros: Option<u64>,
115 pub version: u64,
117}
118
119#[allow(async_fn_in_trait)]
135pub trait Transport {
136 type Error: core::fmt::Display;
139
140 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
144}
145
146#[derive(Debug)]
148pub enum ClientError<E> {
149 Transport(E),
151 Decode(String),
153 Api(ErrorBody),
155 Status { status: u16, body: ErrorBody },
158}
159
160impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
161 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
162 match self {
163 ClientError::Transport(error) => write!(f, "transport error: {error}"),
164 ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
165 ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
166 ClientError::Status { status, body } => {
167 write!(
168 f,
169 "http status {status} ({:?}): {}",
170 body.code, body.message
171 )
172 }
173 }
174 }
175}
176
177impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
178
179impl<E> ClientError<E> {
180 pub fn code(&self) -> Option<ResultCode> {
184 match self {
185 ClientError::Api(body) | ClientError::Status { body, .. } => Some(body.code),
186 _ => None,
187 }
188 }
189}
190
191type ClientResult<T, E> = Result<T, ClientError<E>>;
192
193#[derive(Clone, Debug)]
201pub struct HttpClient<T> {
202 transport: T,
203}
204
205impl<T: Transport> HttpClient<T> {
206 pub fn new(transport: T) -> Self {
208 Self { transport }
209 }
210
211 pub fn transport(&self) -> &T {
213 &self.transport
214 }
215
216 pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
218 self.get(http::CAPABILITIES_PATH.to_owned()).await
219 }
220
221 pub async fn query(&self, query: &Query) -> ClientResult<QueryResult, T::Error> {
223 self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
224 .await
225 }
226
227 pub async fn list_projections(
230 &self,
231 filter: &ProjectionListQuery,
232 ) -> ClientResult<ProjectionPage, T::Error> {
233 self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
234 }
235
236 pub async fn list_schemas(
238 &self,
239 filter: &SchemaListQuery,
240 ) -> ClientResult<SchemaPage, T::Error> {
241 self.get(with_query(http::SCHEMAS_PATH, filter)?).await
242 }
243
244 pub async fn register_schema(
248 &self,
249 source: SchemaSource,
250 name: Option<String>,
251 version: Option<u32>,
252 stream: Option<String>,
253 ) -> ClientResult<u32, T::Error> {
254 let body = http::RegisterSchemaBody {
255 source,
256 name,
257 version,
258 stream,
259 };
260 self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
261 .await
262 }
263
264 pub async fn register_schema_as(
268 &self,
269 source: SchemaSource,
270 name: Option<String>,
271 version: Option<u32>,
272 stream: Option<String>,
273 operation_id: NonZeroU128,
274 ) -> ClientResult<u32, T::Error> {
275 let body = http::RegisterSchemaBody {
276 source,
277 name,
278 version,
279 stream,
280 };
281 self.send_json(
282 Method::Post,
283 with_operation(http::SCHEMAS_PATH, operation_id)?,
284 &body,
285 )
286 .await
287 }
288
289 pub async fn kv_get(
295 &self,
296 namespace: &str,
297 key: &[u8],
298 ) -> ClientResult<Option<KvValue>, T::Error> {
299 let path = http::kv_entry_path(namespace, &base64url_encode(key));
300 let response = self.dispatch(Method::Get, path, None).await?;
301 if response.status == 404 {
302 return Ok(None);
303 }
304 if !(200..300).contains(&response.status) {
305 return Err(api_error(&response));
306 }
307 let expires_at_micros = response
308 .header(http::KV_EXPIRES_AT_MICROS_HEADER)
309 .and_then(|value| value.parse::<u64>().ok());
310 let version = response
311 .header(http::KV_VERSION_HEADER)
312 .and_then(|value| value.parse::<u64>().ok())
313 .ok_or_else(|| {
314 ClientError::Decode(format!(
315 "the entry answer carries no `{}` header",
316 http::KV_VERSION_HEADER
317 ))
318 })?;
319 Ok(Some(KvValue {
320 value: response.body,
321 expires_at_micros,
322 version,
323 }))
324 }
325
326 pub async fn kv_set(
329 &self,
330 namespace: &str,
331 key: &[u8],
332 value: &[u8],
333 expires_at_micros: Option<u64>,
334 ) -> ClientResult<(), T::Error> {
335 self.kv_set_with(namespace, key, value, expires_at_micros, None)
336 .await
337 }
338
339 pub async fn kv_set_as(
343 &self,
344 namespace: &str,
345 key: &[u8],
346 value: &[u8],
347 expires_at_micros: Option<u64>,
348 operation_id: NonZeroU128,
349 ) -> ClientResult<(), T::Error> {
350 self.kv_set_with(namespace, key, value, expires_at_micros, Some(operation_id))
351 .await
352 }
353
354 async fn kv_set_with(
355 &self,
356 namespace: &str,
357 key: &[u8],
358 value: &[u8],
359 expires_at_micros: Option<u64>,
360 operation_id: Option<NonZeroU128>,
361 ) -> ClientResult<(), T::Error> {
362 let path = with_query(
363 &http::kv_entry_path(namespace, &base64url_encode(key)),
364 &KvPutQuery { expires_at_micros },
365 )?;
366 let path = operation_route(path, operation_id)?;
367 self.expect_ok(Method::Put, path, Some(value.to_vec()))
370 .await
371 }
372
373 pub async fn kv_cas(
381 &self,
382 namespace: &str,
383 key: &[u8],
384 value: &[u8],
385 expect: CasExpect,
386 expires_at_micros: Option<u64>,
387 ) -> ClientResult<u64, T::Error> {
388 self.kv_cas_with(namespace, key, value, expect, expires_at_micros, None)
389 .await
390 }
391
392 pub async fn kv_cas_as(
396 &self,
397 namespace: &str,
398 key: &[u8],
399 value: &[u8],
400 expect: CasExpect,
401 expires_at_micros: Option<u64>,
402 operation_id: NonZeroU128,
403 ) -> ClientResult<u64, T::Error> {
404 self.kv_cas_with(
405 namespace,
406 key,
407 value,
408 expect,
409 expires_at_micros,
410 Some(operation_id),
411 )
412 .await
413 }
414
415 async fn kv_cas_with(
416 &self,
417 namespace: &str,
418 key: &[u8],
419 value: &[u8],
420 expect: CasExpect,
421 expires_at_micros: Option<u64>,
422 operation_id: Option<NonZeroU128>,
423 ) -> ClientResult<u64, T::Error> {
424 let (expect_version, expect_absent) = match expect {
425 CasExpect::Match(version) => (Some(version), None),
426 CasExpect::Absent => (None, Some(true)),
427 };
428 let path = with_query(
429 &http::kv_cas_path(namespace, &base64url_encode(key)),
430 &KvCasQuery {
431 expect_version,
432 expect_absent,
433 expires_at_micros,
434 },
435 )?;
436 let path = operation_route(path, operation_id)?;
437 let response = self
439 .dispatch(Method::Put, path, Some(value.to_vec()))
440 .await?;
441 let view: CasCommittedView = decode_ok(&response)?;
442 Ok(view.version)
443 }
444
445 pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
447 let path = http::kv_entry_path(namespace, &base64url_encode(key));
448 self.send_empty(Method::Delete, path).await
449 }
450
451 pub async fn kv_delete_as(
453 &self,
454 namespace: &str,
455 key: &[u8],
456 operation_id: NonZeroU128,
457 ) -> ClientResult<bool, T::Error> {
458 let path = http::kv_entry_path(namespace, &base64url_encode(key));
459 self.send_empty(Method::Delete, with_operation(&path, operation_id)?)
460 .await
461 }
462
463 pub async fn kv_scan(
465 &self,
466 namespace: &str,
467 filter: &KvScanQuery,
468 ) -> ClientResult<KvPageView, T::Error> {
469 self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
470 .await
471 }
472
473 pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
475 self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
476 .await
477 }
478
479 pub async fn create_fork_as(
481 &self,
482 body: &ForkCreateBody,
483 operation_id: NonZeroU128,
484 ) -> ClientResult<ForkInfo, T::Error> {
485 self.send_json(
486 Method::Post,
487 with_operation(http::FORKS_PATH, operation_id)?,
488 body,
489 )
490 .await
491 }
492
493 pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
495 self.get(http::FORKS_PATH.to_owned()).await
496 }
497
498 pub async fn clients(
502 &self,
503 query: &ClientsQuery,
504 ) -> ClientResult<ClientMetadataListView, T::Error> {
505 self.get(with_query(http::CLIENTS_PATH, query)?).await
506 }
507
508 pub async fn list_sessions(
511 &self,
512 stream: &str,
513 query: &SessionsQuery,
514 ) -> ClientResult<crate::session::SessionPage, T::Error> {
515 self.get(with_query(&http::sessions_path(stream), query)?)
516 .await
517 }
518
519 pub async fn session(
522 &self,
523 stream: &str,
524 id: crate::agent::ConversationId,
525 ) -> ClientResult<Option<crate::session::SessionInfo>, T::Error> {
526 self.get_optional(http::session_path(stream, id)).await
527 }
528
529 pub async fn session_events(
532 &self,
533 stream: &str,
534 id: crate::agent::ConversationId,
535 query: &SessionEventsQuery,
536 ) -> ClientResult<crate::session::SessionEventsPage, T::Error> {
537 self.get(with_query(&http::session_events_path(stream, id), query)?)
538 .await
539 }
540
541 pub async fn session_state(
543 &self,
544 stream: &str,
545 id: crate::agent::ConversationId,
546 query: &SessionStateQuery,
547 ) -> ClientResult<crate::session::SessionStateView, T::Error> {
548 self.get(with_query(&http::session_state_path(stream, id), query)?)
549 .await
550 }
551
552 pub async fn session_links(
555 &self,
556 stream: &str,
557 id: crate::agent::ConversationId,
558 query: &SessionLinksQuery,
559 ) -> ClientResult<crate::session::SessionLinksView, T::Error> {
560 self.get(with_query(&http::session_links_path(stream, id), query)?)
561 .await
562 }
563
564 pub async fn session_sources(
567 &self,
568 stream: &str,
569 id: crate::agent::ConversationId,
570 ) -> ClientResult<crate::session::SessionSources, T::Error> {
571 self.get(http::session_sources_path(stream, id)).await
572 }
573
574 pub async fn session_changes(
577 &self,
578 stream: &str,
579 query: &SessionChangesQuery,
580 ) -> ClientResult<crate::session::SessionChanges, T::Error> {
581 self.get(with_query(&http::session_changes_path(stream), query)?)
582 .await
583 }
584
585 pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
588 self.get_optional(http::projection_path(id)).await
589 }
590
591 pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
594 self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
595 .await
596 }
597
598 pub async fn register_projection_as(
602 &self,
603 projection: &Projection,
604 operation_id: NonZeroU128,
605 ) -> ClientResult<(), T::Error> {
606 let path = with_operation(http::PROJECTIONS_PATH, operation_id)?;
607 self.send_json_ok(Method::Post, path, projection).await
608 }
609
610 pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
612 self.expect_ok(Method::Delete, http::projection_path(id), None)
613 .await
614 }
615
616 pub async fn drop_projection_as(
619 &self,
620 id: &str,
621 operation_id: NonZeroU128,
622 ) -> ClientResult<(), T::Error> {
623 let path = with_operation(&http::projection_path(id), operation_id)?;
624 self.expect_ok(Method::Delete, path, None).await
625 }
626
627 pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
629 self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
630 .await
631 }
632
633 pub async fn apply_binding_as(
636 &self,
637 binding: &ProjectionBinding,
638 operation_id: NonZeroU128,
639 ) -> ClientResult<(), T::Error> {
640 let path = with_operation(http::BINDINGS_PATH, operation_id)?;
641 self.send_json_ok(Method::Post, path, binding).await
642 }
643
644 pub async fn remove_binding(
647 &self,
648 source: &SourceSelector,
649 projection_ref: Option<String>,
650 ) -> ClientResult<(), T::Error> {
651 let body = RemoveBindingBody {
652 stream: source.stream.clone(),
653 topic: source.topic.clone(),
654 projection_ref,
655 };
656 self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
657 .await
658 }
659
660 pub async fn remove_binding_as(
663 &self,
664 source: &SourceSelector,
665 projection_ref: Option<String>,
666 operation_id: NonZeroU128,
667 ) -> ClientResult<(), T::Error> {
668 let body = RemoveBindingBody {
669 stream: source.stream.clone(),
670 topic: source.topic.clone(),
671 projection_ref,
672 };
673 let path = with_operation(http::BINDINGS_PATH, operation_id)?;
674 self.send_json_ok(Method::Delete, path, &body).await
675 }
676
677 pub async fn get_schema(
681 &self,
682 id: u32,
683 stream: Option<&str>,
684 ) -> ClientResult<Option<SchemaInfo>, T::Error> {
685 self.get_optional(schema_route(http::schema_path(id), stream)?)
686 .await
687 }
688
689 pub async fn drop_schema(&self, id: u32, stream: Option<&str>) -> ClientResult<(), T::Error> {
692 self.expect_ok(
693 Method::Delete,
694 schema_route(http::schema_path(id), stream)?,
695 None,
696 )
697 .await
698 }
699
700 pub async fn drop_schema_as(
702 &self,
703 id: u32,
704 stream: Option<&str>,
705 operation_id: NonZeroU128,
706 ) -> ClientResult<(), T::Error> {
707 let path = schema_route(http::schema_path(id), stream)?;
708 self.expect_ok(Method::Delete, with_operation(&path, operation_id)?, None)
709 .await
710 }
711
712 pub async fn decode_record(
716 &self,
717 id: u32,
718 payload: &[u8],
719 stream: Option<&str>,
720 ) -> ClientResult<Option<serde_json::Value>, T::Error> {
721 let body = DecodeRecordBody {
722 payload: base64url_encode(payload),
723 };
724 self.send_json(
725 Method::Post,
726 schema_route(http::schema_decode_path(id), stream)?,
727 &body,
728 )
729 .await
730 }
731
732 pub async fn list_filters(
734 &self,
735 query: &FilterListQuery,
736 ) -> ClientResult<FilterPage, T::Error> {
737 self.get(with_query(http::FILTERS_PATH, query)?).await
738 }
739
740 pub async fn get_filter(&self, id: u32) -> ClientResult<Option<FilterDetail>, T::Error> {
742 self.get_optional(http::filter_path(id)).await
743 }
744
745 pub async fn list_filter_revisions(
747 &self,
748 id: u32,
749 query: &FilterRevisionListQuery,
750 ) -> ClientResult<FilterRevisionPage, T::Error> {
751 self.get(with_query(&http::filter_revisions_path(id), query)?)
752 .await
753 }
754
755 pub async fn mutate_filter(
760 &self,
761 request: &FilterMutationRequest,
762 ) -> ClientResult<FilterMutationOutcome, T::Error> {
763 let payload = serde_json::to_vec(request)
764 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
765 let response = self
766 .dispatch(
767 Method::Post,
768 http::FILTER_MUTATIONS_PATH.to_owned(),
769 Some(payload),
770 )
771 .await?;
772 if let Ok(outcome) = serde_json::from_slice::<FilterMutationOutcome>(&response.body) {
773 return Ok(outcome);
774 }
775 decode_ok(&response)
776 }
777
778 pub async fn filter_operation(
781 &self,
782 operation_id: u128,
783 ) -> ClientResult<Option<FilterMutationOutcome>, T::Error> {
784 self.get_optional(http::filter_operation_path(operation_id))
785 .await
786 }
787
788 pub async fn validate_filter(
790 &self,
791 filter: &ConsumerFilter,
792 ) -> ClientResult<FilterValidation, T::Error> {
793 self.send_json(Method::Post, http::FILTER_VALIDATE_PATH.to_owned(), filter)
794 .await
795 }
796
797 pub async fn test_filter(
800 &self,
801 body: &FilterTestBody,
802 ) -> ClientResult<FilterTestResult, T::Error> {
803 self.send_json(Method::Post, http::FILTER_TEST_PATH.to_owned(), body)
804 .await
805 }
806
807 pub async fn preview_filter(
810 &self,
811 request: &FilterPreviewRequest,
812 ) -> ClientResult<FilterPreview, T::Error> {
813 self.send_json(Method::Post, http::FILTER_PREVIEW_PATH.to_owned(), request)
814 .await
815 }
816
817 pub async fn list_filter_bindings(
819 &self,
820 query: &FilterBindingListQuery,
821 ) -> ClientResult<FilterBindingPage, T::Error> {
822 self.get(with_query(http::FILTER_BINDINGS_PATH, query)?)
823 .await
824 }
825
826 pub async fn get_filter_binding(
829 &self,
830 stream: &str,
831 topic: &str,
832 group: &str,
833 ) -> ClientResult<Option<FilterBinding>, T::Error> {
834 let response = self
835 .dispatch(
836 Method::Get,
837 http::filter_binding_path(stream, topic, group),
838 None,
839 )
840 .await?;
841 if response.status == 404 && response.body.is_empty() {
842 return Ok(None);
843 }
844 decode_ok(&response).map(Some)
845 }
846
847 pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
849 self.get(http::KV_PATH.to_owned()).await
850 }
851
852 pub async fn kv_delete_many(
855 &self,
856 namespace: &str,
857 filter: &KvScanQuery,
858 ) -> ClientResult<usize, T::Error> {
859 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
860 let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
861 Ok(view.deleted)
862 }
863
864 pub async fn kv_delete_many_as(
867 &self,
868 namespace: &str,
869 filter: &KvScanQuery,
870 operation_id: NonZeroU128,
871 ) -> ClientResult<usize, T::Error> {
872 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
873 let view: DeletedManyView = self
874 .send_empty(Method::Delete, with_operation(&path, operation_id)?)
875 .await?;
876 Ok(view.deleted)
877 }
878
879 pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
882 let view: PromotedView = self
883 .send_empty(Method::Post, http::fork_promote_path(id))
884 .await?;
885 Ok(view.rows)
886 }
887
888 pub async fn promote_fork_as(
890 &self,
891 id: &str,
892 operation_id: NonZeroU128,
893 ) -> ClientResult<usize, T::Error> {
894 let path = with_operation(&http::fork_promote_path(id), operation_id)?;
895 let view: PromotedView = self.send_empty(Method::Post, path).await?;
896 Ok(view.rows)
897 }
898
899 pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
901 self.expect_ok(Method::Delete, http::fork_path(id), None)
902 .await
903 }
904
905 pub async fn delete_fork_as(
907 &self,
908 id: &str,
909 operation_id: NonZeroU128,
910 ) -> ClientResult<(), T::Error> {
911 let path = with_operation(&http::fork_path(id), operation_id)?;
912 self.expect_ok(Method::Delete, path, None).await
913 }
914
915 pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
917 self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
918 .await
919 }
920
921 pub async fn put_fork_row_as(
923 &self,
924 id: &str,
925 body: &ForkPutBody,
926 operation_id: NonZeroU128,
927 ) -> ClientResult<(), T::Error> {
928 let path = with_operation(&http::fork_rows_path(id), operation_id)?;
929 self.send_json_ok(Method::Put, path, body).await
930 }
931
932 pub async fn graph_query(
936 &self,
937 name: &str,
938 query: &GraphQuery,
939 ) -> ClientResult<GraphResultView, T::Error> {
940 self.send_json(Method::Post, http::graph_query_path(name), query)
941 .await
942 }
943
944 pub async fn graph_neighbors(
949 &self,
950 name: &str,
951 node: &str,
952 query: &GraphNeighborsQuery,
953 ) -> ClientResult<GraphResultView, T::Error> {
954 self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
955 .await
956 }
957
958 pub async fn list_graphs(
963 &self,
964 filter: &ProjectionListQuery,
965 ) -> ClientResult<ProjectionPage, T::Error> {
966 self.get(with_query(http::GRAPHS_PATH, filter)?).await
967 }
968
969 pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
973 self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
974 .await
975 }
976
977 pub async fn register_graph_as(
980 &self,
981 projection: &Projection,
982 operation_id: NonZeroU128,
983 ) -> ClientResult<(), T::Error> {
984 let path = with_operation(http::GRAPHS_PATH, operation_id)?;
985 self.send_json_ok(Method::Post, path, projection).await
986 }
987
988 pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
991 self.get_optional(http::graph_path(id)).await
992 }
993
994 pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
997 self.expect_ok(Method::Delete, http::graph_path(id), None)
998 .await
999 }
1000
1001 pub async fn drop_graph_as(
1003 &self,
1004 id: &str,
1005 operation_id: NonZeroU128,
1006 ) -> ClientResult<(), T::Error> {
1007 let path = with_operation(&http::graph_path(id), operation_id)?;
1008 self.expect_ok(Method::Delete, path, None).await
1009 }
1010
1011 pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
1015 self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
1016 }
1017
1018 pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
1020 self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
1021 }
1022
1023 pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
1025 self.get_optional(http::authz_role_path(name)).await
1026 }
1027
1028 pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
1031 self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
1032 .await
1033 }
1034
1035 pub async fn define_role_as(
1039 &self,
1040 role: &Role,
1041 operation_id: NonZeroU128,
1042 ) -> ClientResult<(), T::Error> {
1043 self.send_json_ok(
1044 Method::Put,
1045 with_operation(&http::authz_role_path(&role.name), operation_id)?,
1046 role,
1047 )
1048 .await
1049 }
1050
1051 pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
1053 self.expect_ok(Method::Delete, http::authz_role_path(name), None)
1054 .await
1055 }
1056
1057 pub async fn delete_role_as(
1059 &self,
1060 name: &str,
1061 operation_id: NonZeroU128,
1062 ) -> ClientResult<(), T::Error> {
1063 self.expect_ok(
1064 Method::Delete,
1065 with_operation(&http::authz_role_path(name), operation_id)?,
1066 None,
1067 )
1068 .await
1069 }
1070
1071 pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
1073 self.get(http::authz_user_roles_path(user_id)).await
1074 }
1075
1076 pub async fn bind_user_roles(
1078 &self,
1079 user_id: u32,
1080 roles: &[String],
1081 ) -> ClientResult<(), T::Error> {
1082 self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
1083 .await
1084 }
1085
1086 pub async fn bind_user_roles_as(
1088 &self,
1089 user_id: u32,
1090 roles: &[String],
1091 operation_id: NonZeroU128,
1092 ) -> ClientResult<(), T::Error> {
1093 self.send_json_ok(
1094 Method::Put,
1095 with_operation(&http::authz_user_roles_path(user_id), operation_id)?,
1096 &roles,
1097 )
1098 .await
1099 }
1100
1101 async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
1102 let response = self.dispatch(Method::Get, path, None).await?;
1103 decode_ok(&response)
1104 }
1105
1106 async fn get_optional<R: DeserializeOwned>(
1108 &self,
1109 path: String,
1110 ) -> ClientResult<Option<R>, T::Error> {
1111 let response = self.dispatch(Method::Get, path, None).await?;
1112 if response.status == 404 {
1113 return Ok(None);
1114 }
1115 decode_ok(&response).map(Some)
1116 }
1117
1118 async fn send_json<B: Serialize, R: DeserializeOwned>(
1119 &self,
1120 method: Method,
1121 path: String,
1122 body: &B,
1123 ) -> ClientResult<R, T::Error> {
1124 let payload = serde_json::to_vec(body)
1125 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1126 let response = self.dispatch(method, path, Some(payload)).await?;
1127 decode_ok(&response)
1128 }
1129
1130 async fn send_empty<R: DeserializeOwned>(
1131 &self,
1132 method: Method,
1133 path: String,
1134 ) -> ClientResult<R, T::Error> {
1135 let response = self.dispatch(method, path, None).await?;
1136 decode_ok(&response)
1137 }
1138
1139 async fn send_json_ok<B: Serialize>(
1142 &self,
1143 method: Method,
1144 path: String,
1145 body: &B,
1146 ) -> ClientResult<(), T::Error> {
1147 let payload = serde_json::to_vec(body)
1148 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1149 let response = self.dispatch(method, path, Some(payload)).await?;
1150 check_status(&response)
1151 }
1152
1153 async fn expect_ok(
1154 &self,
1155 method: Method,
1156 path: String,
1157 body: Option<Vec<u8>>,
1158 ) -> ClientResult<(), T::Error> {
1159 let response = self.dispatch(method, path, body).await?;
1160 check_status(&response)
1161 }
1162
1163 async fn dispatch(
1164 &self,
1165 method: Method,
1166 path: String,
1167 body: Option<Vec<u8>>,
1168 ) -> ClientResult<HttpResponse, T::Error> {
1169 self.transport
1170 .send(HttpRequest { method, path, body })
1171 .await
1172 .map_err(ClientError::Transport)
1173 }
1174}
1175
1176fn schema_route<E>(path: String, stream: Option<&str>) -> Result<String, ClientError<E>> {
1179 with_query(
1180 &path,
1181 &http::SchemaQuery {
1182 stream: stream.map(str::to_owned),
1183 },
1184 )
1185}
1186
1187fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
1189 let query = serde_urlencoded::to_string(params)
1190 .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
1191 if query.is_empty() {
1192 Ok(path.to_owned())
1193 } else if path.contains('?') {
1194 Ok(format!("{path}&{query}"))
1195 } else {
1196 Ok(format!("{path}?{query}"))
1197 }
1198}
1199
1200fn with_operation<E>(path: &str, operation_id: NonZeroU128) -> Result<String, ClientError<E>> {
1201 with_query(path, &OperationQuery::from(operation_id))
1202}
1203
1204fn operation_route<E>(
1205 path: String,
1206 operation_id: Option<NonZeroU128>,
1207) -> Result<String, ClientError<E>> {
1208 match operation_id {
1209 Some(operation_id) => with_operation(&path, operation_id),
1210 None => Ok(path),
1211 }
1212}
1213
1214fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
1218 if (200..300).contains(&response.status) {
1219 serde_json::from_slice(&response.body)
1220 .map_err(|error| ClientError::Decode(format!("response body: {error}")))
1221 } else {
1222 Err(api_error(response))
1223 }
1224}
1225
1226fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
1228 if (200..300).contains(&response.status) {
1229 Ok(())
1230 } else {
1231 Err(api_error(response))
1232 }
1233}
1234
1235fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
1236 match serde_json::from_slice::<ErrorBody>(&response.body) {
1237 Ok(body) => ClientError::Api(body),
1238 Err(_) => ClientError::Status {
1239 status: response.status,
1240 body: ErrorBody::new(
1241 code_for_status(response.status),
1242 String::from_utf8_lossy(&response.body).into_owned(),
1243 ),
1244 },
1245 }
1246}
1247
1248fn code_for_status(status: u16) -> ResultCode {
1252 match status {
1253 404 => ResultCode::NotFound,
1254 400 => ResultCode::InvalidArgument,
1255 401 => ResultCode::Unauthenticated,
1256 403 => ResultCode::Forbidden,
1257 409 => ResultCode::Conflict,
1258 413 => ResultCode::TooLarge,
1259 501 => ResultCode::Unsupported,
1260 503 => ResultCode::Stale,
1261 _ => ResultCode::Backend,
1262 }
1263}
1264
1265const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1269
1270pub fn base64url_encode(input: &[u8]) -> String {
1272 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1273 for chunk in input.chunks(3) {
1274 let b0 = chunk[0] as usize;
1275 out.push(B64URL[b0 >> 2] as char);
1276 match chunk.len() {
1277 1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
1278 2 => {
1279 let b1 = chunk[1] as usize;
1280 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1281 out.push(B64URL[(b1 & 0b1111) << 2] as char);
1282 }
1283 _ => {
1284 let b1 = chunk[1] as usize;
1285 let b2 = chunk[2] as usize;
1286 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1287 out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
1288 out.push(B64URL[b2 & 0b111111] as char);
1289 }
1290 }
1291 }
1292 out
1293}
1294
1295pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
1298 fn val(byte: u8) -> Option<u8> {
1299 match byte {
1300 b'A'..=b'Z' => Some(byte - b'A'),
1301 b'a'..=b'z' => Some(byte - b'a' + 26),
1302 b'0'..=b'9' => Some(byte - b'0' + 52),
1303 b'-' => Some(62),
1304 b'_' => Some(63),
1305 _ => None,
1306 }
1307 }
1308 let bytes = input.as_bytes();
1309 if bytes.len() % 4 == 1 {
1310 return None;
1311 }
1312 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1313 for chunk in bytes.chunks(4) {
1314 let mut acc = 0u32;
1315 for &byte in chunk {
1316 acc = (acc << 6) | u32::from(val(byte)?);
1317 }
1318 acc <<= 6 * (4 - chunk.len());
1320 match chunk.len() {
1321 2 => out.push((acc >> 16) as u8),
1322 3 => {
1323 out.push((acc >> 16) as u8);
1324 out.push((acc >> 8) as u8);
1325 }
1326 _ => {
1327 out.push((acc >> 16) as u8);
1328 out.push((acc >> 8) as u8);
1329 out.push(acc as u8);
1330 }
1331 }
1332 }
1333 Some(out)
1334}
1335
1336#[cfg(test)]
1337mod tests {
1338 use super::*;
1339 use crate::agent::ConversationId;
1340
1341 #[test]
1342 fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
1343 for case in [
1344 &b""[..],
1345 &b"f"[..],
1346 &b"fo"[..],
1347 &b"foo"[..],
1348 &b"foob"[..],
1349 &b"fooba"[..],
1350 &b"foobar"[..],
1351 &[0x00, 0xff, 0x10, 0x80][..],
1352 ] {
1353 let encoded = base64url_encode(case);
1354 assert!(
1355 !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1356 "url-safe unpadded: {encoded}"
1357 );
1358 assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1359 }
1360 }
1361
1362 #[test]
1363 fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1364 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1365 assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1366 }
1367
1368 #[test]
1369 fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1370 assert!(
1371 base64url_decode("====").is_none(),
1372 "padding is not alphabet"
1373 );
1374 assert!(
1375 base64url_decode("A").is_none(),
1376 "a lone char carries no byte"
1377 );
1378 assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1379 }
1380
1381 struct CannedTransport {
1384 response: HttpResponse,
1385 }
1386
1387 impl Transport for CannedTransport {
1388 type Error = std::convert::Infallible;
1389 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1390 Ok(self.response.clone())
1391 }
1392 }
1393
1394 #[derive(Default)]
1397 struct RecordingTransport {
1398 requests: std::cell::RefCell<Vec<HttpRequest>>,
1399 }
1400
1401 impl Transport for RecordingTransport {
1402 type Error = std::convert::Infallible;
1403 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1404 self.requests.borrow_mut().push(request);
1405 Ok(HttpResponse::new(202, Vec::new()))
1406 }
1407 }
1408
1409 fn block_on<F: core::future::Future>(future: F) -> F::Output {
1410 use core::task::{Context, Poll, Waker};
1414 let mut context = Context::from_waker(Waker::noop());
1415 let mut future = core::pin::pin!(future);
1416 loop {
1417 if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1418 return output;
1419 }
1420 }
1421 }
1422
1423 #[test]
1424 fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1425 let body = serde_json::to_vec(&Capabilities::new(
1426 true,
1427 crate::hello::OpVersions::new(1, 1, 1, 1),
1428 ))
1429 .unwrap();
1430 let client = HttpClient::new(CannedTransport {
1431 response: HttpResponse::new(200, body),
1432 });
1433 let caps = block_on(client.capabilities()).expect("decodes");
1434 assert!(caps.managed && !caps.kv.cas);
1435 }
1436
1437 #[test]
1438 fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1439 let body =
1440 serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1441 let client = HttpClient::new(CannedTransport {
1442 response: HttpResponse::new(404, body),
1443 });
1444 let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1445 assert_eq!(error.code(), Some(ResultCode::NotFound));
1446 }
1447
1448 #[test]
1449 fn given_a_missing_binding_when_fetched_then_should_distinguish_absence_from_missing_source() {
1450 let absent = HttpClient::new(CannedTransport {
1451 response: HttpResponse::new(404, Vec::new()),
1452 });
1453 assert!(
1454 block_on(absent.get_filter_binding("stream", "topic", "group"))
1455 .unwrap()
1456 .is_none()
1457 );
1458 let body = serde_json::to_vec(&ErrorBody::new(
1459 ResultCode::NotFound,
1460 "topic does not exist",
1461 ))
1462 .unwrap();
1463 let missing_source = HttpClient::new(CannedTransport {
1464 response: HttpResponse::new(404, body),
1465 });
1466 let error =
1467 block_on(missing_source.get_filter_binding("stream", "topic", "group")).unwrap_err();
1468 assert_eq!(error.code(), Some(ResultCode::NotFound));
1469 }
1470
1471 #[test]
1472 fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
1473 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1474 let client = HttpClient::new(CannedTransport {
1475 response: HttpResponse::new(404, body),
1476 });
1477 let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1478 assert!(entry.is_none());
1479 }
1480
1481 #[test]
1482 fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_expiry_and_version_headers()
1483 {
1484 let response = HttpResponse::new(200, b"world".to_vec())
1485 .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000")
1486 .with_header(http::KV_VERSION_HEADER, "7");
1487 let client = HttpClient::new(CannedTransport { response });
1488 let entry = block_on(client.kv_get("sessions", b"user:1"))
1489 .expect("decodes")
1490 .expect("present");
1491 assert_eq!(entry.value, b"world");
1492 assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1493 assert_eq!(
1494 entry.version, 7,
1495 "the version a CAS expects survives the helper"
1496 );
1497 }
1498
1499 #[test]
1500 fn given_a_kv_entry_answer_without_its_version_when_fetched_then_should_refuse_it() {
1501 let response = HttpResponse::new(200, b"world".to_vec());
1502 let client = HttpClient::new(CannedTransport { response });
1503 assert!(matches!(
1504 block_on(client.kv_get("sessions", b"user:1")),
1505 Err(ClientError::Decode(_))
1506 ));
1507 }
1508
1509 #[test]
1510 fn given_a_missing_projection_when_fetched_then_should_be_none() {
1511 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1512 let client = HttpClient::new(CannedTransport {
1513 response: HttpResponse::new(404, body),
1514 });
1515 let info = block_on(client.get_projection("reading.v1")).expect("404 maps to None");
1516 assert!(info.is_none());
1517 }
1518
1519 #[test]
1520 fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1521 let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1522 let client = HttpClient::new(CannedTransport {
1523 response: HttpResponse::new(200, body),
1524 });
1525 let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1526 .expect("decodes the count");
1527 assert_eq!(removed, 7);
1528 }
1529
1530 #[test]
1531 fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1532 let client = HttpClient::new(CannedTransport {
1533 response: HttpResponse::new(204, Vec::new()),
1534 });
1535 block_on(client.drop_projection("reading.v1")).expect("a 204 is a success");
1536 }
1537
1538 #[test]
1539 fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1540 let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1541 let client = HttpClient::new(CannedTransport {
1542 response: HttpResponse::new(200, body),
1543 });
1544 let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1545 .expect("a commit returns the new version");
1546 assert_eq!(version, 4);
1547 }
1548
1549 #[test]
1550 fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1551 let reply = WhoamiReply {
1552 v: 1,
1553 roles: vec!["admin".to_owned()],
1554 grants: vec![crate::authz::Grant {
1555 effect: crate::authz::Effect::Allow,
1556 feature: crate::authz::Feature::Authz,
1557 action: crate::authz::Action::Admin,
1558 resource: crate::authz::ResourcePattern::all(),
1559 }],
1560 };
1561 let body = serde_json::to_vec(&reply).unwrap();
1562 let client = HttpClient::new(CannedTransport {
1563 response: HttpResponse::new(200, body),
1564 });
1565 let whoami = block_on(client.authz_whoami()).expect("decodes");
1566 assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1567 assert_eq!(whoami.grants.len(), 1);
1568 }
1569
1570 #[test]
1571 fn given_a_missing_role_when_fetched_then_should_be_none() {
1572 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1573 let client = HttpClient::new(CannedTransport {
1574 response: HttpResponse::new(404, body),
1575 });
1576 let role = block_on(client.get_role("ghost")).expect("404 maps to None");
1577 assert!(role.is_none());
1578 }
1579
1580 #[test]
1581 fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1582 let body = serde_json::to_vec(
1583 &ErrorBody::new(ResultCode::Conflict, "version conflict")
1584 .with_detail(serde_json::json!({ "current": 3 })),
1585 )
1586 .unwrap();
1587 let client = HttpClient::new(CannedTransport {
1588 response: HttpResponse::new(409, body),
1589 });
1590 let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1591 .expect_err("a precondition miss is an error");
1592 assert_eq!(error.code(), Some(ResultCode::Conflict));
1593 }
1594
1595 #[test]
1596 fn given_a_caller_operation_id_when_writing_the_registry_then_should_ride_the_query_string() {
1597 let operation_id = NonZeroU128::new(42).expect("non-zero");
1598 let client = HttpClient::new(RecordingTransport::default());
1599 let projection = Projection::builder("reading.v1").build();
1600 let source = SourceSelector::new("telemetry", "events");
1601
1602 block_on(client.register_projection_as(&projection, operation_id)).expect("register");
1603 block_on(client.drop_projection_as("reading.v1", operation_id)).expect("drop");
1604 block_on(client.remove_binding_as(&source, None, operation_id)).expect("remove");
1605 block_on(client.drop_schema_as(7, Some("telemetry"), operation_id)).expect("drop schema");
1606 block_on(client.drop_schema_as(7, None, operation_id)).expect("drop shared schema");
1607 block_on(client.register_graph_as(&projection, operation_id)).expect("register graph");
1608 block_on(client.drop_graph_as("topology.v1", operation_id)).expect("drop graph");
1609
1610 let paths: Vec<String> = client
1611 .transport()
1612 .requests
1613 .borrow()
1614 .iter()
1615 .map(|request| request.path.clone())
1616 .collect();
1617 assert_eq!(
1618 paths,
1619 [
1620 "/agdx/projections?operation_id=42",
1621 "/agdx/projections/reading.v1?operation_id=42",
1622 "/agdx/bindings?operation_id=42",
1623 "/agdx/schemas/7?stream=telemetry&operation_id=42",
1624 "/agdx/schemas/7?operation_id=42",
1625 "/agdx/graphs?operation_id=42",
1626 "/agdx/graphs/topology.v1?operation_id=42",
1627 ]
1628 );
1629 }
1630
1631 #[test]
1632 fn given_a_caller_operation_id_when_writing_key_values_then_should_follow_the_other_parameters()
1633 {
1634 let operation_id = NonZeroU128::new(7).expect("non-zero");
1635 let client = HttpClient::new(RecordingTransport::default());
1636
1637 let _ = block_on(client.kv_set_as("notes", b"k", b"v", Some(10), operation_id));
1638 let _ =
1639 block_on(client.kv_cas_as("notes", b"k", b"v", CasExpect::Absent, None, operation_id));
1640 let _ = block_on(client.kv_delete_as("notes", b"k", operation_id));
1641 let _ = block_on(client.delete_fork_as("f1", operation_id));
1642
1643 let requests = client.transport().requests.borrow();
1644 assert_eq!(
1645 requests[0].path,
1646 "/agdx/kv/notes/aw?expires_at_micros=10&operation_id=7"
1647 );
1648 assert_eq!(
1649 requests[1].path,
1650 "/agdx/kv/notes/aw/cas?expect_absent=true&operation_id=7"
1651 );
1652 assert_eq!(requests[2].path, "/agdx/kv/notes/aw?operation_id=7");
1653 assert_eq!(requests[3].path, "/agdx/forks/f1?operation_id=7");
1654 }
1655
1656 #[test]
1657 fn given_no_operation_id_when_writing_then_should_send_the_bare_path() {
1658 let client = HttpClient::new(RecordingTransport::default());
1659 block_on(client.drop_projection("reading.v1")).expect("drop");
1660 block_on(client.drop_schema(7, Some("telemetry"))).expect("drop schema");
1661 let requests = client.transport().requests.borrow();
1662 assert_eq!(requests[0].path, "/agdx/projections/reading.v1");
1663 assert_eq!(requests[1].path, "/agdx/schemas/7?stream=telemetry");
1664 }
1665
1666 #[test]
1667 fn given_a_plain_text_gateway_reply_when_read_then_should_keep_its_status() {
1668 for (status, text) in [
1669 (502, "bad gateway: upstream unreachable"),
1670 (504, "gateway timeout: the request may still complete"),
1671 ] {
1672 let client = HttpClient::new(CannedTransport {
1673 response: HttpResponse::new(status, text.as_bytes().to_vec()),
1674 });
1675 let error = block_on(client.list_sessions("agents", &SessionsQuery::default()))
1676 .expect_err("a gateway failure is an error");
1677 assert_eq!(error.code(), Some(ResultCode::Backend));
1678 assert_eq!(
1679 error.to_string(),
1680 format!("http status {status} (Backend): {text}")
1681 );
1682 let ClientError::Status { status: kept, body } = error else {
1683 panic!("a body that is not an ErrorBody is a status error: {error:?}");
1684 };
1685 assert_eq!(
1686 (kept, body),
1687 (status, ErrorBody::new(ResultCode::Backend, text))
1688 );
1689 }
1690 }
1691
1692 #[test]
1693 fn given_a_typed_backend_error_with_status_502_when_read_then_should_stay_an_api_error() {
1694 let typed = ErrorBody::new(ResultCode::Backend, "session backend error: store closed");
1695 let client = HttpClient::new(CannedTransport {
1696 response: HttpResponse::new(
1697 502,
1698 serde_json::to_vec(&typed).expect("error body encodes"),
1699 ),
1700 });
1701 let error = block_on(client.list_sessions("agents", &SessionsQuery::default()))
1702 .expect_err("a typed backend error is an error");
1703 assert_eq!(error.code(), Some(ResultCode::Backend));
1704 let ClientError::Api(body) = error else {
1705 panic!("a decodable ErrorBody is an api error: {error:?}");
1706 };
1707 assert_eq!(body, typed);
1708 }
1709
1710 #[test]
1711 fn given_a_missing_session_when_read_then_should_be_none_for_any_404_body() {
1712 let typed = ErrorBody::new(ResultCode::NotFound, "session not found");
1713 for body in [
1714 serde_json::to_vec(&typed).expect("error body encodes"),
1715 b"not found".to_vec(),
1716 Vec::new(),
1717 ] {
1718 let client = HttpClient::new(CannedTransport {
1719 response: HttpResponse::new(404, body),
1720 });
1721 let session = block_on(client.session("agents", ConversationId::from_u128(2)))
1722 .expect("a 404 maps to None");
1723 assert_eq!(session, None);
1724 }
1725 }
1726}