1use crate::authz::{Role, WhoamiReply};
15use crate::browse::{ProjectionInfo, SchemaInfo};
16use crate::checkpoint::CheckpointReadConsistency;
17use crate::control::{Projection, ProjectionBinding, SchemaSource, SourceSelector};
18use crate::destination::{DestinationId, DestinationOperationId};
19use crate::filter::{
20 ConsumerFilter, FilterBinding, FilterBindingPage, FilterDetail, FilterMutationOutcome,
21 FilterMutationRequest, FilterPage, FilterPreview, FilterPreviewRequest, FilterRevisionPage,
22 FilterTestResult, FilterValidation,
23};
24use crate::fork::ForkInfo;
25use crate::graph::GraphQuery;
26use crate::http::{
27 self, AcceptedOperationView, Capabilities, CasCommittedView, CheckpointReadQuery,
28 ClientMetadataListView, ClientsQuery, DecodeRecordBody, DeletedManyView, DestinationIssueView,
29 DestinationListQuery, DestinationMutationBody, DestinationPageView, DestinationView, ErrorBody,
30 FilterBindingListQuery, FilterListQuery, FilterRevisionListQuery, FilterTestBody,
31 ForkCreateBody, ForkPutBody, GraphNeighborsQuery, GraphResultView, KvCasQuery, KvPageView,
32 KvPutQuery, KvScanQuery, OperationQuery, ProjectionListQuery, PromotedView, QueryExecutionView,
33 QueryPageBody, QueryRouteListQuery, QueryRoutePageView, RemoveBindingBody, SchemaListQuery,
34 SessionChangesQuery, SessionEventsQuery, SessionLinksQuery, SessionStateQuery, SessionsQuery,
35 SnapshotListQuery, SnapshotPageView, TableFileListQuery, TableFilePageView, TableMetricsView,
36 TableSchemaView, TableSnapshotView, TableView,
37};
38use crate::kv::{CasExpect, KvNamespaceInfo};
39use crate::query::{Query, QueryResult};
40use crate::result::ResultCode;
41use serde::Serialize;
42use serde::de::DeserializeOwned;
43use std::num::NonZeroU128;
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
47#[strum(serialize_all = "UPPERCASE")]
48pub enum Method {
49 Get,
50 Post,
51 Put,
52 Delete,
53}
54
55impl Method {
56 pub fn as_str(self) -> &'static str {
58 self.into()
59 }
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct HttpRequest {
69 pub method: Method,
70 pub path: String,
71 pub body: Option<Vec<u8>>,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Default)]
79pub struct HttpResponse {
80 pub status: u16,
81 pub headers: Vec<(String, String)>,
82 pub body: Vec<u8>,
83}
84
85impl HttpResponse {
86 pub fn new(status: u16, body: Vec<u8>) -> Self {
88 Self {
89 status,
90 headers: Vec::new(),
91 body,
92 }
93 }
94
95 #[must_use]
97 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
98 self.headers.push((name.into(), value.into()));
99 self
100 }
101
102 pub fn header(&self, name: &str) -> Option<&str> {
105 self.headers
106 .iter()
107 .find(|(key, _)| key.eq_ignore_ascii_case(name))
108 .map(|(_, value)| value.as_str())
109 }
110}
111
112#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct KvValue {
119 pub value: Vec<u8>,
120 pub expires_at_micros: Option<u64>,
121}
122
123#[allow(async_fn_in_trait)]
139pub trait Transport {
140 type Error: core::fmt::Display;
143
144 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
148}
149
150#[derive(Debug)]
152pub enum ClientError<E> {
153 Transport(E),
155 Decode(String),
157 Api(ErrorBody),
159 Status { status: u16, body: ErrorBody },
162}
163
164impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
165 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
166 match self {
167 ClientError::Transport(error) => write!(f, "transport error: {error}"),
168 ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
169 ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
170 ClientError::Status { status, body } => {
171 write!(
172 f,
173 "http status {status} ({:?}): {}",
174 body.code, body.message
175 )
176 }
177 }
178 }
179}
180
181impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
182
183impl<E> ClientError<E> {
184 pub fn code(&self) -> Option<ResultCode> {
188 match self {
189 ClientError::Api(body) | ClientError::Status { body, .. } => Some(body.code),
190 _ => None,
191 }
192 }
193}
194
195type ClientResult<T, E> = Result<T, ClientError<E>>;
196
197#[derive(Clone, Debug)]
205pub struct HttpClient<T> {
206 transport: T,
207}
208
209impl<T: Transport> HttpClient<T> {
210 pub fn new(transport: T) -> Self {
212 Self { transport }
213 }
214
215 pub fn transport(&self) -> &T {
217 &self.transport
218 }
219
220 pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
222 self.get(http::CAPABILITIES_PATH.to_owned()).await
223 }
224
225 pub async fn query(&self, query: &Query) -> ClientResult<QueryExecutionView, T::Error> {
227 self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
228 .await
229 }
230
231 pub async fn query_page(
232 &self,
233 execution_id: crate::query::QueryExecutionId,
234 body: &QueryPageBody,
235 ) -> ClientResult<QueryResult, T::Error> {
236 self.send_json(Method::Post, http::query_page_path(execution_id), body)
237 .await
238 }
239
240 pub async fn query_status(
241 &self,
242 execution_id: crate::query::QueryExecutionId,
243 ) -> ClientResult<QueryExecutionView, T::Error> {
244 self.get(http::query_execution_path(execution_id)).await
245 }
246
247 pub async fn cancel_query(
248 &self,
249 execution_id: crate::query::QueryExecutionId,
250 ) -> ClientResult<crate::query::QueryExecutionStatus, T::Error> {
251 self.send_empty(Method::Post, http::query_cancel_path(execution_id))
252 .await
253 }
254
255 pub async fn list_destinations(
256 &self,
257 query: &DestinationListQuery,
258 ) -> ClientResult<DestinationPageView, T::Error> {
259 self.get(with_query(http::DESTINATIONS_PATH, query)?).await
260 }
261
262 pub async fn destination(
263 &self,
264 id: DestinationId,
265 consistency: CheckpointReadConsistency,
266 ) -> ClientResult<Option<DestinationView>, T::Error> {
267 self.get_optional(with_query(
268 &http::destination_path(id),
269 &CheckpointReadQuery::new(consistency),
270 )?)
271 .await
272 }
273
274 pub async fn create_destination(
275 &self,
276 body: &DestinationMutationBody,
277 ) -> ClientResult<AcceptedOperationView, T::Error> {
278 self.send_json(Method::Post, http::DESTINATIONS_PATH.to_owned(), body)
279 .await
280 }
281
282 pub async fn enable_destination(
283 &self,
284 id: DestinationId,
285 body: &DestinationMutationBody,
286 ) -> ClientResult<AcceptedOperationView, T::Error> {
287 self.send_json(Method::Post, http::destination_enable_path(id), body)
288 .await
289 }
290
291 pub async fn disable_destination(
292 &self,
293 id: DestinationId,
294 body: &DestinationMutationBody,
295 ) -> ClientResult<AcceptedOperationView, T::Error> {
296 self.send_json(Method::Post, http::destination_disable_path(id), body)
297 .await
298 }
299
300 pub async fn destination_operation(
301 &self,
302 id: DestinationOperationId,
303 ) -> ClientResult<AcceptedOperationView, T::Error> {
304 self.get(http::destination_operation_path(id)).await
305 }
306
307 pub async fn destination_status(
308 &self,
309 id: DestinationId,
310 consistency: CheckpointReadConsistency,
311 ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
312 self.get(with_query(
313 &http::destination_status_path(id),
314 &CheckpointReadQuery::new(consistency),
315 )?)
316 .await
317 }
318
319 pub async fn destination_checkpoint(
320 &self,
321 id: DestinationId,
322 consistency: CheckpointReadConsistency,
323 ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
324 self.get(with_query(
325 &http::destination_checkpoint_path(id),
326 &CheckpointReadQuery::new(consistency),
327 )?)
328 .await
329 }
330
331 pub async fn destination_retention_gap(
332 &self,
333 id: DestinationId,
334 consistency: CheckpointReadConsistency,
335 ) -> ClientResult<DestinationIssueView, T::Error> {
336 self.get(with_query(
337 &http::destination_retention_gap_path(id),
338 &CheckpointReadQuery::new(consistency),
339 )?)
340 .await
341 }
342
343 pub async fn destination_prepared_attempt(
344 &self,
345 id: DestinationId,
346 consistency: CheckpointReadConsistency,
347 ) -> ClientResult<DestinationIssueView, T::Error> {
348 self.get(with_query(
349 &http::destination_prepared_attempt_path(id),
350 &CheckpointReadQuery::new(consistency),
351 )?)
352 .await
353 }
354
355 pub async fn query_routes(
356 &self,
357 query: &QueryRouteListQuery,
358 ) -> ClientResult<QueryRoutePageView, T::Error> {
359 self.get(with_query(http::QUERY_ROUTES_PATH, query)?).await
360 }
361
362 pub async fn destination_table(&self, id: DestinationId) -> ClientResult<TableView, T::Error> {
363 self.get(http::destination_table_path(id)).await
364 }
365
366 pub async fn destination_table_schema(
367 &self,
368 id: DestinationId,
369 ) -> ClientResult<TableSchemaView, T::Error> {
370 self.get(http::destination_table_schema_path(id)).await
371 }
372
373 pub async fn destination_current_snapshot(
374 &self,
375 id: DestinationId,
376 ) -> ClientResult<TableSnapshotView, T::Error> {
377 self.get(http::destination_current_snapshot_path(id)).await
378 }
379
380 pub async fn destination_snapshots(
381 &self,
382 id: DestinationId,
383 query: &SnapshotListQuery,
384 ) -> ClientResult<SnapshotPageView, T::Error> {
385 self.get(with_query(&http::destination_snapshots_path(id), query)?)
386 .await
387 }
388
389 pub async fn destination_snapshot(
390 &self,
391 id: DestinationId,
392 snapshot_id: i64,
393 ) -> ClientResult<TableSnapshotView, T::Error> {
394 self.get(http::destination_snapshot_path(id, snapshot_id))
395 .await
396 }
397
398 pub async fn destination_files(
399 &self,
400 id: DestinationId,
401 query: &TableFileListQuery,
402 ) -> ClientResult<TableFilePageView, T::Error> {
403 self.get(with_query(&http::destination_files_path(id), query)?)
404 .await
405 }
406
407 pub async fn destination_metrics(
408 &self,
409 id: DestinationId,
410 ) -> ClientResult<TableMetricsView, T::Error> {
411 self.get(http::destination_metrics_path(id)).await
412 }
413
414 pub async fn list_projections(
417 &self,
418 filter: &ProjectionListQuery,
419 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
420 self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
421 }
422
423 pub async fn list_schemas(
425 &self,
426 filter: &SchemaListQuery,
427 ) -> ClientResult<Vec<SchemaInfo>, T::Error> {
428 self.get(with_query(http::SCHEMAS_PATH, filter)?).await
429 }
430
431 pub async fn register_schema(
435 &self,
436 source: SchemaSource,
437 name: Option<String>,
438 version: Option<u32>,
439 stream: Option<String>,
440 ) -> ClientResult<u32, T::Error> {
441 let body = http::RegisterSchemaBody {
442 source,
443 name,
444 version,
445 stream,
446 };
447 self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
448 .await
449 }
450
451 pub async fn kv_get(
457 &self,
458 namespace: &str,
459 key: &[u8],
460 ) -> ClientResult<Option<KvValue>, T::Error> {
461 let path = http::kv_entry_path(namespace, &base64url_encode(key));
462 let response = self.dispatch(Method::Get, path, None).await?;
463 if response.status == 404 {
464 return Ok(None);
465 }
466 if !(200..300).contains(&response.status) {
467 return Err(api_error(&response));
468 }
469 let expires_at_micros = response
470 .header(http::KV_EXPIRES_AT_MICROS_HEADER)
471 .and_then(|value| value.parse::<u64>().ok());
472 Ok(Some(KvValue {
473 value: response.body,
474 expires_at_micros,
475 }))
476 }
477
478 pub async fn kv_set(
481 &self,
482 namespace: &str,
483 key: &[u8],
484 value: &[u8],
485 expires_at_micros: Option<u64>,
486 ) -> ClientResult<(), T::Error> {
487 self.kv_set_with(namespace, key, value, expires_at_micros, None)
488 .await
489 }
490
491 pub async fn kv_set_as(
495 &self,
496 namespace: &str,
497 key: &[u8],
498 value: &[u8],
499 expires_at_micros: Option<u64>,
500 operation_id: NonZeroU128,
501 ) -> ClientResult<(), T::Error> {
502 self.kv_set_with(namespace, key, value, expires_at_micros, Some(operation_id))
503 .await
504 }
505
506 async fn kv_set_with(
507 &self,
508 namespace: &str,
509 key: &[u8],
510 value: &[u8],
511 expires_at_micros: Option<u64>,
512 operation_id: Option<NonZeroU128>,
513 ) -> ClientResult<(), T::Error> {
514 let path = with_query(
515 &http::kv_entry_path(namespace, &base64url_encode(key)),
516 &KvPutQuery { expires_at_micros },
517 )?;
518 let path = operation_route(path, operation_id)?;
519 self.expect_ok(Method::Put, path, Some(value.to_vec()))
522 .await
523 }
524
525 pub async fn kv_cas(
533 &self,
534 namespace: &str,
535 key: &[u8],
536 value: &[u8],
537 expect: CasExpect,
538 expires_at_micros: Option<u64>,
539 ) -> ClientResult<u64, T::Error> {
540 self.kv_cas_with(namespace, key, value, expect, expires_at_micros, None)
541 .await
542 }
543
544 pub async fn kv_cas_as(
548 &self,
549 namespace: &str,
550 key: &[u8],
551 value: &[u8],
552 expect: CasExpect,
553 expires_at_micros: Option<u64>,
554 operation_id: NonZeroU128,
555 ) -> ClientResult<u64, T::Error> {
556 self.kv_cas_with(
557 namespace,
558 key,
559 value,
560 expect,
561 expires_at_micros,
562 Some(operation_id),
563 )
564 .await
565 }
566
567 async fn kv_cas_with(
568 &self,
569 namespace: &str,
570 key: &[u8],
571 value: &[u8],
572 expect: CasExpect,
573 expires_at_micros: Option<u64>,
574 operation_id: Option<NonZeroU128>,
575 ) -> ClientResult<u64, T::Error> {
576 let (expect_version, expect_absent) = match expect {
577 CasExpect::Match(version) => (Some(version), None),
578 CasExpect::Absent => (None, Some(true)),
579 };
580 let path = with_query(
581 &http::kv_cas_path(namespace, &base64url_encode(key)),
582 &KvCasQuery {
583 expect_version,
584 expect_absent,
585 expires_at_micros,
586 },
587 )?;
588 let path = operation_route(path, operation_id)?;
589 let response = self
591 .dispatch(Method::Put, path, Some(value.to_vec()))
592 .await?;
593 let view: CasCommittedView = decode_ok(&response)?;
594 Ok(view.version)
595 }
596
597 pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
599 let path = http::kv_entry_path(namespace, &base64url_encode(key));
600 self.send_empty(Method::Delete, path).await
601 }
602
603 pub async fn kv_delete_as(
605 &self,
606 namespace: &str,
607 key: &[u8],
608 operation_id: NonZeroU128,
609 ) -> ClientResult<bool, T::Error> {
610 let path = http::kv_entry_path(namespace, &base64url_encode(key));
611 self.send_empty(Method::Delete, with_operation(&path, operation_id)?)
612 .await
613 }
614
615 pub async fn kv_scan(
617 &self,
618 namespace: &str,
619 filter: &KvScanQuery,
620 ) -> ClientResult<KvPageView, T::Error> {
621 self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
622 .await
623 }
624
625 pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
627 self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
628 .await
629 }
630
631 pub async fn create_fork_as(
633 &self,
634 body: &ForkCreateBody,
635 operation_id: NonZeroU128,
636 ) -> ClientResult<ForkInfo, T::Error> {
637 self.send_json(
638 Method::Post,
639 with_operation(http::FORKS_PATH, operation_id)?,
640 body,
641 )
642 .await
643 }
644
645 pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
647 self.get(http::FORKS_PATH.to_owned()).await
648 }
649
650 pub async fn clients(
654 &self,
655 query: &ClientsQuery,
656 ) -> ClientResult<ClientMetadataListView, T::Error> {
657 self.get(with_query(http::CLIENTS_PATH, query)?).await
658 }
659
660 pub async fn list_sessions(
663 &self,
664 stream: &str,
665 query: &SessionsQuery,
666 ) -> ClientResult<crate::session::SessionPage, T::Error> {
667 self.get(with_query(&http::sessions_path(stream), query)?)
668 .await
669 }
670
671 pub async fn session(
674 &self,
675 stream: &str,
676 id: crate::agent::ConversationId,
677 ) -> ClientResult<Option<crate::session::SessionInfo>, T::Error> {
678 self.get_optional(http::session_path(stream, id)).await
679 }
680
681 pub async fn session_events(
684 &self,
685 stream: &str,
686 id: crate::agent::ConversationId,
687 query: &SessionEventsQuery,
688 ) -> ClientResult<crate::session::SessionEventsPage, T::Error> {
689 self.get(with_query(&http::session_events_path(stream, id), query)?)
690 .await
691 }
692
693 pub async fn session_state(
695 &self,
696 stream: &str,
697 id: crate::agent::ConversationId,
698 query: &SessionStateQuery,
699 ) -> ClientResult<crate::session::SessionStateView, T::Error> {
700 self.get(with_query(&http::session_state_path(stream, id), query)?)
701 .await
702 }
703
704 pub async fn session_links(
707 &self,
708 stream: &str,
709 id: crate::agent::ConversationId,
710 query: &SessionLinksQuery,
711 ) -> ClientResult<crate::session::SessionLinksView, T::Error> {
712 self.get(with_query(&http::session_links_path(stream, id), query)?)
713 .await
714 }
715
716 pub async fn session_sources(
719 &self,
720 stream: &str,
721 id: crate::agent::ConversationId,
722 ) -> ClientResult<crate::session::SessionSources, T::Error> {
723 self.get(http::session_sources_path(stream, id)).await
724 }
725
726 pub async fn session_changes(
729 &self,
730 stream: &str,
731 query: &SessionChangesQuery,
732 ) -> ClientResult<crate::session::SessionChanges, T::Error> {
733 self.get(with_query(&http::session_changes_path(stream), query)?)
734 .await
735 }
736
737 pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
740 self.get_optional(http::projection_path(id)).await
741 }
742
743 pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
746 self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
747 .await
748 }
749
750 pub async fn register_projection_as(
754 &self,
755 projection: &Projection,
756 operation_id: NonZeroU128,
757 ) -> ClientResult<(), T::Error> {
758 let path = with_operation(http::PROJECTIONS_PATH, operation_id)?;
759 self.send_json_ok(Method::Post, path, projection).await
760 }
761
762 pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
764 self.expect_ok(Method::Delete, http::projection_path(id), None)
765 .await
766 }
767
768 pub async fn drop_projection_as(
771 &self,
772 id: &str,
773 operation_id: NonZeroU128,
774 ) -> ClientResult<(), T::Error> {
775 let path = with_operation(&http::projection_path(id), operation_id)?;
776 self.expect_ok(Method::Delete, path, None).await
777 }
778
779 pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
781 self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
782 .await
783 }
784
785 pub async fn apply_binding_as(
788 &self,
789 binding: &ProjectionBinding,
790 operation_id: NonZeroU128,
791 ) -> ClientResult<(), T::Error> {
792 let path = with_operation(http::BINDINGS_PATH, operation_id)?;
793 self.send_json_ok(Method::Post, path, binding).await
794 }
795
796 pub async fn remove_binding(
799 &self,
800 source: &SourceSelector,
801 projection_ref: Option<String>,
802 ) -> ClientResult<(), T::Error> {
803 let body = RemoveBindingBody {
804 stream: source.stream.clone(),
805 topic: source.topic.clone(),
806 projection_ref,
807 };
808 self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
809 .await
810 }
811
812 pub async fn remove_binding_as(
815 &self,
816 source: &SourceSelector,
817 projection_ref: Option<String>,
818 operation_id: NonZeroU128,
819 ) -> ClientResult<(), T::Error> {
820 let body = RemoveBindingBody {
821 stream: source.stream.clone(),
822 topic: source.topic.clone(),
823 projection_ref,
824 };
825 let path = with_operation(http::BINDINGS_PATH, operation_id)?;
826 self.send_json_ok(Method::Delete, path, &body).await
827 }
828
829 pub async fn get_schema(
833 &self,
834 id: u32,
835 stream: Option<&str>,
836 ) -> ClientResult<Option<SchemaInfo>, T::Error> {
837 self.get_optional(schema_route(http::schema_path(id), stream)?)
838 .await
839 }
840
841 pub async fn drop_schema(&self, id: u32, stream: Option<&str>) -> ClientResult<(), T::Error> {
844 self.expect_ok(
845 Method::Delete,
846 schema_route(http::schema_path(id), stream)?,
847 None,
848 )
849 .await
850 }
851
852 pub async fn drop_schema_as(
854 &self,
855 id: u32,
856 stream: Option<&str>,
857 operation_id: NonZeroU128,
858 ) -> ClientResult<(), T::Error> {
859 let path = schema_route(http::schema_path(id), stream)?;
860 self.expect_ok(Method::Delete, with_operation(&path, operation_id)?, None)
861 .await
862 }
863
864 pub async fn decode_record(
868 &self,
869 id: u32,
870 payload: &[u8],
871 stream: Option<&str>,
872 ) -> ClientResult<Option<serde_json::Value>, T::Error> {
873 let body = DecodeRecordBody {
874 payload: base64url_encode(payload),
875 };
876 self.send_json(
877 Method::Post,
878 schema_route(http::schema_decode_path(id), stream)?,
879 &body,
880 )
881 .await
882 }
883
884 pub async fn list_filters(
886 &self,
887 query: &FilterListQuery,
888 ) -> ClientResult<FilterPage, T::Error> {
889 self.get(with_query(http::FILTERS_PATH, query)?).await
890 }
891
892 pub async fn get_filter(&self, id: u32) -> ClientResult<Option<FilterDetail>, T::Error> {
894 self.get_optional(http::filter_path(id)).await
895 }
896
897 pub async fn list_filter_revisions(
899 &self,
900 id: u32,
901 query: &FilterRevisionListQuery,
902 ) -> ClientResult<FilterRevisionPage, T::Error> {
903 self.get(with_query(&http::filter_revisions_path(id), query)?)
904 .await
905 }
906
907 pub async fn mutate_filter(
912 &self,
913 request: &FilterMutationRequest,
914 ) -> ClientResult<FilterMutationOutcome, T::Error> {
915 let payload = serde_json::to_vec(request)
916 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
917 let response = self
918 .dispatch(
919 Method::Post,
920 http::FILTER_MUTATIONS_PATH.to_owned(),
921 Some(payload),
922 )
923 .await?;
924 if let Ok(outcome) = serde_json::from_slice::<FilterMutationOutcome>(&response.body) {
925 return Ok(outcome);
926 }
927 decode_ok(&response)
928 }
929
930 pub async fn filter_operation(
933 &self,
934 operation_id: u128,
935 ) -> ClientResult<Option<FilterMutationOutcome>, T::Error> {
936 self.get_optional(http::filter_operation_path(operation_id))
937 .await
938 }
939
940 pub async fn validate_filter(
942 &self,
943 filter: &ConsumerFilter,
944 ) -> ClientResult<FilterValidation, T::Error> {
945 self.send_json(Method::Post, http::FILTER_VALIDATE_PATH.to_owned(), filter)
946 .await
947 }
948
949 pub async fn test_filter(
952 &self,
953 body: &FilterTestBody,
954 ) -> ClientResult<FilterTestResult, T::Error> {
955 self.send_json(Method::Post, http::FILTER_TEST_PATH.to_owned(), body)
956 .await
957 }
958
959 pub async fn preview_filter(
962 &self,
963 request: &FilterPreviewRequest,
964 ) -> ClientResult<FilterPreview, T::Error> {
965 self.send_json(Method::Post, http::FILTER_PREVIEW_PATH.to_owned(), request)
966 .await
967 }
968
969 pub async fn list_filter_bindings(
971 &self,
972 query: &FilterBindingListQuery,
973 ) -> ClientResult<FilterBindingPage, T::Error> {
974 self.get(with_query(http::FILTER_BINDINGS_PATH, query)?)
975 .await
976 }
977
978 pub async fn get_filter_binding(
981 &self,
982 stream: &str,
983 topic: &str,
984 group: &str,
985 ) -> ClientResult<Option<FilterBinding>, T::Error> {
986 let response = self
987 .dispatch(
988 Method::Get,
989 http::filter_binding_path(stream, topic, group),
990 None,
991 )
992 .await?;
993 if response.status == 404 && response.body.is_empty() {
994 return Ok(None);
995 }
996 decode_ok(&response).map(Some)
997 }
998
999 pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
1001 self.get(http::KV_PATH.to_owned()).await
1002 }
1003
1004 pub async fn kv_delete_many(
1007 &self,
1008 namespace: &str,
1009 filter: &KvScanQuery,
1010 ) -> ClientResult<usize, T::Error> {
1011 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
1012 let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
1013 Ok(view.deleted)
1014 }
1015
1016 pub async fn kv_delete_many_as(
1019 &self,
1020 namespace: &str,
1021 filter: &KvScanQuery,
1022 operation_id: NonZeroU128,
1023 ) -> ClientResult<usize, T::Error> {
1024 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
1025 let view: DeletedManyView = self
1026 .send_empty(Method::Delete, with_operation(&path, operation_id)?)
1027 .await?;
1028 Ok(view.deleted)
1029 }
1030
1031 pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
1034 let view: PromotedView = self
1035 .send_empty(Method::Post, http::fork_promote_path(id))
1036 .await?;
1037 Ok(view.rows)
1038 }
1039
1040 pub async fn promote_fork_as(
1042 &self,
1043 id: &str,
1044 operation_id: NonZeroU128,
1045 ) -> ClientResult<usize, T::Error> {
1046 let path = with_operation(&http::fork_promote_path(id), operation_id)?;
1047 let view: PromotedView = self.send_empty(Method::Post, path).await?;
1048 Ok(view.rows)
1049 }
1050
1051 pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
1053 self.expect_ok(Method::Delete, http::fork_path(id), None)
1054 .await
1055 }
1056
1057 pub async fn delete_fork_as(
1059 &self,
1060 id: &str,
1061 operation_id: NonZeroU128,
1062 ) -> ClientResult<(), T::Error> {
1063 let path = with_operation(&http::fork_path(id), operation_id)?;
1064 self.expect_ok(Method::Delete, path, None).await
1065 }
1066
1067 pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
1069 self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
1070 .await
1071 }
1072
1073 pub async fn put_fork_row_as(
1075 &self,
1076 id: &str,
1077 body: &ForkPutBody,
1078 operation_id: NonZeroU128,
1079 ) -> ClientResult<(), T::Error> {
1080 let path = with_operation(&http::fork_rows_path(id), operation_id)?;
1081 self.send_json_ok(Method::Put, path, body).await
1082 }
1083
1084 pub async fn graph_query(
1088 &self,
1089 name: &str,
1090 query: &GraphQuery,
1091 ) -> ClientResult<GraphResultView, T::Error> {
1092 self.send_json(Method::Post, http::graph_query_path(name), query)
1093 .await
1094 }
1095
1096 pub async fn graph_neighbors(
1101 &self,
1102 name: &str,
1103 node: &str,
1104 query: &GraphNeighborsQuery,
1105 ) -> ClientResult<GraphResultView, T::Error> {
1106 self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
1107 .await
1108 }
1109
1110 pub async fn list_graphs(
1114 &self,
1115 filter: &ProjectionListQuery,
1116 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
1117 self.get(with_query(http::GRAPHS_PATH, filter)?).await
1118 }
1119
1120 pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
1124 self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
1125 .await
1126 }
1127
1128 pub async fn register_graph_as(
1131 &self,
1132 projection: &Projection,
1133 operation_id: NonZeroU128,
1134 ) -> ClientResult<(), T::Error> {
1135 let path = with_operation(http::GRAPHS_PATH, operation_id)?;
1136 self.send_json_ok(Method::Post, path, projection).await
1137 }
1138
1139 pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
1142 self.get_optional(http::graph_path(id)).await
1143 }
1144
1145 pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
1148 self.expect_ok(Method::Delete, http::graph_path(id), None)
1149 .await
1150 }
1151
1152 pub async fn drop_graph_as(
1154 &self,
1155 id: &str,
1156 operation_id: NonZeroU128,
1157 ) -> ClientResult<(), T::Error> {
1158 let path = with_operation(&http::graph_path(id), operation_id)?;
1159 self.expect_ok(Method::Delete, path, None).await
1160 }
1161
1162 pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
1166 self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
1167 }
1168
1169 pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
1171 self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
1172 }
1173
1174 pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
1176 self.get_optional(http::authz_role_path(name)).await
1177 }
1178
1179 pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
1182 self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
1183 .await
1184 }
1185
1186 pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
1188 self.expect_ok(Method::Delete, http::authz_role_path(name), None)
1189 .await
1190 }
1191
1192 pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
1194 self.get(http::authz_user_roles_path(user_id)).await
1195 }
1196
1197 pub async fn bind_user_roles(
1199 &self,
1200 user_id: u32,
1201 roles: &[String],
1202 ) -> ClientResult<(), T::Error> {
1203 self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
1204 .await
1205 }
1206
1207 async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
1208 let response = self.dispatch(Method::Get, path, None).await?;
1209 decode_ok(&response)
1210 }
1211
1212 async fn get_optional<R: DeserializeOwned>(
1214 &self,
1215 path: String,
1216 ) -> ClientResult<Option<R>, T::Error> {
1217 let response = self.dispatch(Method::Get, path, None).await?;
1218 if response.status == 404 {
1219 return Ok(None);
1220 }
1221 decode_ok(&response).map(Some)
1222 }
1223
1224 async fn send_json<B: Serialize, R: DeserializeOwned>(
1225 &self,
1226 method: Method,
1227 path: String,
1228 body: &B,
1229 ) -> ClientResult<R, T::Error> {
1230 let payload = serde_json::to_vec(body)
1231 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1232 let response = self.dispatch(method, path, Some(payload)).await?;
1233 decode_ok(&response)
1234 }
1235
1236 async fn send_empty<R: DeserializeOwned>(
1237 &self,
1238 method: Method,
1239 path: String,
1240 ) -> ClientResult<R, T::Error> {
1241 let response = self.dispatch(method, path, None).await?;
1242 decode_ok(&response)
1243 }
1244
1245 async fn send_json_ok<B: Serialize>(
1248 &self,
1249 method: Method,
1250 path: String,
1251 body: &B,
1252 ) -> ClientResult<(), T::Error> {
1253 let payload = serde_json::to_vec(body)
1254 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1255 let response = self.dispatch(method, path, Some(payload)).await?;
1256 check_status(&response)
1257 }
1258
1259 async fn expect_ok(
1260 &self,
1261 method: Method,
1262 path: String,
1263 body: Option<Vec<u8>>,
1264 ) -> ClientResult<(), T::Error> {
1265 let response = self.dispatch(method, path, body).await?;
1266 check_status(&response)
1267 }
1268
1269 async fn dispatch(
1270 &self,
1271 method: Method,
1272 path: String,
1273 body: Option<Vec<u8>>,
1274 ) -> ClientResult<HttpResponse, T::Error> {
1275 self.transport
1276 .send(HttpRequest { method, path, body })
1277 .await
1278 .map_err(ClientError::Transport)
1279 }
1280}
1281
1282fn schema_route<E>(path: String, stream: Option<&str>) -> Result<String, ClientError<E>> {
1285 with_query(
1286 &path,
1287 &http::SchemaQuery {
1288 stream: stream.map(str::to_owned),
1289 },
1290 )
1291}
1292
1293fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
1295 let query = serde_urlencoded::to_string(params)
1296 .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
1297 if query.is_empty() {
1298 Ok(path.to_owned())
1299 } else if path.contains('?') {
1300 Ok(format!("{path}&{query}"))
1301 } else {
1302 Ok(format!("{path}?{query}"))
1303 }
1304}
1305
1306fn with_operation<E>(path: &str, operation_id: NonZeroU128) -> Result<String, ClientError<E>> {
1307 with_query(path, &OperationQuery::from(operation_id))
1308}
1309
1310fn operation_route<E>(
1311 path: String,
1312 operation_id: Option<NonZeroU128>,
1313) -> Result<String, ClientError<E>> {
1314 match operation_id {
1315 Some(operation_id) => with_operation(&path, operation_id),
1316 None => Ok(path),
1317 }
1318}
1319
1320fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
1324 if (200..300).contains(&response.status) {
1325 serde_json::from_slice(&response.body)
1326 .map_err(|error| ClientError::Decode(format!("response body: {error}")))
1327 } else {
1328 Err(api_error(response))
1329 }
1330}
1331
1332fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
1334 if (200..300).contains(&response.status) {
1335 Ok(())
1336 } else {
1337 Err(api_error(response))
1338 }
1339}
1340
1341fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
1342 match serde_json::from_slice::<ErrorBody>(&response.body) {
1343 Ok(body) => ClientError::Api(body),
1344 Err(_) => ClientError::Status {
1345 status: response.status,
1346 body: ErrorBody::new(
1347 code_for_status(response.status),
1348 String::from_utf8_lossy(&response.body).into_owned(),
1349 ),
1350 },
1351 }
1352}
1353
1354fn code_for_status(status: u16) -> ResultCode {
1358 match status {
1359 404 => ResultCode::NotFound,
1360 400 => ResultCode::InvalidArgument,
1361 401 => ResultCode::Unauthenticated,
1362 403 => ResultCode::Forbidden,
1363 409 => ResultCode::Conflict,
1364 413 => ResultCode::TooLarge,
1365 501 => ResultCode::Unsupported,
1366 503 => ResultCode::Stale,
1367 _ => ResultCode::Backend,
1368 }
1369}
1370
1371const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1375
1376pub fn base64url_encode(input: &[u8]) -> String {
1378 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1379 for chunk in input.chunks(3) {
1380 let b0 = chunk[0] as usize;
1381 out.push(B64URL[b0 >> 2] as char);
1382 match chunk.len() {
1383 1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
1384 2 => {
1385 let b1 = chunk[1] as usize;
1386 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1387 out.push(B64URL[(b1 & 0b1111) << 2] as char);
1388 }
1389 _ => {
1390 let b1 = chunk[1] as usize;
1391 let b2 = chunk[2] as usize;
1392 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1393 out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
1394 out.push(B64URL[b2 & 0b111111] as char);
1395 }
1396 }
1397 }
1398 out
1399}
1400
1401pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
1404 fn val(byte: u8) -> Option<u8> {
1405 match byte {
1406 b'A'..=b'Z' => Some(byte - b'A'),
1407 b'a'..=b'z' => Some(byte - b'a' + 26),
1408 b'0'..=b'9' => Some(byte - b'0' + 52),
1409 b'-' => Some(62),
1410 b'_' => Some(63),
1411 _ => None,
1412 }
1413 }
1414 let bytes = input.as_bytes();
1415 if bytes.len() % 4 == 1 {
1416 return None;
1417 }
1418 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1419 for chunk in bytes.chunks(4) {
1420 let mut acc = 0u32;
1421 for &byte in chunk {
1422 acc = (acc << 6) | u32::from(val(byte)?);
1423 }
1424 acc <<= 6 * (4 - chunk.len());
1426 match chunk.len() {
1427 2 => out.push((acc >> 16) as u8),
1428 3 => {
1429 out.push((acc >> 16) as u8);
1430 out.push((acc >> 8) as u8);
1431 }
1432 _ => {
1433 out.push((acc >> 16) as u8);
1434 out.push((acc >> 8) as u8);
1435 out.push(acc as u8);
1436 }
1437 }
1438 }
1439 Some(out)
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445 use crate::agent::ConversationId;
1446
1447 #[test]
1448 fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
1449 for case in [
1450 &b""[..],
1451 &b"f"[..],
1452 &b"fo"[..],
1453 &b"foo"[..],
1454 &b"foob"[..],
1455 &b"fooba"[..],
1456 &b"foobar"[..],
1457 &[0x00, 0xff, 0x10, 0x80][..],
1458 ] {
1459 let encoded = base64url_encode(case);
1460 assert!(
1461 !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1462 "url-safe unpadded: {encoded}"
1463 );
1464 assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1465 }
1466 }
1467
1468 #[test]
1469 fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1470 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1471 assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1472 }
1473
1474 #[test]
1475 fn given_checkpoint_reads_when_paths_are_built_then_should_require_the_selected_consistency() {
1476 let single = with_query::<(), _>(
1477 &http::destination_status_path(DestinationId::from_u128(1)),
1478 &CheckpointReadQuery::new(CheckpointReadConsistency::Linearizable),
1479 )
1480 .expect("single checkpoint read query encodes");
1481 assert!(single.ends_with("?consistency=linearizable"));
1482
1483 let list = with_query::<(), _>(
1484 http::DESTINATIONS_PATH,
1485 &DestinationListQuery {
1486 source_stream: None,
1487 source_topic: None,
1488 name_contains: None,
1489 after: None,
1490 limit: None,
1491 consistency: CheckpointReadConsistency::PotentiallyStale,
1492 },
1493 )
1494 .expect("checkpoint list query encodes");
1495 assert!(list.ends_with("?consistency=potentially_stale"));
1496 }
1497
1498 #[test]
1499 fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1500 assert!(
1501 base64url_decode("====").is_none(),
1502 "padding is not alphabet"
1503 );
1504 assert!(
1505 base64url_decode("A").is_none(),
1506 "a lone char carries no byte"
1507 );
1508 assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1509 }
1510
1511 struct CannedTransport {
1514 response: HttpResponse,
1515 }
1516
1517 impl Transport for CannedTransport {
1518 type Error = std::convert::Infallible;
1519 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1520 Ok(self.response.clone())
1521 }
1522 }
1523
1524 #[derive(Default)]
1527 struct RecordingTransport {
1528 requests: std::cell::RefCell<Vec<HttpRequest>>,
1529 }
1530
1531 impl Transport for RecordingTransport {
1532 type Error = std::convert::Infallible;
1533 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1534 self.requests.borrow_mut().push(request);
1535 Ok(HttpResponse::new(202, Vec::new()))
1536 }
1537 }
1538
1539 fn block_on<F: core::future::Future>(future: F) -> F::Output {
1540 use core::task::{Context, Poll, Waker};
1544 let mut context = Context::from_waker(Waker::noop());
1545 let mut future = core::pin::pin!(future);
1546 loop {
1547 if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1548 return output;
1549 }
1550 }
1551 }
1552
1553 #[test]
1554 fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1555 let body = serde_json::to_vec(&Capabilities::new(
1556 true,
1557 crate::hello::OpVersions::new(1, 1, 1, 1),
1558 ))
1559 .unwrap();
1560 let client = HttpClient::new(CannedTransport {
1561 response: HttpResponse::new(200, body),
1562 });
1563 let caps = block_on(client.capabilities()).expect("decodes");
1564 assert!(caps.managed && !caps.kv.cas);
1565 }
1566
1567 #[test]
1568 fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1569 let body =
1570 serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1571 let client = HttpClient::new(CannedTransport {
1572 response: HttpResponse::new(404, body),
1573 });
1574 let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1575 assert_eq!(error.code(), Some(ResultCode::NotFound));
1576 }
1577
1578 #[test]
1579 fn given_a_missing_binding_when_fetched_then_should_distinguish_absence_from_missing_source() {
1580 let absent = HttpClient::new(CannedTransport {
1581 response: HttpResponse::new(404, Vec::new()),
1582 });
1583 assert!(
1584 block_on(absent.get_filter_binding("stream", "topic", "group"))
1585 .unwrap()
1586 .is_none()
1587 );
1588 let body = serde_json::to_vec(&ErrorBody::new(
1589 ResultCode::NotFound,
1590 "topic does not exist",
1591 ))
1592 .unwrap();
1593 let missing_source = HttpClient::new(CannedTransport {
1594 response: HttpResponse::new(404, body),
1595 });
1596 let error =
1597 block_on(missing_source.get_filter_binding("stream", "topic", "group")).unwrap_err();
1598 assert_eq!(error.code(), Some(ResultCode::NotFound));
1599 }
1600
1601 #[test]
1602 fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
1603 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1604 let client = HttpClient::new(CannedTransport {
1605 response: HttpResponse::new(404, body),
1606 });
1607 let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1608 assert!(entry.is_none());
1609 }
1610
1611 #[test]
1612 fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
1613 let response = HttpResponse::new(200, b"world".to_vec())
1614 .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
1615 let client = HttpClient::new(CannedTransport { response });
1616 let entry = block_on(client.kv_get("sessions", b"user:1"))
1617 .expect("decodes")
1618 .expect("present");
1619 assert_eq!(entry.value, b"world");
1620 assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1621 }
1622
1623 #[test]
1624 fn given_a_missing_projection_when_fetched_then_should_be_none() {
1625 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1626 let client = HttpClient::new(CannedTransport {
1627 response: HttpResponse::new(404, body),
1628 });
1629 let info = block_on(client.get_projection("reading.v1")).expect("404 maps to None");
1630 assert!(info.is_none());
1631 }
1632
1633 #[test]
1634 fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1635 let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1636 let client = HttpClient::new(CannedTransport {
1637 response: HttpResponse::new(200, body),
1638 });
1639 let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1640 .expect("decodes the count");
1641 assert_eq!(removed, 7);
1642 }
1643
1644 #[test]
1645 fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1646 let client = HttpClient::new(CannedTransport {
1647 response: HttpResponse::new(204, Vec::new()),
1648 });
1649 block_on(client.drop_projection("reading.v1")).expect("a 204 is a success");
1650 }
1651
1652 #[test]
1653 fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1654 let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1655 let client = HttpClient::new(CannedTransport {
1656 response: HttpResponse::new(200, body),
1657 });
1658 let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1659 .expect("a commit returns the new version");
1660 assert_eq!(version, 4);
1661 }
1662
1663 #[test]
1664 fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1665 let reply = WhoamiReply {
1666 v: 1,
1667 roles: vec!["admin".to_owned()],
1668 grants: vec![crate::authz::Grant {
1669 effect: crate::authz::Effect::Allow,
1670 feature: crate::authz::Feature::Authz,
1671 action: crate::authz::Action::Admin,
1672 resource: crate::authz::ResourcePattern::all(),
1673 }],
1674 };
1675 let body = serde_json::to_vec(&reply).unwrap();
1676 let client = HttpClient::new(CannedTransport {
1677 response: HttpResponse::new(200, body),
1678 });
1679 let whoami = block_on(client.authz_whoami()).expect("decodes");
1680 assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1681 assert_eq!(whoami.grants.len(), 1);
1682 }
1683
1684 #[test]
1685 fn given_a_missing_role_when_fetched_then_should_be_none() {
1686 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1687 let client = HttpClient::new(CannedTransport {
1688 response: HttpResponse::new(404, body),
1689 });
1690 let role = block_on(client.get_role("ghost")).expect("404 maps to None");
1691 assert!(role.is_none());
1692 }
1693
1694 #[test]
1695 fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1696 let body = serde_json::to_vec(
1697 &ErrorBody::new(ResultCode::Conflict, "version conflict")
1698 .with_detail(serde_json::json!({ "current": 3 })),
1699 )
1700 .unwrap();
1701 let client = HttpClient::new(CannedTransport {
1702 response: HttpResponse::new(409, body),
1703 });
1704 let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1705 .expect_err("a precondition miss is an error");
1706 assert_eq!(error.code(), Some(ResultCode::Conflict));
1707 }
1708
1709 #[test]
1710 fn given_a_caller_operation_id_when_writing_the_registry_then_should_ride_the_query_string() {
1711 let operation_id = NonZeroU128::new(42).expect("non-zero");
1712 let client = HttpClient::new(RecordingTransport::default());
1713 let projection = Projection::builder("reading.v1").build();
1714 let source = SourceSelector::new("telemetry", "events");
1715
1716 block_on(client.register_projection_as(&projection, operation_id)).expect("register");
1717 block_on(client.drop_projection_as("reading.v1", operation_id)).expect("drop");
1718 block_on(client.remove_binding_as(&source, None, operation_id)).expect("remove");
1719 block_on(client.drop_schema_as(7, Some("telemetry"), operation_id)).expect("drop schema");
1720 block_on(client.drop_schema_as(7, None, operation_id)).expect("drop shared schema");
1721 block_on(client.register_graph_as(&projection, operation_id)).expect("register graph");
1722 block_on(client.drop_graph_as("topology.v1", operation_id)).expect("drop graph");
1723
1724 let paths: Vec<String> = client
1725 .transport()
1726 .requests
1727 .borrow()
1728 .iter()
1729 .map(|request| request.path.clone())
1730 .collect();
1731 assert_eq!(
1732 paths,
1733 [
1734 "/agdx/projections?operation_id=42",
1735 "/agdx/projections/reading.v1?operation_id=42",
1736 "/agdx/bindings?operation_id=42",
1737 "/agdx/schemas/7?stream=telemetry&operation_id=42",
1738 "/agdx/schemas/7?operation_id=42",
1739 "/agdx/graphs?operation_id=42",
1740 "/agdx/graphs/topology.v1?operation_id=42",
1741 ]
1742 );
1743 }
1744
1745 #[test]
1746 fn given_a_caller_operation_id_when_writing_key_values_then_should_follow_the_other_parameters()
1747 {
1748 let operation_id = NonZeroU128::new(7).expect("non-zero");
1749 let client = HttpClient::new(RecordingTransport::default());
1750
1751 let _ = block_on(client.kv_set_as("notes", b"k", b"v", Some(10), operation_id));
1752 let _ =
1753 block_on(client.kv_cas_as("notes", b"k", b"v", CasExpect::Absent, None, operation_id));
1754 let _ = block_on(client.kv_delete_as("notes", b"k", operation_id));
1755 let _ = block_on(client.delete_fork_as("f1", operation_id));
1756
1757 let requests = client.transport().requests.borrow();
1758 assert_eq!(
1759 requests[0].path,
1760 "/agdx/kv/notes/aw?expires_at_micros=10&operation_id=7"
1761 );
1762 assert_eq!(
1763 requests[1].path,
1764 "/agdx/kv/notes/aw/cas?expect_absent=true&operation_id=7"
1765 );
1766 assert_eq!(requests[2].path, "/agdx/kv/notes/aw?operation_id=7");
1767 assert_eq!(requests[3].path, "/agdx/forks/f1?operation_id=7");
1768 }
1769
1770 #[test]
1771 fn given_no_operation_id_when_writing_then_should_send_the_bare_path() {
1772 let client = HttpClient::new(RecordingTransport::default());
1773 block_on(client.drop_projection("reading.v1")).expect("drop");
1774 block_on(client.drop_schema(7, Some("telemetry"))).expect("drop schema");
1775 let requests = client.transport().requests.borrow();
1776 assert_eq!(requests[0].path, "/agdx/projections/reading.v1");
1777 assert_eq!(requests[1].path, "/agdx/schemas/7?stream=telemetry");
1778 }
1779
1780 #[test]
1781 fn given_a_plain_text_gateway_reply_when_read_then_should_keep_its_status() {
1782 for (status, text) in [
1783 (502, "bad gateway: upstream unreachable"),
1784 (504, "gateway timeout: the request may still complete"),
1785 ] {
1786 let client = HttpClient::new(CannedTransport {
1787 response: HttpResponse::new(status, text.as_bytes().to_vec()),
1788 });
1789 let error = block_on(client.list_sessions("agents", &SessionsQuery::default()))
1790 .expect_err("a gateway failure is an error");
1791 assert_eq!(error.code(), Some(ResultCode::Backend));
1792 assert_eq!(
1793 error.to_string(),
1794 format!("http status {status} (Backend): {text}")
1795 );
1796 let ClientError::Status { status: kept, body } = error else {
1797 panic!("a body that is not an ErrorBody is a status error: {error:?}");
1798 };
1799 assert_eq!(
1800 (kept, body),
1801 (status, ErrorBody::new(ResultCode::Backend, text))
1802 );
1803 }
1804 }
1805
1806 #[test]
1807 fn given_a_typed_backend_error_with_status_502_when_read_then_should_stay_an_api_error() {
1808 let typed = ErrorBody::new(ResultCode::Backend, "session backend error: store closed");
1809 let client = HttpClient::new(CannedTransport {
1810 response: HttpResponse::new(
1811 502,
1812 serde_json::to_vec(&typed).expect("error body encodes"),
1813 ),
1814 });
1815 let error = block_on(client.list_sessions("agents", &SessionsQuery::default()))
1816 .expect_err("a typed backend error is an error");
1817 assert_eq!(error.code(), Some(ResultCode::Backend));
1818 let ClientError::Api(body) = error else {
1819 panic!("a decodable ErrorBody is an api error: {error:?}");
1820 };
1821 assert_eq!(body, typed);
1822 }
1823
1824 #[test]
1825 fn given_a_missing_session_when_read_then_should_be_none_for_any_404_body() {
1826 let typed = ErrorBody::new(ResultCode::NotFound, "session not found");
1827 for body in [
1828 serde_json::to_vec(&typed).expect("error body encodes"),
1829 b"not found".to_vec(),
1830 Vec::new(),
1831 ] {
1832 let client = HttpClient::new(CannedTransport {
1833 response: HttpResponse::new(404, body),
1834 });
1835 let session = block_on(client.session("agents", ConversationId::from_u128(2)))
1836 .expect("a 404 maps to None");
1837 assert_eq!(session, None);
1838 }
1839 }
1840}