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, ProjectionListQuery, PromotedView, QueryExecutionView, QueryPageBody,
33 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;
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
46#[strum(serialize_all = "UPPERCASE")]
47pub enum Method {
48 Get,
49 Post,
50 Put,
51 Delete,
52}
53
54impl Method {
55 pub fn as_str(self) -> &'static str {
57 self.into()
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct HttpRequest {
68 pub method: Method,
69 pub path: String,
70 pub body: Option<Vec<u8>>,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq, Default)]
78pub struct HttpResponse {
79 pub status: u16,
80 pub headers: Vec<(String, String)>,
81 pub body: Vec<u8>,
82}
83
84impl HttpResponse {
85 pub fn new(status: u16, body: Vec<u8>) -> Self {
87 Self {
88 status,
89 headers: Vec::new(),
90 body,
91 }
92 }
93
94 #[must_use]
96 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
97 self.headers.push((name.into(), value.into()));
98 self
99 }
100
101 pub fn header(&self, name: &str) -> Option<&str> {
104 self.headers
105 .iter()
106 .find(|(key, _)| key.eq_ignore_ascii_case(name))
107 .map(|(_, value)| value.as_str())
108 }
109}
110
111#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct KvValue {
118 pub value: Vec<u8>,
119 pub expires_at_micros: Option<u64>,
120}
121
122#[allow(async_fn_in_trait)]
138pub trait Transport {
139 type Error: core::fmt::Display;
142
143 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
147}
148
149#[derive(Debug)]
151pub enum ClientError<E> {
152 Transport(E),
154 Decode(String),
156 Api(ErrorBody),
158}
159
160impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
161 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
162 match self {
163 ClientError::Transport(error) => write!(f, "transport error: {error}"),
164 ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
165 ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
166 }
167 }
168}
169
170impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
171
172impl<E> ClientError<E> {
173 pub fn code(&self) -> Option<ResultCode> {
177 match self {
178 ClientError::Api(body) => Some(body.code),
179 _ => None,
180 }
181 }
182}
183
184type ClientResult<T, E> = Result<T, ClientError<E>>;
185
186#[derive(Clone, Debug)]
194pub struct HttpClient<T> {
195 transport: T,
196}
197
198impl<T: Transport> HttpClient<T> {
199 pub fn new(transport: T) -> Self {
201 Self { transport }
202 }
203
204 pub fn transport(&self) -> &T {
206 &self.transport
207 }
208
209 pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
211 self.get(http::CAPABILITIES_PATH.to_owned()).await
212 }
213
214 pub async fn query(&self, query: &Query) -> ClientResult<QueryExecutionView, T::Error> {
216 self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
217 .await
218 }
219
220 pub async fn query_page(
221 &self,
222 execution_id: crate::query::QueryExecutionId,
223 body: &QueryPageBody,
224 ) -> ClientResult<QueryResult, T::Error> {
225 self.send_json(Method::Post, http::query_page_path(execution_id), body)
226 .await
227 }
228
229 pub async fn query_status(
230 &self,
231 execution_id: crate::query::QueryExecutionId,
232 ) -> ClientResult<QueryExecutionView, T::Error> {
233 self.get(http::query_execution_path(execution_id)).await
234 }
235
236 pub async fn cancel_query(
237 &self,
238 execution_id: crate::query::QueryExecutionId,
239 ) -> ClientResult<crate::query::QueryExecutionStatus, T::Error> {
240 self.send_empty(Method::Post, http::query_cancel_path(execution_id))
241 .await
242 }
243
244 pub async fn list_destinations(
245 &self,
246 query: &DestinationListQuery,
247 ) -> ClientResult<DestinationPageView, T::Error> {
248 self.get(with_query(http::DESTINATIONS_PATH, query)?).await
249 }
250
251 pub async fn destination(
252 &self,
253 id: DestinationId,
254 consistency: CheckpointReadConsistency,
255 ) -> ClientResult<Option<DestinationView>, T::Error> {
256 self.get_optional(with_query(
257 &http::destination_path(id),
258 &CheckpointReadQuery::new(consistency),
259 )?)
260 .await
261 }
262
263 pub async fn create_destination(
264 &self,
265 body: &DestinationMutationBody,
266 ) -> ClientResult<AcceptedOperationView, T::Error> {
267 self.send_json(Method::Post, http::DESTINATIONS_PATH.to_owned(), body)
268 .await
269 }
270
271 pub async fn enable_destination(
272 &self,
273 id: DestinationId,
274 body: &DestinationMutationBody,
275 ) -> ClientResult<AcceptedOperationView, T::Error> {
276 self.send_json(Method::Post, http::destination_enable_path(id), body)
277 .await
278 }
279
280 pub async fn disable_destination(
281 &self,
282 id: DestinationId,
283 body: &DestinationMutationBody,
284 ) -> ClientResult<AcceptedOperationView, T::Error> {
285 self.send_json(Method::Post, http::destination_disable_path(id), body)
286 .await
287 }
288
289 pub async fn destination_operation(
290 &self,
291 id: DestinationOperationId,
292 ) -> ClientResult<AcceptedOperationView, T::Error> {
293 self.get(http::destination_operation_path(id)).await
294 }
295
296 pub async fn destination_status(
297 &self,
298 id: DestinationId,
299 consistency: CheckpointReadConsistency,
300 ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
301 self.get(with_query(
302 &http::destination_status_path(id),
303 &CheckpointReadQuery::new(consistency),
304 )?)
305 .await
306 }
307
308 pub async fn destination_checkpoint(
309 &self,
310 id: DestinationId,
311 consistency: CheckpointReadConsistency,
312 ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
313 self.get(with_query(
314 &http::destination_checkpoint_path(id),
315 &CheckpointReadQuery::new(consistency),
316 )?)
317 .await
318 }
319
320 pub async fn destination_retention_gap(
321 &self,
322 id: DestinationId,
323 consistency: CheckpointReadConsistency,
324 ) -> ClientResult<DestinationIssueView, T::Error> {
325 self.get(with_query(
326 &http::destination_retention_gap_path(id),
327 &CheckpointReadQuery::new(consistency),
328 )?)
329 .await
330 }
331
332 pub async fn destination_prepared_attempt(
333 &self,
334 id: DestinationId,
335 consistency: CheckpointReadConsistency,
336 ) -> ClientResult<DestinationIssueView, T::Error> {
337 self.get(with_query(
338 &http::destination_prepared_attempt_path(id),
339 &CheckpointReadQuery::new(consistency),
340 )?)
341 .await
342 }
343
344 pub async fn query_routes(
345 &self,
346 query: &QueryRouteListQuery,
347 ) -> ClientResult<QueryRoutePageView, T::Error> {
348 self.get(with_query(http::QUERY_ROUTES_PATH, query)?).await
349 }
350
351 pub async fn destination_table(&self, id: DestinationId) -> ClientResult<TableView, T::Error> {
352 self.get(http::destination_table_path(id)).await
353 }
354
355 pub async fn destination_table_schema(
356 &self,
357 id: DestinationId,
358 ) -> ClientResult<TableSchemaView, T::Error> {
359 self.get(http::destination_table_schema_path(id)).await
360 }
361
362 pub async fn destination_current_snapshot(
363 &self,
364 id: DestinationId,
365 ) -> ClientResult<TableSnapshotView, T::Error> {
366 self.get(http::destination_current_snapshot_path(id)).await
367 }
368
369 pub async fn destination_snapshots(
370 &self,
371 id: DestinationId,
372 query: &SnapshotListQuery,
373 ) -> ClientResult<SnapshotPageView, T::Error> {
374 self.get(with_query(&http::destination_snapshots_path(id), query)?)
375 .await
376 }
377
378 pub async fn destination_snapshot(
379 &self,
380 id: DestinationId,
381 snapshot_id: i64,
382 ) -> ClientResult<TableSnapshotView, T::Error> {
383 self.get(http::destination_snapshot_path(id, snapshot_id))
384 .await
385 }
386
387 pub async fn destination_files(
388 &self,
389 id: DestinationId,
390 query: &TableFileListQuery,
391 ) -> ClientResult<TableFilePageView, T::Error> {
392 self.get(with_query(&http::destination_files_path(id), query)?)
393 .await
394 }
395
396 pub async fn destination_metrics(
397 &self,
398 id: DestinationId,
399 ) -> ClientResult<TableMetricsView, T::Error> {
400 self.get(http::destination_metrics_path(id)).await
401 }
402
403 pub async fn list_projections(
406 &self,
407 filter: &ProjectionListQuery,
408 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
409 self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
410 }
411
412 pub async fn list_schemas(
414 &self,
415 filter: &SchemaListQuery,
416 ) -> ClientResult<Vec<SchemaInfo>, T::Error> {
417 self.get(with_query(http::SCHEMAS_PATH, filter)?).await
418 }
419
420 pub async fn register_schema(
424 &self,
425 source: SchemaSource,
426 name: Option<String>,
427 version: Option<u32>,
428 stream: Option<String>,
429 ) -> ClientResult<u32, T::Error> {
430 let body = http::RegisterSchemaBody {
431 source,
432 name,
433 version,
434 stream,
435 };
436 self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
437 .await
438 }
439
440 pub async fn kv_get(
446 &self,
447 namespace: &str,
448 key: &[u8],
449 ) -> ClientResult<Option<KvValue>, T::Error> {
450 let path = http::kv_entry_path(namespace, &base64url_encode(key));
451 let response = self.dispatch(Method::Get, path, None).await?;
452 if response.status == 404 {
453 return Ok(None);
454 }
455 if !(200..300).contains(&response.status) {
456 return Err(api_error(&response));
457 }
458 let expires_at_micros = response
459 .header(http::KV_EXPIRES_AT_MICROS_HEADER)
460 .and_then(|value| value.parse::<u64>().ok());
461 Ok(Some(KvValue {
462 value: response.body,
463 expires_at_micros,
464 }))
465 }
466
467 pub async fn kv_set(
470 &self,
471 namespace: &str,
472 key: &[u8],
473 value: &[u8],
474 expires_at_micros: Option<u64>,
475 ) -> ClientResult<(), T::Error> {
476 let path = with_query(
477 &http::kv_entry_path(namespace, &base64url_encode(key)),
478 &KvPutQuery { expires_at_micros },
479 )?;
480 self.expect_ok(Method::Put, path, Some(value.to_vec()))
483 .await
484 }
485
486 pub async fn kv_cas(
494 &self,
495 namespace: &str,
496 key: &[u8],
497 value: &[u8],
498 expect: CasExpect,
499 expires_at_micros: Option<u64>,
500 ) -> ClientResult<u64, T::Error> {
501 let (expect_version, expect_absent) = match expect {
502 CasExpect::Match(version) => (Some(version), None),
503 CasExpect::Absent => (None, Some(true)),
504 };
505 let path = with_query(
506 &http::kv_cas_path(namespace, &base64url_encode(key)),
507 &KvCasQuery {
508 expect_version,
509 expect_absent,
510 expires_at_micros,
511 },
512 )?;
513 let response = self
515 .dispatch(Method::Put, path, Some(value.to_vec()))
516 .await?;
517 let view: CasCommittedView = decode_ok(&response)?;
518 Ok(view.version)
519 }
520
521 pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
523 let path = http::kv_entry_path(namespace, &base64url_encode(key));
524 self.send_empty(Method::Delete, path).await
525 }
526
527 pub async fn kv_scan(
529 &self,
530 namespace: &str,
531 filter: &KvScanQuery,
532 ) -> ClientResult<KvPageView, T::Error> {
533 self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
534 .await
535 }
536
537 pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
539 self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
540 .await
541 }
542
543 pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
545 self.get(http::FORKS_PATH.to_owned()).await
546 }
547
548 pub async fn clients(
552 &self,
553 query: &ClientsQuery,
554 ) -> ClientResult<ClientMetadataListView, T::Error> {
555 self.get(with_query(http::CLIENTS_PATH, query)?).await
556 }
557
558 pub async fn list_sessions(
561 &self,
562 stream: &str,
563 query: &SessionsQuery,
564 ) -> ClientResult<crate::session::SessionPage, T::Error> {
565 self.get(with_query(&http::sessions_path(stream), query)?)
566 .await
567 }
568
569 pub async fn session(
572 &self,
573 stream: &str,
574 id: crate::agent::ConversationId,
575 ) -> ClientResult<Option<crate::session::SessionInfo>, T::Error> {
576 self.get_optional(http::session_path(stream, id)).await
577 }
578
579 pub async fn session_events(
582 &self,
583 stream: &str,
584 id: crate::agent::ConversationId,
585 query: &SessionEventsQuery,
586 ) -> ClientResult<crate::session::SessionEventsPage, T::Error> {
587 self.get(with_query(&http::session_events_path(stream, id), query)?)
588 .await
589 }
590
591 pub async fn session_state(
593 &self,
594 stream: &str,
595 id: crate::agent::ConversationId,
596 query: &SessionStateQuery,
597 ) -> ClientResult<crate::session::SessionStateView, T::Error> {
598 self.get(with_query(&http::session_state_path(stream, id), query)?)
599 .await
600 }
601
602 pub async fn session_links(
605 &self,
606 stream: &str,
607 id: crate::agent::ConversationId,
608 query: &SessionLinksQuery,
609 ) -> ClientResult<crate::session::SessionLinksView, T::Error> {
610 self.get(with_query(&http::session_links_path(stream, id), query)?)
611 .await
612 }
613
614 pub async fn session_sources(
617 &self,
618 stream: &str,
619 id: crate::agent::ConversationId,
620 ) -> ClientResult<crate::session::SessionSources, T::Error> {
621 self.get(http::session_sources_path(stream, id)).await
622 }
623
624 pub async fn session_changes(
627 &self,
628 stream: &str,
629 query: &SessionChangesQuery,
630 ) -> ClientResult<crate::session::SessionChanges, T::Error> {
631 self.get(with_query(&http::session_changes_path(stream), query)?)
632 .await
633 }
634
635 pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
638 self.get_optional(http::projection_path(id)).await
639 }
640
641 pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
644 self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
645 .await
646 }
647
648 pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
650 self.expect_ok(Method::Delete, http::projection_path(id), None)
651 .await
652 }
653
654 pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
656 self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
657 .await
658 }
659
660 pub async fn remove_binding(
663 &self,
664 source: &SourceSelector,
665 projection_ref: Option<String>,
666 ) -> ClientResult<(), T::Error> {
667 let body = RemoveBindingBody {
668 stream: source.stream.clone(),
669 topic: source.topic.clone(),
670 projection_ref,
671 };
672 self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
673 .await
674 }
675
676 pub async fn get_schema(
680 &self,
681 id: u32,
682 stream: Option<&str>,
683 ) -> ClientResult<Option<SchemaInfo>, T::Error> {
684 self.get_optional(schema_route(http::schema_path(id), stream)?)
685 .await
686 }
687
688 pub async fn drop_schema(&self, id: u32, stream: Option<&str>) -> ClientResult<(), T::Error> {
691 self.expect_ok(
692 Method::Delete,
693 schema_route(http::schema_path(id), stream)?,
694 None,
695 )
696 .await
697 }
698
699 pub async fn decode_record(
703 &self,
704 id: u32,
705 payload: &[u8],
706 stream: Option<&str>,
707 ) -> ClientResult<Option<serde_json::Value>, T::Error> {
708 let body = DecodeRecordBody {
709 payload: base64url_encode(payload),
710 };
711 self.send_json(
712 Method::Post,
713 schema_route(http::schema_decode_path(id), stream)?,
714 &body,
715 )
716 .await
717 }
718
719 pub async fn list_filters(
721 &self,
722 query: &FilterListQuery,
723 ) -> ClientResult<FilterPage, T::Error> {
724 self.get(with_query(http::FILTERS_PATH, query)?).await
725 }
726
727 pub async fn get_filter(&self, id: u32) -> ClientResult<Option<FilterDetail>, T::Error> {
729 self.get_optional(http::filter_path(id)).await
730 }
731
732 pub async fn list_filter_revisions(
734 &self,
735 id: u32,
736 query: &FilterRevisionListQuery,
737 ) -> ClientResult<FilterRevisionPage, T::Error> {
738 self.get(with_query(&http::filter_revisions_path(id), query)?)
739 .await
740 }
741
742 pub async fn mutate_filter(
747 &self,
748 request: &FilterMutationRequest,
749 ) -> ClientResult<FilterMutationOutcome, T::Error> {
750 let payload = serde_json::to_vec(request)
751 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
752 let response = self
753 .dispatch(
754 Method::Post,
755 http::FILTER_MUTATIONS_PATH.to_owned(),
756 Some(payload),
757 )
758 .await?;
759 if let Ok(outcome) = serde_json::from_slice::<FilterMutationOutcome>(&response.body) {
760 return Ok(outcome);
761 }
762 decode_ok(&response)
763 }
764
765 pub async fn filter_operation(
768 &self,
769 operation_id: u128,
770 ) -> ClientResult<Option<FilterMutationOutcome>, T::Error> {
771 self.get_optional(http::filter_operation_path(operation_id))
772 .await
773 }
774
775 pub async fn validate_filter(
777 &self,
778 filter: &ConsumerFilter,
779 ) -> ClientResult<FilterValidation, T::Error> {
780 self.send_json(Method::Post, http::FILTER_VALIDATE_PATH.to_owned(), filter)
781 .await
782 }
783
784 pub async fn test_filter(
787 &self,
788 body: &FilterTestBody,
789 ) -> ClientResult<FilterTestResult, T::Error> {
790 self.send_json(Method::Post, http::FILTER_TEST_PATH.to_owned(), body)
791 .await
792 }
793
794 pub async fn preview_filter(
797 &self,
798 request: &FilterPreviewRequest,
799 ) -> ClientResult<FilterPreview, T::Error> {
800 self.send_json(Method::Post, http::FILTER_PREVIEW_PATH.to_owned(), request)
801 .await
802 }
803
804 pub async fn list_filter_bindings(
806 &self,
807 query: &FilterBindingListQuery,
808 ) -> ClientResult<FilterBindingPage, T::Error> {
809 self.get(with_query(http::FILTER_BINDINGS_PATH, query)?)
810 .await
811 }
812
813 pub async fn get_filter_binding(
816 &self,
817 stream: &str,
818 topic: &str,
819 group: &str,
820 ) -> ClientResult<Option<FilterBinding>, T::Error> {
821 let response = self
822 .dispatch(
823 Method::Get,
824 http::filter_binding_path(stream, topic, group),
825 None,
826 )
827 .await?;
828 if response.status == 404 && response.body.is_empty() {
829 return Ok(None);
830 }
831 decode_ok(&response).map(Some)
832 }
833
834 pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
836 self.get(http::KV_PATH.to_owned()).await
837 }
838
839 pub async fn kv_delete_many(
842 &self,
843 namespace: &str,
844 filter: &KvScanQuery,
845 ) -> ClientResult<usize, T::Error> {
846 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
847 let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
848 Ok(view.deleted)
849 }
850
851 pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
854 let view: PromotedView = self
855 .send_empty(Method::Post, http::fork_promote_path(id))
856 .await?;
857 Ok(view.rows)
858 }
859
860 pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
862 self.expect_ok(Method::Delete, http::fork_path(id), None)
863 .await
864 }
865
866 pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
868 self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
869 .await
870 }
871
872 pub async fn graph_query(
876 &self,
877 name: &str,
878 query: &GraphQuery,
879 ) -> ClientResult<GraphResultView, T::Error> {
880 self.send_json(Method::Post, http::graph_query_path(name), query)
881 .await
882 }
883
884 pub async fn graph_neighbors(
889 &self,
890 name: &str,
891 node: &str,
892 query: &GraphNeighborsQuery,
893 ) -> ClientResult<GraphResultView, T::Error> {
894 self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
895 .await
896 }
897
898 pub async fn list_graphs(
902 &self,
903 filter: &ProjectionListQuery,
904 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
905 self.get(with_query(http::GRAPHS_PATH, filter)?).await
906 }
907
908 pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
912 self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
913 .await
914 }
915
916 pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
919 self.get_optional(http::graph_path(id)).await
920 }
921
922 pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
925 self.expect_ok(Method::Delete, http::graph_path(id), None)
926 .await
927 }
928
929 pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
933 self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
934 }
935
936 pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
938 self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
939 }
940
941 pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
943 self.get_optional(http::authz_role_path(name)).await
944 }
945
946 pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
949 self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
950 .await
951 }
952
953 pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
955 self.expect_ok(Method::Delete, http::authz_role_path(name), None)
956 .await
957 }
958
959 pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
961 self.get(http::authz_user_roles_path(user_id)).await
962 }
963
964 pub async fn bind_user_roles(
966 &self,
967 user_id: u32,
968 roles: &[String],
969 ) -> ClientResult<(), T::Error> {
970 self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
971 .await
972 }
973
974 async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
975 let response = self.dispatch(Method::Get, path, None).await?;
976 decode_ok(&response)
977 }
978
979 async fn get_optional<R: DeserializeOwned>(
981 &self,
982 path: String,
983 ) -> ClientResult<Option<R>, T::Error> {
984 let response = self.dispatch(Method::Get, path, None).await?;
985 if response.status == 404 {
986 return Ok(None);
987 }
988 decode_ok(&response).map(Some)
989 }
990
991 async fn send_json<B: Serialize, R: DeserializeOwned>(
992 &self,
993 method: Method,
994 path: String,
995 body: &B,
996 ) -> ClientResult<R, T::Error> {
997 let payload = serde_json::to_vec(body)
998 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
999 let response = self.dispatch(method, path, Some(payload)).await?;
1000 decode_ok(&response)
1001 }
1002
1003 async fn send_empty<R: DeserializeOwned>(
1004 &self,
1005 method: Method,
1006 path: String,
1007 ) -> ClientResult<R, T::Error> {
1008 let response = self.dispatch(method, path, None).await?;
1009 decode_ok(&response)
1010 }
1011
1012 async fn send_json_ok<B: Serialize>(
1015 &self,
1016 method: Method,
1017 path: String,
1018 body: &B,
1019 ) -> ClientResult<(), T::Error> {
1020 let payload = serde_json::to_vec(body)
1021 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1022 let response = self.dispatch(method, path, Some(payload)).await?;
1023 check_status(&response)
1024 }
1025
1026 async fn expect_ok(
1027 &self,
1028 method: Method,
1029 path: String,
1030 body: Option<Vec<u8>>,
1031 ) -> ClientResult<(), T::Error> {
1032 let response = self.dispatch(method, path, body).await?;
1033 check_status(&response)
1034 }
1035
1036 async fn dispatch(
1037 &self,
1038 method: Method,
1039 path: String,
1040 body: Option<Vec<u8>>,
1041 ) -> ClientResult<HttpResponse, T::Error> {
1042 self.transport
1043 .send(HttpRequest { method, path, body })
1044 .await
1045 .map_err(ClientError::Transport)
1046 }
1047}
1048
1049fn schema_route<E>(path: String, stream: Option<&str>) -> Result<String, ClientError<E>> {
1052 with_query(
1053 &path,
1054 &http::SchemaQuery {
1055 stream: stream.map(str::to_owned),
1056 },
1057 )
1058}
1059
1060fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
1061 let query = serde_urlencoded::to_string(params)
1062 .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
1063 if query.is_empty() {
1064 Ok(path.to_owned())
1065 } else {
1066 Ok(format!("{path}?{query}"))
1067 }
1068}
1069
1070fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
1074 if (200..300).contains(&response.status) {
1075 serde_json::from_slice(&response.body)
1076 .map_err(|error| ClientError::Decode(format!("response body: {error}")))
1077 } else {
1078 Err(api_error(response))
1079 }
1080}
1081
1082fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
1084 if (200..300).contains(&response.status) {
1085 Ok(())
1086 } else {
1087 Err(api_error(response))
1088 }
1089}
1090
1091fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
1092 let body = serde_json::from_slice::<ErrorBody>(&response.body).unwrap_or_else(|_| {
1093 ErrorBody::new(
1094 code_for_status(response.status),
1095 String::from_utf8_lossy(&response.body).into_owned(),
1096 )
1097 });
1098 ClientError::Api(body)
1099}
1100
1101fn code_for_status(status: u16) -> ResultCode {
1105 match status {
1106 404 => ResultCode::NotFound,
1107 400 => ResultCode::InvalidArgument,
1108 401 => ResultCode::Unauthenticated,
1109 403 => ResultCode::Forbidden,
1110 409 => ResultCode::Conflict,
1111 413 => ResultCode::TooLarge,
1112 501 => ResultCode::Unsupported,
1113 503 => ResultCode::Stale,
1114 _ => ResultCode::Backend,
1115 }
1116}
1117
1118const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1122
1123pub fn base64url_encode(input: &[u8]) -> String {
1125 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1126 for chunk in input.chunks(3) {
1127 let b0 = chunk[0] as usize;
1128 out.push(B64URL[b0 >> 2] as char);
1129 match chunk.len() {
1130 1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
1131 2 => {
1132 let b1 = chunk[1] as usize;
1133 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1134 out.push(B64URL[(b1 & 0b1111) << 2] as char);
1135 }
1136 _ => {
1137 let b1 = chunk[1] as usize;
1138 let b2 = chunk[2] as usize;
1139 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1140 out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
1141 out.push(B64URL[b2 & 0b111111] as char);
1142 }
1143 }
1144 }
1145 out
1146}
1147
1148pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
1151 fn val(byte: u8) -> Option<u8> {
1152 match byte {
1153 b'A'..=b'Z' => Some(byte - b'A'),
1154 b'a'..=b'z' => Some(byte - b'a' + 26),
1155 b'0'..=b'9' => Some(byte - b'0' + 52),
1156 b'-' => Some(62),
1157 b'_' => Some(63),
1158 _ => None,
1159 }
1160 }
1161 let bytes = input.as_bytes();
1162 if bytes.len() % 4 == 1 {
1163 return None;
1164 }
1165 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1166 for chunk in bytes.chunks(4) {
1167 let mut acc = 0u32;
1168 for &byte in chunk {
1169 acc = (acc << 6) | u32::from(val(byte)?);
1170 }
1171 acc <<= 6 * (4 - chunk.len());
1173 match chunk.len() {
1174 2 => out.push((acc >> 16) as u8),
1175 3 => {
1176 out.push((acc >> 16) as u8);
1177 out.push((acc >> 8) as u8);
1178 }
1179 _ => {
1180 out.push((acc >> 16) as u8);
1181 out.push((acc >> 8) as u8);
1182 out.push(acc as u8);
1183 }
1184 }
1185 }
1186 Some(out)
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191 use super::*;
1192
1193 #[test]
1194 fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
1195 for case in [
1196 &b""[..],
1197 &b"f"[..],
1198 &b"fo"[..],
1199 &b"foo"[..],
1200 &b"foob"[..],
1201 &b"fooba"[..],
1202 &b"foobar"[..],
1203 &[0x00, 0xff, 0x10, 0x80][..],
1204 ] {
1205 let encoded = base64url_encode(case);
1206 assert!(
1207 !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1208 "url-safe unpadded: {encoded}"
1209 );
1210 assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1211 }
1212 }
1213
1214 #[test]
1215 fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1216 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1217 assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1218 }
1219
1220 #[test]
1221 fn given_checkpoint_reads_when_paths_are_built_then_should_require_the_selected_consistency() {
1222 let single = with_query::<(), _>(
1223 &http::destination_status_path(DestinationId::from_u128(1)),
1224 &CheckpointReadQuery::new(CheckpointReadConsistency::Linearizable),
1225 )
1226 .expect("single checkpoint read query encodes");
1227 assert!(single.ends_with("?consistency=linearizable"));
1228
1229 let list = with_query::<(), _>(
1230 http::DESTINATIONS_PATH,
1231 &DestinationListQuery {
1232 source_stream: None,
1233 source_topic: None,
1234 name_contains: None,
1235 after: None,
1236 limit: None,
1237 consistency: CheckpointReadConsistency::PotentiallyStale,
1238 },
1239 )
1240 .expect("checkpoint list query encodes");
1241 assert!(list.ends_with("?consistency=potentially_stale"));
1242 }
1243
1244 #[test]
1245 fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1246 assert!(
1247 base64url_decode("====").is_none(),
1248 "padding is not alphabet"
1249 );
1250 assert!(
1251 base64url_decode("A").is_none(),
1252 "a lone char carries no byte"
1253 );
1254 assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1255 }
1256
1257 struct CannedTransport {
1260 response: HttpResponse,
1261 }
1262
1263 impl Transport for CannedTransport {
1264 type Error = std::convert::Infallible;
1265 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1266 Ok(self.response.clone())
1267 }
1268 }
1269
1270 fn block_on<F: core::future::Future>(future: F) -> F::Output {
1271 use core::task::{Context, Poll, Waker};
1275 let mut context = Context::from_waker(Waker::noop());
1276 let mut future = core::pin::pin!(future);
1277 loop {
1278 if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1279 return output;
1280 }
1281 }
1282 }
1283
1284 #[test]
1285 fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1286 let body = serde_json::to_vec(&Capabilities::new(
1287 true,
1288 crate::hello::OpVersions::new(1, 1, 1, 1),
1289 ))
1290 .unwrap();
1291 let client = HttpClient::new(CannedTransport {
1292 response: HttpResponse::new(200, body),
1293 });
1294 let caps = block_on(client.capabilities()).expect("decodes");
1295 assert!(caps.managed && !caps.kv.cas);
1296 }
1297
1298 #[test]
1299 fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1300 let body =
1301 serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1302 let client = HttpClient::new(CannedTransport {
1303 response: HttpResponse::new(404, body),
1304 });
1305 let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1306 assert_eq!(error.code(), Some(ResultCode::NotFound));
1307 }
1308
1309 #[test]
1310 fn given_a_missing_binding_when_fetched_then_should_distinguish_absence_from_missing_source() {
1311 let absent = HttpClient::new(CannedTransport {
1312 response: HttpResponse::new(404, Vec::new()),
1313 });
1314 assert!(
1315 block_on(absent.get_filter_binding("stream", "topic", "group"))
1316 .unwrap()
1317 .is_none()
1318 );
1319 let body = serde_json::to_vec(&ErrorBody::new(
1320 ResultCode::NotFound,
1321 "topic does not exist",
1322 ))
1323 .unwrap();
1324 let missing_source = HttpClient::new(CannedTransport {
1325 response: HttpResponse::new(404, body),
1326 });
1327 let error =
1328 block_on(missing_source.get_filter_binding("stream", "topic", "group")).unwrap_err();
1329 assert_eq!(error.code(), Some(ResultCode::NotFound));
1330 }
1331
1332 #[test]
1333 fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
1334 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1335 let client = HttpClient::new(CannedTransport {
1336 response: HttpResponse::new(404, body),
1337 });
1338 let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1339 assert!(entry.is_none());
1340 }
1341
1342 #[test]
1343 fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
1344 let response = HttpResponse::new(200, b"world".to_vec())
1345 .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
1346 let client = HttpClient::new(CannedTransport { response });
1347 let entry = block_on(client.kv_get("sessions", b"user:1"))
1348 .expect("decodes")
1349 .expect("present");
1350 assert_eq!(entry.value, b"world");
1351 assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1352 }
1353
1354 #[test]
1355 fn given_a_missing_projection_when_fetched_then_should_be_none() {
1356 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1357 let client = HttpClient::new(CannedTransport {
1358 response: HttpResponse::new(404, body),
1359 });
1360 let info = block_on(client.get_projection("reading.v1")).expect("404 maps to None");
1361 assert!(info.is_none());
1362 }
1363
1364 #[test]
1365 fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1366 let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1367 let client = HttpClient::new(CannedTransport {
1368 response: HttpResponse::new(200, body),
1369 });
1370 let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1371 .expect("decodes the count");
1372 assert_eq!(removed, 7);
1373 }
1374
1375 #[test]
1376 fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1377 let client = HttpClient::new(CannedTransport {
1378 response: HttpResponse::new(204, Vec::new()),
1379 });
1380 block_on(client.drop_projection("reading.v1")).expect("a 204 is a success");
1381 }
1382
1383 #[test]
1384 fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1385 let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1386 let client = HttpClient::new(CannedTransport {
1387 response: HttpResponse::new(200, body),
1388 });
1389 let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1390 .expect("a commit returns the new version");
1391 assert_eq!(version, 4);
1392 }
1393
1394 #[test]
1395 fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1396 let reply = WhoamiReply {
1397 v: 1,
1398 roles: vec!["admin".to_owned()],
1399 grants: vec![crate::authz::Grant {
1400 effect: crate::authz::Effect::Allow,
1401 feature: crate::authz::Feature::Authz,
1402 action: crate::authz::Action::Admin,
1403 resource: crate::authz::ResourcePattern::all(),
1404 }],
1405 };
1406 let body = serde_json::to_vec(&reply).unwrap();
1407 let client = HttpClient::new(CannedTransport {
1408 response: HttpResponse::new(200, body),
1409 });
1410 let whoami = block_on(client.authz_whoami()).expect("decodes");
1411 assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1412 assert_eq!(whoami.grants.len(), 1);
1413 }
1414
1415 #[test]
1416 fn given_a_missing_role_when_fetched_then_should_be_none() {
1417 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1418 let client = HttpClient::new(CannedTransport {
1419 response: HttpResponse::new(404, body),
1420 });
1421 let role = block_on(client.get_role("ghost")).expect("404 maps to None");
1422 assert!(role.is_none());
1423 }
1424
1425 #[test]
1426 fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1427 let body = serde_json::to_vec(
1428 &ErrorBody::new(ResultCode::Conflict, "version conflict")
1429 .with_detail(serde_json::json!({ "current": 3 })),
1430 )
1431 .unwrap();
1432 let client = HttpClient::new(CannedTransport {
1433 response: HttpResponse::new(409, body),
1434 });
1435 let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1436 .expect_err("a precondition miss is an error");
1437 assert_eq!(error.code(), Some(ResultCode::Conflict));
1438 }
1439}