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}
160
161impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
162 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
163 match self {
164 ClientError::Transport(error) => write!(f, "transport error: {error}"),
165 ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
166 ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
167 }
168 }
169}
170
171impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
172
173impl<E> ClientError<E> {
174 pub fn code(&self) -> Option<ResultCode> {
178 match self {
179 ClientError::Api(body) => Some(body.code),
180 _ => None,
181 }
182 }
183}
184
185type ClientResult<T, E> = Result<T, ClientError<E>>;
186
187#[derive(Clone, Debug)]
195pub struct HttpClient<T> {
196 transport: T,
197}
198
199impl<T: Transport> HttpClient<T> {
200 pub fn new(transport: T) -> Self {
202 Self { transport }
203 }
204
205 pub fn transport(&self) -> &T {
207 &self.transport
208 }
209
210 pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
212 self.get(http::CAPABILITIES_PATH.to_owned()).await
213 }
214
215 pub async fn query(&self, query: &Query) -> ClientResult<QueryExecutionView, T::Error> {
217 self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
218 .await
219 }
220
221 pub async fn query_page(
222 &self,
223 execution_id: crate::query::QueryExecutionId,
224 body: &QueryPageBody,
225 ) -> ClientResult<QueryResult, T::Error> {
226 self.send_json(Method::Post, http::query_page_path(execution_id), body)
227 .await
228 }
229
230 pub async fn query_status(
231 &self,
232 execution_id: crate::query::QueryExecutionId,
233 ) -> ClientResult<QueryExecutionView, T::Error> {
234 self.get(http::query_execution_path(execution_id)).await
235 }
236
237 pub async fn cancel_query(
238 &self,
239 execution_id: crate::query::QueryExecutionId,
240 ) -> ClientResult<crate::query::QueryExecutionStatus, T::Error> {
241 self.send_empty(Method::Post, http::query_cancel_path(execution_id))
242 .await
243 }
244
245 pub async fn list_destinations(
246 &self,
247 query: &DestinationListQuery,
248 ) -> ClientResult<DestinationPageView, T::Error> {
249 self.get(with_query(http::DESTINATIONS_PATH, query)?).await
250 }
251
252 pub async fn destination(
253 &self,
254 id: DestinationId,
255 consistency: CheckpointReadConsistency,
256 ) -> ClientResult<Option<DestinationView>, T::Error> {
257 self.get_optional(with_query(
258 &http::destination_path(id),
259 &CheckpointReadQuery::new(consistency),
260 )?)
261 .await
262 }
263
264 pub async fn create_destination(
265 &self,
266 body: &DestinationMutationBody,
267 ) -> ClientResult<AcceptedOperationView, T::Error> {
268 self.send_json(Method::Post, http::DESTINATIONS_PATH.to_owned(), body)
269 .await
270 }
271
272 pub async fn enable_destination(
273 &self,
274 id: DestinationId,
275 body: &DestinationMutationBody,
276 ) -> ClientResult<AcceptedOperationView, T::Error> {
277 self.send_json(Method::Post, http::destination_enable_path(id), body)
278 .await
279 }
280
281 pub async fn disable_destination(
282 &self,
283 id: DestinationId,
284 body: &DestinationMutationBody,
285 ) -> ClientResult<AcceptedOperationView, T::Error> {
286 self.send_json(Method::Post, http::destination_disable_path(id), body)
287 .await
288 }
289
290 pub async fn destination_operation(
291 &self,
292 id: DestinationOperationId,
293 ) -> ClientResult<AcceptedOperationView, T::Error> {
294 self.get(http::destination_operation_path(id)).await
295 }
296
297 pub async fn destination_status(
298 &self,
299 id: DestinationId,
300 consistency: CheckpointReadConsistency,
301 ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
302 self.get(with_query(
303 &http::destination_status_path(id),
304 &CheckpointReadQuery::new(consistency),
305 )?)
306 .await
307 }
308
309 pub async fn destination_checkpoint(
310 &self,
311 id: DestinationId,
312 consistency: CheckpointReadConsistency,
313 ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
314 self.get(with_query(
315 &http::destination_checkpoint_path(id),
316 &CheckpointReadQuery::new(consistency),
317 )?)
318 .await
319 }
320
321 pub async fn destination_retention_gap(
322 &self,
323 id: DestinationId,
324 consistency: CheckpointReadConsistency,
325 ) -> ClientResult<DestinationIssueView, T::Error> {
326 self.get(with_query(
327 &http::destination_retention_gap_path(id),
328 &CheckpointReadQuery::new(consistency),
329 )?)
330 .await
331 }
332
333 pub async fn destination_prepared_attempt(
334 &self,
335 id: DestinationId,
336 consistency: CheckpointReadConsistency,
337 ) -> ClientResult<DestinationIssueView, T::Error> {
338 self.get(with_query(
339 &http::destination_prepared_attempt_path(id),
340 &CheckpointReadQuery::new(consistency),
341 )?)
342 .await
343 }
344
345 pub async fn query_routes(
346 &self,
347 query: &QueryRouteListQuery,
348 ) -> ClientResult<QueryRoutePageView, T::Error> {
349 self.get(with_query(http::QUERY_ROUTES_PATH, query)?).await
350 }
351
352 pub async fn destination_table(&self, id: DestinationId) -> ClientResult<TableView, T::Error> {
353 self.get(http::destination_table_path(id)).await
354 }
355
356 pub async fn destination_table_schema(
357 &self,
358 id: DestinationId,
359 ) -> ClientResult<TableSchemaView, T::Error> {
360 self.get(http::destination_table_schema_path(id)).await
361 }
362
363 pub async fn destination_current_snapshot(
364 &self,
365 id: DestinationId,
366 ) -> ClientResult<TableSnapshotView, T::Error> {
367 self.get(http::destination_current_snapshot_path(id)).await
368 }
369
370 pub async fn destination_snapshots(
371 &self,
372 id: DestinationId,
373 query: &SnapshotListQuery,
374 ) -> ClientResult<SnapshotPageView, T::Error> {
375 self.get(with_query(&http::destination_snapshots_path(id), query)?)
376 .await
377 }
378
379 pub async fn destination_snapshot(
380 &self,
381 id: DestinationId,
382 snapshot_id: i64,
383 ) -> ClientResult<TableSnapshotView, T::Error> {
384 self.get(http::destination_snapshot_path(id, snapshot_id))
385 .await
386 }
387
388 pub async fn destination_files(
389 &self,
390 id: DestinationId,
391 query: &TableFileListQuery,
392 ) -> ClientResult<TableFilePageView, T::Error> {
393 self.get(with_query(&http::destination_files_path(id), query)?)
394 .await
395 }
396
397 pub async fn destination_metrics(
398 &self,
399 id: DestinationId,
400 ) -> ClientResult<TableMetricsView, T::Error> {
401 self.get(http::destination_metrics_path(id)).await
402 }
403
404 pub async fn list_projections(
407 &self,
408 filter: &ProjectionListQuery,
409 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
410 self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
411 }
412
413 pub async fn list_schemas(
415 &self,
416 filter: &SchemaListQuery,
417 ) -> ClientResult<Vec<SchemaInfo>, T::Error> {
418 self.get(with_query(http::SCHEMAS_PATH, filter)?).await
419 }
420
421 pub async fn register_schema(
425 &self,
426 source: SchemaSource,
427 name: Option<String>,
428 version: Option<u32>,
429 stream: Option<String>,
430 ) -> ClientResult<u32, T::Error> {
431 let body = http::RegisterSchemaBody {
432 source,
433 name,
434 version,
435 stream,
436 };
437 self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
438 .await
439 }
440
441 pub async fn kv_get(
447 &self,
448 namespace: &str,
449 key: &[u8],
450 ) -> ClientResult<Option<KvValue>, T::Error> {
451 let path = http::kv_entry_path(namespace, &base64url_encode(key));
452 let response = self.dispatch(Method::Get, path, None).await?;
453 if response.status == 404 {
454 return Ok(None);
455 }
456 if !(200..300).contains(&response.status) {
457 return Err(api_error(&response));
458 }
459 let expires_at_micros = response
460 .header(http::KV_EXPIRES_AT_MICROS_HEADER)
461 .and_then(|value| value.parse::<u64>().ok());
462 Ok(Some(KvValue {
463 value: response.body,
464 expires_at_micros,
465 }))
466 }
467
468 pub async fn kv_set(
471 &self,
472 namespace: &str,
473 key: &[u8],
474 value: &[u8],
475 expires_at_micros: Option<u64>,
476 ) -> ClientResult<(), T::Error> {
477 self.kv_set_with(namespace, key, value, expires_at_micros, None)
478 .await
479 }
480
481 pub async fn kv_set_as(
485 &self,
486 namespace: &str,
487 key: &[u8],
488 value: &[u8],
489 expires_at_micros: Option<u64>,
490 operation_id: NonZeroU128,
491 ) -> ClientResult<(), T::Error> {
492 self.kv_set_with(namespace, key, value, expires_at_micros, Some(operation_id))
493 .await
494 }
495
496 async fn kv_set_with(
497 &self,
498 namespace: &str,
499 key: &[u8],
500 value: &[u8],
501 expires_at_micros: Option<u64>,
502 operation_id: Option<NonZeroU128>,
503 ) -> ClientResult<(), T::Error> {
504 let path = with_query(
505 &http::kv_entry_path(namespace, &base64url_encode(key)),
506 &KvPutQuery { expires_at_micros },
507 )?;
508 let path = operation_route(path, operation_id)?;
509 self.expect_ok(Method::Put, path, Some(value.to_vec()))
512 .await
513 }
514
515 pub async fn kv_cas(
523 &self,
524 namespace: &str,
525 key: &[u8],
526 value: &[u8],
527 expect: CasExpect,
528 expires_at_micros: Option<u64>,
529 ) -> ClientResult<u64, T::Error> {
530 self.kv_cas_with(namespace, key, value, expect, expires_at_micros, None)
531 .await
532 }
533
534 pub async fn kv_cas_as(
538 &self,
539 namespace: &str,
540 key: &[u8],
541 value: &[u8],
542 expect: CasExpect,
543 expires_at_micros: Option<u64>,
544 operation_id: NonZeroU128,
545 ) -> ClientResult<u64, T::Error> {
546 self.kv_cas_with(
547 namespace,
548 key,
549 value,
550 expect,
551 expires_at_micros,
552 Some(operation_id),
553 )
554 .await
555 }
556
557 async fn kv_cas_with(
558 &self,
559 namespace: &str,
560 key: &[u8],
561 value: &[u8],
562 expect: CasExpect,
563 expires_at_micros: Option<u64>,
564 operation_id: Option<NonZeroU128>,
565 ) -> ClientResult<u64, T::Error> {
566 let (expect_version, expect_absent) = match expect {
567 CasExpect::Match(version) => (Some(version), None),
568 CasExpect::Absent => (None, Some(true)),
569 };
570 let path = with_query(
571 &http::kv_cas_path(namespace, &base64url_encode(key)),
572 &KvCasQuery {
573 expect_version,
574 expect_absent,
575 expires_at_micros,
576 },
577 )?;
578 let path = operation_route(path, operation_id)?;
579 let response = self
581 .dispatch(Method::Put, path, Some(value.to_vec()))
582 .await?;
583 let view: CasCommittedView = decode_ok(&response)?;
584 Ok(view.version)
585 }
586
587 pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
589 let path = http::kv_entry_path(namespace, &base64url_encode(key));
590 self.send_empty(Method::Delete, path).await
591 }
592
593 pub async fn kv_delete_as(
595 &self,
596 namespace: &str,
597 key: &[u8],
598 operation_id: NonZeroU128,
599 ) -> ClientResult<bool, T::Error> {
600 let path = http::kv_entry_path(namespace, &base64url_encode(key));
601 self.send_empty(Method::Delete, with_operation(&path, operation_id)?)
602 .await
603 }
604
605 pub async fn kv_scan(
607 &self,
608 namespace: &str,
609 filter: &KvScanQuery,
610 ) -> ClientResult<KvPageView, T::Error> {
611 self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
612 .await
613 }
614
615 pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
617 self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
618 .await
619 }
620
621 pub async fn create_fork_as(
623 &self,
624 body: &ForkCreateBody,
625 operation_id: NonZeroU128,
626 ) -> ClientResult<ForkInfo, T::Error> {
627 self.send_json(
628 Method::Post,
629 with_operation(http::FORKS_PATH, operation_id)?,
630 body,
631 )
632 .await
633 }
634
635 pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
637 self.get(http::FORKS_PATH.to_owned()).await
638 }
639
640 pub async fn clients(
644 &self,
645 query: &ClientsQuery,
646 ) -> ClientResult<ClientMetadataListView, T::Error> {
647 self.get(with_query(http::CLIENTS_PATH, query)?).await
648 }
649
650 pub async fn list_sessions(
653 &self,
654 stream: &str,
655 query: &SessionsQuery,
656 ) -> ClientResult<crate::session::SessionPage, T::Error> {
657 self.get(with_query(&http::sessions_path(stream), query)?)
658 .await
659 }
660
661 pub async fn session(
664 &self,
665 stream: &str,
666 id: crate::agent::ConversationId,
667 ) -> ClientResult<Option<crate::session::SessionInfo>, T::Error> {
668 self.get_optional(http::session_path(stream, id)).await
669 }
670
671 pub async fn session_events(
674 &self,
675 stream: &str,
676 id: crate::agent::ConversationId,
677 query: &SessionEventsQuery,
678 ) -> ClientResult<crate::session::SessionEventsPage, T::Error> {
679 self.get(with_query(&http::session_events_path(stream, id), query)?)
680 .await
681 }
682
683 pub async fn session_state(
685 &self,
686 stream: &str,
687 id: crate::agent::ConversationId,
688 query: &SessionStateQuery,
689 ) -> ClientResult<crate::session::SessionStateView, T::Error> {
690 self.get(with_query(&http::session_state_path(stream, id), query)?)
691 .await
692 }
693
694 pub async fn session_links(
697 &self,
698 stream: &str,
699 id: crate::agent::ConversationId,
700 query: &SessionLinksQuery,
701 ) -> ClientResult<crate::session::SessionLinksView, T::Error> {
702 self.get(with_query(&http::session_links_path(stream, id), query)?)
703 .await
704 }
705
706 pub async fn session_sources(
709 &self,
710 stream: &str,
711 id: crate::agent::ConversationId,
712 ) -> ClientResult<crate::session::SessionSources, T::Error> {
713 self.get(http::session_sources_path(stream, id)).await
714 }
715
716 pub async fn session_changes(
719 &self,
720 stream: &str,
721 query: &SessionChangesQuery,
722 ) -> ClientResult<crate::session::SessionChanges, T::Error> {
723 self.get(with_query(&http::session_changes_path(stream), query)?)
724 .await
725 }
726
727 pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
730 self.get_optional(http::projection_path(id)).await
731 }
732
733 pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
736 self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
737 .await
738 }
739
740 pub async fn register_projection_as(
744 &self,
745 projection: &Projection,
746 operation_id: NonZeroU128,
747 ) -> ClientResult<(), T::Error> {
748 let path = with_operation(http::PROJECTIONS_PATH, operation_id)?;
749 self.send_json_ok(Method::Post, path, projection).await
750 }
751
752 pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
754 self.expect_ok(Method::Delete, http::projection_path(id), None)
755 .await
756 }
757
758 pub async fn drop_projection_as(
761 &self,
762 id: &str,
763 operation_id: NonZeroU128,
764 ) -> ClientResult<(), T::Error> {
765 let path = with_operation(&http::projection_path(id), operation_id)?;
766 self.expect_ok(Method::Delete, path, None).await
767 }
768
769 pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
771 self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
772 .await
773 }
774
775 pub async fn apply_binding_as(
778 &self,
779 binding: &ProjectionBinding,
780 operation_id: NonZeroU128,
781 ) -> ClientResult<(), T::Error> {
782 let path = with_operation(http::BINDINGS_PATH, operation_id)?;
783 self.send_json_ok(Method::Post, path, binding).await
784 }
785
786 pub async fn remove_binding(
789 &self,
790 source: &SourceSelector,
791 projection_ref: Option<String>,
792 ) -> ClientResult<(), T::Error> {
793 let body = RemoveBindingBody {
794 stream: source.stream.clone(),
795 topic: source.topic.clone(),
796 projection_ref,
797 };
798 self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
799 .await
800 }
801
802 pub async fn remove_binding_as(
805 &self,
806 source: &SourceSelector,
807 projection_ref: Option<String>,
808 operation_id: NonZeroU128,
809 ) -> ClientResult<(), T::Error> {
810 let body = RemoveBindingBody {
811 stream: source.stream.clone(),
812 topic: source.topic.clone(),
813 projection_ref,
814 };
815 let path = with_operation(http::BINDINGS_PATH, operation_id)?;
816 self.send_json_ok(Method::Delete, path, &body).await
817 }
818
819 pub async fn get_schema(
823 &self,
824 id: u32,
825 stream: Option<&str>,
826 ) -> ClientResult<Option<SchemaInfo>, T::Error> {
827 self.get_optional(schema_route(http::schema_path(id), stream)?)
828 .await
829 }
830
831 pub async fn drop_schema(&self, id: u32, stream: Option<&str>) -> ClientResult<(), T::Error> {
834 self.expect_ok(
835 Method::Delete,
836 schema_route(http::schema_path(id), stream)?,
837 None,
838 )
839 .await
840 }
841
842 pub async fn drop_schema_as(
844 &self,
845 id: u32,
846 stream: Option<&str>,
847 operation_id: NonZeroU128,
848 ) -> ClientResult<(), T::Error> {
849 let path = schema_route(http::schema_path(id), stream)?;
850 self.expect_ok(Method::Delete, with_operation(&path, operation_id)?, None)
851 .await
852 }
853
854 pub async fn decode_record(
858 &self,
859 id: u32,
860 payload: &[u8],
861 stream: Option<&str>,
862 ) -> ClientResult<Option<serde_json::Value>, T::Error> {
863 let body = DecodeRecordBody {
864 payload: base64url_encode(payload),
865 };
866 self.send_json(
867 Method::Post,
868 schema_route(http::schema_decode_path(id), stream)?,
869 &body,
870 )
871 .await
872 }
873
874 pub async fn list_filters(
876 &self,
877 query: &FilterListQuery,
878 ) -> ClientResult<FilterPage, T::Error> {
879 self.get(with_query(http::FILTERS_PATH, query)?).await
880 }
881
882 pub async fn get_filter(&self, id: u32) -> ClientResult<Option<FilterDetail>, T::Error> {
884 self.get_optional(http::filter_path(id)).await
885 }
886
887 pub async fn list_filter_revisions(
889 &self,
890 id: u32,
891 query: &FilterRevisionListQuery,
892 ) -> ClientResult<FilterRevisionPage, T::Error> {
893 self.get(with_query(&http::filter_revisions_path(id), query)?)
894 .await
895 }
896
897 pub async fn mutate_filter(
902 &self,
903 request: &FilterMutationRequest,
904 ) -> ClientResult<FilterMutationOutcome, T::Error> {
905 let payload = serde_json::to_vec(request)
906 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
907 let response = self
908 .dispatch(
909 Method::Post,
910 http::FILTER_MUTATIONS_PATH.to_owned(),
911 Some(payload),
912 )
913 .await?;
914 if let Ok(outcome) = serde_json::from_slice::<FilterMutationOutcome>(&response.body) {
915 return Ok(outcome);
916 }
917 decode_ok(&response)
918 }
919
920 pub async fn filter_operation(
923 &self,
924 operation_id: u128,
925 ) -> ClientResult<Option<FilterMutationOutcome>, T::Error> {
926 self.get_optional(http::filter_operation_path(operation_id))
927 .await
928 }
929
930 pub async fn validate_filter(
932 &self,
933 filter: &ConsumerFilter,
934 ) -> ClientResult<FilterValidation, T::Error> {
935 self.send_json(Method::Post, http::FILTER_VALIDATE_PATH.to_owned(), filter)
936 .await
937 }
938
939 pub async fn test_filter(
942 &self,
943 body: &FilterTestBody,
944 ) -> ClientResult<FilterTestResult, T::Error> {
945 self.send_json(Method::Post, http::FILTER_TEST_PATH.to_owned(), body)
946 .await
947 }
948
949 pub async fn preview_filter(
952 &self,
953 request: &FilterPreviewRequest,
954 ) -> ClientResult<FilterPreview, T::Error> {
955 self.send_json(Method::Post, http::FILTER_PREVIEW_PATH.to_owned(), request)
956 .await
957 }
958
959 pub async fn list_filter_bindings(
961 &self,
962 query: &FilterBindingListQuery,
963 ) -> ClientResult<FilterBindingPage, T::Error> {
964 self.get(with_query(http::FILTER_BINDINGS_PATH, query)?)
965 .await
966 }
967
968 pub async fn get_filter_binding(
971 &self,
972 stream: &str,
973 topic: &str,
974 group: &str,
975 ) -> ClientResult<Option<FilterBinding>, T::Error> {
976 let response = self
977 .dispatch(
978 Method::Get,
979 http::filter_binding_path(stream, topic, group),
980 None,
981 )
982 .await?;
983 if response.status == 404 && response.body.is_empty() {
984 return Ok(None);
985 }
986 decode_ok(&response).map(Some)
987 }
988
989 pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
991 self.get(http::KV_PATH.to_owned()).await
992 }
993
994 pub async fn kv_delete_many(
997 &self,
998 namespace: &str,
999 filter: &KvScanQuery,
1000 ) -> ClientResult<usize, T::Error> {
1001 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
1002 let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
1003 Ok(view.deleted)
1004 }
1005
1006 pub async fn kv_delete_many_as(
1009 &self,
1010 namespace: &str,
1011 filter: &KvScanQuery,
1012 operation_id: NonZeroU128,
1013 ) -> ClientResult<usize, T::Error> {
1014 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
1015 let view: DeletedManyView = self
1016 .send_empty(Method::Delete, with_operation(&path, operation_id)?)
1017 .await?;
1018 Ok(view.deleted)
1019 }
1020
1021 pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
1024 let view: PromotedView = self
1025 .send_empty(Method::Post, http::fork_promote_path(id))
1026 .await?;
1027 Ok(view.rows)
1028 }
1029
1030 pub async fn promote_fork_as(
1032 &self,
1033 id: &str,
1034 operation_id: NonZeroU128,
1035 ) -> ClientResult<usize, T::Error> {
1036 let path = with_operation(&http::fork_promote_path(id), operation_id)?;
1037 let view: PromotedView = self.send_empty(Method::Post, path).await?;
1038 Ok(view.rows)
1039 }
1040
1041 pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
1043 self.expect_ok(Method::Delete, http::fork_path(id), None)
1044 .await
1045 }
1046
1047 pub async fn delete_fork_as(
1049 &self,
1050 id: &str,
1051 operation_id: NonZeroU128,
1052 ) -> ClientResult<(), T::Error> {
1053 let path = with_operation(&http::fork_path(id), operation_id)?;
1054 self.expect_ok(Method::Delete, path, None).await
1055 }
1056
1057 pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
1059 self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
1060 .await
1061 }
1062
1063 pub async fn put_fork_row_as(
1065 &self,
1066 id: &str,
1067 body: &ForkPutBody,
1068 operation_id: NonZeroU128,
1069 ) -> ClientResult<(), T::Error> {
1070 let path = with_operation(&http::fork_rows_path(id), operation_id)?;
1071 self.send_json_ok(Method::Put, path, body).await
1072 }
1073
1074 pub async fn graph_query(
1078 &self,
1079 name: &str,
1080 query: &GraphQuery,
1081 ) -> ClientResult<GraphResultView, T::Error> {
1082 self.send_json(Method::Post, http::graph_query_path(name), query)
1083 .await
1084 }
1085
1086 pub async fn graph_neighbors(
1091 &self,
1092 name: &str,
1093 node: &str,
1094 query: &GraphNeighborsQuery,
1095 ) -> ClientResult<GraphResultView, T::Error> {
1096 self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
1097 .await
1098 }
1099
1100 pub async fn list_graphs(
1104 &self,
1105 filter: &ProjectionListQuery,
1106 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
1107 self.get(with_query(http::GRAPHS_PATH, filter)?).await
1108 }
1109
1110 pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
1114 self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
1115 .await
1116 }
1117
1118 pub async fn register_graph_as(
1121 &self,
1122 projection: &Projection,
1123 operation_id: NonZeroU128,
1124 ) -> ClientResult<(), T::Error> {
1125 let path = with_operation(http::GRAPHS_PATH, operation_id)?;
1126 self.send_json_ok(Method::Post, path, projection).await
1127 }
1128
1129 pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
1132 self.get_optional(http::graph_path(id)).await
1133 }
1134
1135 pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
1138 self.expect_ok(Method::Delete, http::graph_path(id), None)
1139 .await
1140 }
1141
1142 pub async fn drop_graph_as(
1144 &self,
1145 id: &str,
1146 operation_id: NonZeroU128,
1147 ) -> ClientResult<(), T::Error> {
1148 let path = with_operation(&http::graph_path(id), operation_id)?;
1149 self.expect_ok(Method::Delete, path, None).await
1150 }
1151
1152 pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
1156 self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
1157 }
1158
1159 pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
1161 self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
1162 }
1163
1164 pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
1166 self.get_optional(http::authz_role_path(name)).await
1167 }
1168
1169 pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
1172 self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
1173 .await
1174 }
1175
1176 pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
1178 self.expect_ok(Method::Delete, http::authz_role_path(name), None)
1179 .await
1180 }
1181
1182 pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
1184 self.get(http::authz_user_roles_path(user_id)).await
1185 }
1186
1187 pub async fn bind_user_roles(
1189 &self,
1190 user_id: u32,
1191 roles: &[String],
1192 ) -> ClientResult<(), T::Error> {
1193 self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
1194 .await
1195 }
1196
1197 async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
1198 let response = self.dispatch(Method::Get, path, None).await?;
1199 decode_ok(&response)
1200 }
1201
1202 async fn get_optional<R: DeserializeOwned>(
1204 &self,
1205 path: String,
1206 ) -> ClientResult<Option<R>, T::Error> {
1207 let response = self.dispatch(Method::Get, path, None).await?;
1208 if response.status == 404 {
1209 return Ok(None);
1210 }
1211 decode_ok(&response).map(Some)
1212 }
1213
1214 async fn send_json<B: Serialize, R: DeserializeOwned>(
1215 &self,
1216 method: Method,
1217 path: String,
1218 body: &B,
1219 ) -> ClientResult<R, T::Error> {
1220 let payload = serde_json::to_vec(body)
1221 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1222 let response = self.dispatch(method, path, Some(payload)).await?;
1223 decode_ok(&response)
1224 }
1225
1226 async fn send_empty<R: DeserializeOwned>(
1227 &self,
1228 method: Method,
1229 path: String,
1230 ) -> ClientResult<R, T::Error> {
1231 let response = self.dispatch(method, path, None).await?;
1232 decode_ok(&response)
1233 }
1234
1235 async fn send_json_ok<B: Serialize>(
1238 &self,
1239 method: Method,
1240 path: String,
1241 body: &B,
1242 ) -> ClientResult<(), T::Error> {
1243 let payload = serde_json::to_vec(body)
1244 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1245 let response = self.dispatch(method, path, Some(payload)).await?;
1246 check_status(&response)
1247 }
1248
1249 async fn expect_ok(
1250 &self,
1251 method: Method,
1252 path: String,
1253 body: Option<Vec<u8>>,
1254 ) -> ClientResult<(), T::Error> {
1255 let response = self.dispatch(method, path, body).await?;
1256 check_status(&response)
1257 }
1258
1259 async fn dispatch(
1260 &self,
1261 method: Method,
1262 path: String,
1263 body: Option<Vec<u8>>,
1264 ) -> ClientResult<HttpResponse, T::Error> {
1265 self.transport
1266 .send(HttpRequest { method, path, body })
1267 .await
1268 .map_err(ClientError::Transport)
1269 }
1270}
1271
1272fn schema_route<E>(path: String, stream: Option<&str>) -> Result<String, ClientError<E>> {
1275 with_query(
1276 &path,
1277 &http::SchemaQuery {
1278 stream: stream.map(str::to_owned),
1279 },
1280 )
1281}
1282
1283fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
1285 let query = serde_urlencoded::to_string(params)
1286 .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
1287 if query.is_empty() {
1288 Ok(path.to_owned())
1289 } else if path.contains('?') {
1290 Ok(format!("{path}&{query}"))
1291 } else {
1292 Ok(format!("{path}?{query}"))
1293 }
1294}
1295
1296fn with_operation<E>(path: &str, operation_id: NonZeroU128) -> Result<String, ClientError<E>> {
1297 with_query(path, &OperationQuery::from(operation_id))
1298}
1299
1300fn operation_route<E>(
1301 path: String,
1302 operation_id: Option<NonZeroU128>,
1303) -> Result<String, ClientError<E>> {
1304 match operation_id {
1305 Some(operation_id) => with_operation(&path, operation_id),
1306 None => Ok(path),
1307 }
1308}
1309
1310fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
1314 if (200..300).contains(&response.status) {
1315 serde_json::from_slice(&response.body)
1316 .map_err(|error| ClientError::Decode(format!("response body: {error}")))
1317 } else {
1318 Err(api_error(response))
1319 }
1320}
1321
1322fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
1324 if (200..300).contains(&response.status) {
1325 Ok(())
1326 } else {
1327 Err(api_error(response))
1328 }
1329}
1330
1331fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
1332 let body = serde_json::from_slice::<ErrorBody>(&response.body).unwrap_or_else(|_| {
1333 ErrorBody::new(
1334 code_for_status(response.status),
1335 String::from_utf8_lossy(&response.body).into_owned(),
1336 )
1337 });
1338 ClientError::Api(body)
1339}
1340
1341fn code_for_status(status: u16) -> ResultCode {
1345 match status {
1346 404 => ResultCode::NotFound,
1347 400 => ResultCode::InvalidArgument,
1348 401 => ResultCode::Unauthenticated,
1349 403 => ResultCode::Forbidden,
1350 409 => ResultCode::Conflict,
1351 413 => ResultCode::TooLarge,
1352 501 => ResultCode::Unsupported,
1353 503 => ResultCode::Stale,
1354 _ => ResultCode::Backend,
1355 }
1356}
1357
1358const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1362
1363pub fn base64url_encode(input: &[u8]) -> String {
1365 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1366 for chunk in input.chunks(3) {
1367 let b0 = chunk[0] as usize;
1368 out.push(B64URL[b0 >> 2] as char);
1369 match chunk.len() {
1370 1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
1371 2 => {
1372 let b1 = chunk[1] as usize;
1373 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1374 out.push(B64URL[(b1 & 0b1111) << 2] as char);
1375 }
1376 _ => {
1377 let b1 = chunk[1] as usize;
1378 let b2 = chunk[2] as usize;
1379 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1380 out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
1381 out.push(B64URL[b2 & 0b111111] as char);
1382 }
1383 }
1384 }
1385 out
1386}
1387
1388pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
1391 fn val(byte: u8) -> Option<u8> {
1392 match byte {
1393 b'A'..=b'Z' => Some(byte - b'A'),
1394 b'a'..=b'z' => Some(byte - b'a' + 26),
1395 b'0'..=b'9' => Some(byte - b'0' + 52),
1396 b'-' => Some(62),
1397 b'_' => Some(63),
1398 _ => None,
1399 }
1400 }
1401 let bytes = input.as_bytes();
1402 if bytes.len() % 4 == 1 {
1403 return None;
1404 }
1405 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1406 for chunk in bytes.chunks(4) {
1407 let mut acc = 0u32;
1408 for &byte in chunk {
1409 acc = (acc << 6) | u32::from(val(byte)?);
1410 }
1411 acc <<= 6 * (4 - chunk.len());
1413 match chunk.len() {
1414 2 => out.push((acc >> 16) as u8),
1415 3 => {
1416 out.push((acc >> 16) as u8);
1417 out.push((acc >> 8) as u8);
1418 }
1419 _ => {
1420 out.push((acc >> 16) as u8);
1421 out.push((acc >> 8) as u8);
1422 out.push(acc as u8);
1423 }
1424 }
1425 }
1426 Some(out)
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431 use super::*;
1432
1433 #[test]
1434 fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
1435 for case in [
1436 &b""[..],
1437 &b"f"[..],
1438 &b"fo"[..],
1439 &b"foo"[..],
1440 &b"foob"[..],
1441 &b"fooba"[..],
1442 &b"foobar"[..],
1443 &[0x00, 0xff, 0x10, 0x80][..],
1444 ] {
1445 let encoded = base64url_encode(case);
1446 assert!(
1447 !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1448 "url-safe unpadded: {encoded}"
1449 );
1450 assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1451 }
1452 }
1453
1454 #[test]
1455 fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1456 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1457 assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1458 }
1459
1460 #[test]
1461 fn given_checkpoint_reads_when_paths_are_built_then_should_require_the_selected_consistency() {
1462 let single = with_query::<(), _>(
1463 &http::destination_status_path(DestinationId::from_u128(1)),
1464 &CheckpointReadQuery::new(CheckpointReadConsistency::Linearizable),
1465 )
1466 .expect("single checkpoint read query encodes");
1467 assert!(single.ends_with("?consistency=linearizable"));
1468
1469 let list = with_query::<(), _>(
1470 http::DESTINATIONS_PATH,
1471 &DestinationListQuery {
1472 source_stream: None,
1473 source_topic: None,
1474 name_contains: None,
1475 after: None,
1476 limit: None,
1477 consistency: CheckpointReadConsistency::PotentiallyStale,
1478 },
1479 )
1480 .expect("checkpoint list query encodes");
1481 assert!(list.ends_with("?consistency=potentially_stale"));
1482 }
1483
1484 #[test]
1485 fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1486 assert!(
1487 base64url_decode("====").is_none(),
1488 "padding is not alphabet"
1489 );
1490 assert!(
1491 base64url_decode("A").is_none(),
1492 "a lone char carries no byte"
1493 );
1494 assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1495 }
1496
1497 struct CannedTransport {
1500 response: HttpResponse,
1501 }
1502
1503 impl Transport for CannedTransport {
1504 type Error = std::convert::Infallible;
1505 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1506 Ok(self.response.clone())
1507 }
1508 }
1509
1510 #[derive(Default)]
1513 struct RecordingTransport {
1514 requests: std::cell::RefCell<Vec<HttpRequest>>,
1515 }
1516
1517 impl Transport for RecordingTransport {
1518 type Error = std::convert::Infallible;
1519 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1520 self.requests.borrow_mut().push(request);
1521 Ok(HttpResponse::new(202, Vec::new()))
1522 }
1523 }
1524
1525 fn block_on<F: core::future::Future>(future: F) -> F::Output {
1526 use core::task::{Context, Poll, Waker};
1530 let mut context = Context::from_waker(Waker::noop());
1531 let mut future = core::pin::pin!(future);
1532 loop {
1533 if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1534 return output;
1535 }
1536 }
1537 }
1538
1539 #[test]
1540 fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1541 let body = serde_json::to_vec(&Capabilities::new(
1542 true,
1543 crate::hello::OpVersions::new(1, 1, 1, 1),
1544 ))
1545 .unwrap();
1546 let client = HttpClient::new(CannedTransport {
1547 response: HttpResponse::new(200, body),
1548 });
1549 let caps = block_on(client.capabilities()).expect("decodes");
1550 assert!(caps.managed && !caps.kv.cas);
1551 }
1552
1553 #[test]
1554 fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1555 let body =
1556 serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1557 let client = HttpClient::new(CannedTransport {
1558 response: HttpResponse::new(404, body),
1559 });
1560 let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1561 assert_eq!(error.code(), Some(ResultCode::NotFound));
1562 }
1563
1564 #[test]
1565 fn given_a_missing_binding_when_fetched_then_should_distinguish_absence_from_missing_source() {
1566 let absent = HttpClient::new(CannedTransport {
1567 response: HttpResponse::new(404, Vec::new()),
1568 });
1569 assert!(
1570 block_on(absent.get_filter_binding("stream", "topic", "group"))
1571 .unwrap()
1572 .is_none()
1573 );
1574 let body = serde_json::to_vec(&ErrorBody::new(
1575 ResultCode::NotFound,
1576 "topic does not exist",
1577 ))
1578 .unwrap();
1579 let missing_source = HttpClient::new(CannedTransport {
1580 response: HttpResponse::new(404, body),
1581 });
1582 let error =
1583 block_on(missing_source.get_filter_binding("stream", "topic", "group")).unwrap_err();
1584 assert_eq!(error.code(), Some(ResultCode::NotFound));
1585 }
1586
1587 #[test]
1588 fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
1589 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1590 let client = HttpClient::new(CannedTransport {
1591 response: HttpResponse::new(404, body),
1592 });
1593 let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1594 assert!(entry.is_none());
1595 }
1596
1597 #[test]
1598 fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
1599 let response = HttpResponse::new(200, b"world".to_vec())
1600 .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
1601 let client = HttpClient::new(CannedTransport { response });
1602 let entry = block_on(client.kv_get("sessions", b"user:1"))
1603 .expect("decodes")
1604 .expect("present");
1605 assert_eq!(entry.value, b"world");
1606 assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1607 }
1608
1609 #[test]
1610 fn given_a_missing_projection_when_fetched_then_should_be_none() {
1611 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1612 let client = HttpClient::new(CannedTransport {
1613 response: HttpResponse::new(404, body),
1614 });
1615 let info = block_on(client.get_projection("reading.v1")).expect("404 maps to None");
1616 assert!(info.is_none());
1617 }
1618
1619 #[test]
1620 fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1621 let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1622 let client = HttpClient::new(CannedTransport {
1623 response: HttpResponse::new(200, body),
1624 });
1625 let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1626 .expect("decodes the count");
1627 assert_eq!(removed, 7);
1628 }
1629
1630 #[test]
1631 fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1632 let client = HttpClient::new(CannedTransport {
1633 response: HttpResponse::new(204, Vec::new()),
1634 });
1635 block_on(client.drop_projection("reading.v1")).expect("a 204 is a success");
1636 }
1637
1638 #[test]
1639 fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1640 let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1641 let client = HttpClient::new(CannedTransport {
1642 response: HttpResponse::new(200, body),
1643 });
1644 let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1645 .expect("a commit returns the new version");
1646 assert_eq!(version, 4);
1647 }
1648
1649 #[test]
1650 fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1651 let reply = WhoamiReply {
1652 v: 1,
1653 roles: vec!["admin".to_owned()],
1654 grants: vec![crate::authz::Grant {
1655 effect: crate::authz::Effect::Allow,
1656 feature: crate::authz::Feature::Authz,
1657 action: crate::authz::Action::Admin,
1658 resource: crate::authz::ResourcePattern::all(),
1659 }],
1660 };
1661 let body = serde_json::to_vec(&reply).unwrap();
1662 let client = HttpClient::new(CannedTransport {
1663 response: HttpResponse::new(200, body),
1664 });
1665 let whoami = block_on(client.authz_whoami()).expect("decodes");
1666 assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1667 assert_eq!(whoami.grants.len(), 1);
1668 }
1669
1670 #[test]
1671 fn given_a_missing_role_when_fetched_then_should_be_none() {
1672 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1673 let client = HttpClient::new(CannedTransport {
1674 response: HttpResponse::new(404, body),
1675 });
1676 let role = block_on(client.get_role("ghost")).expect("404 maps to None");
1677 assert!(role.is_none());
1678 }
1679
1680 #[test]
1681 fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1682 let body = serde_json::to_vec(
1683 &ErrorBody::new(ResultCode::Conflict, "version conflict")
1684 .with_detail(serde_json::json!({ "current": 3 })),
1685 )
1686 .unwrap();
1687 let client = HttpClient::new(CannedTransport {
1688 response: HttpResponse::new(409, body),
1689 });
1690 let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1691 .expect_err("a precondition miss is an error");
1692 assert_eq!(error.code(), Some(ResultCode::Conflict));
1693 }
1694
1695 #[test]
1696 fn given_a_caller_operation_id_when_writing_the_registry_then_should_ride_the_query_string() {
1697 let operation_id = NonZeroU128::new(42).expect("non-zero");
1698 let client = HttpClient::new(RecordingTransport::default());
1699 let projection = Projection::builder("reading.v1").build();
1700 let source = SourceSelector::new("telemetry", "events");
1701
1702 block_on(client.register_projection_as(&projection, operation_id)).expect("register");
1703 block_on(client.drop_projection_as("reading.v1", operation_id)).expect("drop");
1704 block_on(client.remove_binding_as(&source, None, operation_id)).expect("remove");
1705 block_on(client.drop_schema_as(7, Some("telemetry"), operation_id)).expect("drop schema");
1706 block_on(client.drop_schema_as(7, None, operation_id)).expect("drop shared schema");
1707 block_on(client.register_graph_as(&projection, operation_id)).expect("register graph");
1708 block_on(client.drop_graph_as("topology.v1", operation_id)).expect("drop graph");
1709
1710 let paths: Vec<String> = client
1711 .transport()
1712 .requests
1713 .borrow()
1714 .iter()
1715 .map(|request| request.path.clone())
1716 .collect();
1717 assert_eq!(
1718 paths,
1719 [
1720 "/agdx/projections?operation_id=42",
1721 "/agdx/projections/reading.v1?operation_id=42",
1722 "/agdx/bindings?operation_id=42",
1723 "/agdx/schemas/7?stream=telemetry&operation_id=42",
1724 "/agdx/schemas/7?operation_id=42",
1725 "/agdx/graphs?operation_id=42",
1726 "/agdx/graphs/topology.v1?operation_id=42",
1727 ]
1728 );
1729 }
1730
1731 #[test]
1732 fn given_a_caller_operation_id_when_writing_key_values_then_should_follow_the_other_parameters()
1733 {
1734 let operation_id = NonZeroU128::new(7).expect("non-zero");
1735 let client = HttpClient::new(RecordingTransport::default());
1736
1737 let _ = block_on(client.kv_set_as("notes", b"k", b"v", Some(10), operation_id));
1738 let _ =
1739 block_on(client.kv_cas_as("notes", b"k", b"v", CasExpect::Absent, None, operation_id));
1740 let _ = block_on(client.kv_delete_as("notes", b"k", operation_id));
1741 let _ = block_on(client.delete_fork_as("f1", operation_id));
1742
1743 let requests = client.transport().requests.borrow();
1744 assert_eq!(
1745 requests[0].path,
1746 "/agdx/kv/notes/aw?expires_at_micros=10&operation_id=7"
1747 );
1748 assert_eq!(
1749 requests[1].path,
1750 "/agdx/kv/notes/aw/cas?expect_absent=true&operation_id=7"
1751 );
1752 assert_eq!(requests[2].path, "/agdx/kv/notes/aw?operation_id=7");
1753 assert_eq!(requests[3].path, "/agdx/forks/f1?operation_id=7");
1754 }
1755
1756 #[test]
1757 fn given_no_operation_id_when_writing_then_should_send_the_bare_path() {
1758 let client = HttpClient::new(RecordingTransport::default());
1759 block_on(client.drop_projection("reading.v1")).expect("drop");
1760 block_on(client.drop_schema(7, Some("telemetry"))).expect("drop schema");
1761 let requests = client.transport().requests.borrow();
1762 assert_eq!(requests[0].path, "/agdx/projections/reading.v1");
1763 assert_eq!(requests[1].path, "/agdx/schemas/7?stream=telemetry");
1764 }
1765}