1use crate::agent_workflow::{AgentRunInfo, AgentSubmit};
15use crate::authz::{Role, WhoamiReply};
16use crate::browse::{ProjectionInfo, SchemaInfo};
17use crate::checkpoint::CheckpointReadConsistency;
18use crate::control::{Projection, ProjectionBinding, SchemaSource, SourceSelector};
19use crate::destination::{DestinationId, DestinationOperationId};
20use crate::filter::{
21 ConsumerFilter, FilterBinding, FilterBindingPage, FilterDetail, FilterMutationOutcome,
22 FilterMutationRequest, FilterPage, FilterPreview, FilterPreviewRequest, FilterRevisionPage,
23 FilterTestResult, FilterValidation,
24};
25use crate::fork::ForkInfo;
26use crate::graph::GraphQuery;
27use crate::http::{
28 self, AcceptedOperationView, Capabilities, CasCommittedView, CheckpointReadQuery,
29 ClientMetadataListView, ClientsQuery, DecodeRecordBody, DeletedManyView, DestinationIssueView,
30 DestinationListQuery, DestinationMutationBody, DestinationPageView, DestinationView, ErrorBody,
31 FilterBindingListQuery, FilterListQuery, FilterRevisionListQuery, FilterTestBody,
32 ForkCreateBody, ForkPutBody, GraphNeighborsQuery, GraphResultView, KvCasQuery, KvPageView,
33 KvPutQuery, KvScanQuery, ProjectionListQuery, PromotedView, QueryExecutionView, QueryPageBody,
34 QueryRouteListQuery, QueryRoutePageView, RemoveBindingBody, RunPageView, RunsQuery,
35 SchemaListQuery, SnapshotListQuery, SnapshotPageView, TableFileListQuery, TableFilePageView,
36 TableMetricsView, 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(
422 &self,
423 source: SchemaSource,
424 name: Option<String>,
425 version: Option<u32>,
426 ) -> ClientResult<u32, T::Error> {
427 let body = http::RegisterSchemaBody {
428 source,
429 name,
430 version,
431 };
432 self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
433 .await
434 }
435
436 pub async fn kv_get(
442 &self,
443 namespace: &str,
444 key: &[u8],
445 ) -> ClientResult<Option<KvValue>, T::Error> {
446 let path = http::kv_entry_path(namespace, &base64url_encode(key));
447 let response = self.dispatch(Method::Get, path, None).await?;
448 if response.status == 404 {
449 return Ok(None);
450 }
451 if !(200..300).contains(&response.status) {
452 return Err(api_error(&response));
453 }
454 let expires_at_micros = response
455 .header(http::KV_EXPIRES_AT_MICROS_HEADER)
456 .and_then(|value| value.parse::<u64>().ok());
457 Ok(Some(KvValue {
458 value: response.body,
459 expires_at_micros,
460 }))
461 }
462
463 pub async fn kv_set(
466 &self,
467 namespace: &str,
468 key: &[u8],
469 value: &[u8],
470 expires_at_micros: Option<u64>,
471 ) -> ClientResult<(), T::Error> {
472 let path = with_query(
473 &http::kv_entry_path(namespace, &base64url_encode(key)),
474 &KvPutQuery { expires_at_micros },
475 )?;
476 self.expect_ok(Method::Put, path, Some(value.to_vec()))
479 .await
480 }
481
482 pub async fn kv_cas(
490 &self,
491 namespace: &str,
492 key: &[u8],
493 value: &[u8],
494 expect: CasExpect,
495 expires_at_micros: Option<u64>,
496 ) -> ClientResult<u64, T::Error> {
497 let (expect_version, expect_absent) = match expect {
498 CasExpect::Match(version) => (Some(version), None),
499 CasExpect::Absent => (None, Some(true)),
500 };
501 let path = with_query(
502 &http::kv_cas_path(namespace, &base64url_encode(key)),
503 &KvCasQuery {
504 expect_version,
505 expect_absent,
506 expires_at_micros,
507 },
508 )?;
509 let response = self
511 .dispatch(Method::Put, path, Some(value.to_vec()))
512 .await?;
513 let view: CasCommittedView = decode_ok(&response)?;
514 Ok(view.version)
515 }
516
517 pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
519 let path = http::kv_entry_path(namespace, &base64url_encode(key));
520 self.send_empty(Method::Delete, path).await
521 }
522
523 pub async fn kv_scan(
525 &self,
526 namespace: &str,
527 filter: &KvScanQuery,
528 ) -> ClientResult<KvPageView, T::Error> {
529 self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
530 .await
531 }
532
533 pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
535 self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
536 .await
537 }
538
539 pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
541 self.get(http::FORKS_PATH.to_owned()).await
542 }
543
544 pub async fn clients(
548 &self,
549 query: &ClientsQuery,
550 ) -> ClientResult<ClientMetadataListView, T::Error> {
551 self.get(with_query(http::CLIENTS_PATH, query)?).await
552 }
553
554 pub async fn submit_run(&self, body: &AgentSubmit) -> ClientResult<AgentRunInfo, T::Error> {
557 self.send_json(Method::Post, http::RUNS_PATH.to_owned(), body)
558 .await
559 }
560
561 pub async fn run_status(&self, id: &str) -> ClientResult<Option<AgentRunInfo>, T::Error> {
563 self.get_optional(http::run_path(id)).await
564 }
565
566 pub async fn list_runs(&self, query: &RunsQuery) -> ClientResult<RunPageView, T::Error> {
569 self.get(with_query(http::RUNS_PATH, query)?).await
570 }
571
572 pub async fn cancel_run(&self, id: &str) -> ClientResult<AgentRunInfo, T::Error> {
575 self.send_empty(Method::Post, http::run_cancel_path(id))
576 .await
577 }
578
579 pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
582 self.get_optional(http::projection_path(id)).await
583 }
584
585 pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
588 self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
589 .await
590 }
591
592 pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
594 self.expect_ok(Method::Delete, http::projection_path(id), None)
595 .await
596 }
597
598 pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
600 self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
601 .await
602 }
603
604 pub async fn remove_binding(
607 &self,
608 source: &SourceSelector,
609 projection_ref: Option<String>,
610 ) -> ClientResult<(), T::Error> {
611 let body = RemoveBindingBody {
612 stream: source.stream.clone(),
613 topic: source.topic.clone(),
614 projection_ref,
615 };
616 self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
617 .await
618 }
619
620 pub async fn get_schema(&self, id: u32) -> ClientResult<Option<SchemaInfo>, T::Error> {
622 self.get_optional(http::schema_path(id)).await
623 }
624
625 pub async fn drop_schema(&self, id: u32) -> ClientResult<(), T::Error> {
627 self.expect_ok(Method::Delete, http::schema_path(id), None)
628 .await
629 }
630
631 pub async fn decode_record(
634 &self,
635 id: u32,
636 payload: &[u8],
637 ) -> ClientResult<Option<serde_json::Value>, T::Error> {
638 let body = DecodeRecordBody {
639 payload: base64url_encode(payload),
640 };
641 self.send_json(Method::Post, http::schema_decode_path(id), &body)
642 .await
643 }
644
645 pub async fn list_filters(
647 &self,
648 query: &FilterListQuery,
649 ) -> ClientResult<FilterPage, T::Error> {
650 self.get(with_query(http::FILTERS_PATH, query)?).await
651 }
652
653 pub async fn get_filter(&self, id: u32) -> ClientResult<Option<FilterDetail>, T::Error> {
655 self.get_optional(http::filter_path(id)).await
656 }
657
658 pub async fn list_filter_revisions(
660 &self,
661 id: u32,
662 query: &FilterRevisionListQuery,
663 ) -> ClientResult<FilterRevisionPage, T::Error> {
664 self.get(with_query(&http::filter_revisions_path(id), query)?)
665 .await
666 }
667
668 pub async fn mutate_filter(
673 &self,
674 request: &FilterMutationRequest,
675 ) -> ClientResult<FilterMutationOutcome, T::Error> {
676 let payload = serde_json::to_vec(request)
677 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
678 let response = self
679 .dispatch(
680 Method::Post,
681 http::FILTER_MUTATIONS_PATH.to_owned(),
682 Some(payload),
683 )
684 .await?;
685 if let Ok(outcome) = serde_json::from_slice::<FilterMutationOutcome>(&response.body) {
686 return Ok(outcome);
687 }
688 decode_ok(&response)
689 }
690
691 pub async fn filter_operation(
694 &self,
695 operation_id: u128,
696 ) -> ClientResult<Option<FilterMutationOutcome>, T::Error> {
697 self.get_optional(http::filter_operation_path(operation_id))
698 .await
699 }
700
701 pub async fn validate_filter(
703 &self,
704 filter: &ConsumerFilter,
705 ) -> ClientResult<FilterValidation, T::Error> {
706 self.send_json(Method::Post, http::FILTER_VALIDATE_PATH.to_owned(), filter)
707 .await
708 }
709
710 pub async fn test_filter(
713 &self,
714 body: &FilterTestBody,
715 ) -> ClientResult<FilterTestResult, T::Error> {
716 self.send_json(Method::Post, http::FILTER_TEST_PATH.to_owned(), body)
717 .await
718 }
719
720 pub async fn preview_filter(
723 &self,
724 request: &FilterPreviewRequest,
725 ) -> ClientResult<FilterPreview, T::Error> {
726 self.send_json(Method::Post, http::FILTER_PREVIEW_PATH.to_owned(), request)
727 .await
728 }
729
730 pub async fn list_filter_bindings(
732 &self,
733 query: &FilterBindingListQuery,
734 ) -> ClientResult<FilterBindingPage, T::Error> {
735 self.get(with_query(http::FILTER_BINDINGS_PATH, query)?)
736 .await
737 }
738
739 pub async fn get_filter_binding(
742 &self,
743 stream: &str,
744 topic: &str,
745 group: &str,
746 ) -> ClientResult<Option<FilterBinding>, T::Error> {
747 let response = self
748 .dispatch(
749 Method::Get,
750 http::filter_binding_path(stream, topic, group),
751 None,
752 )
753 .await?;
754 if response.status == 404 && response.body.is_empty() {
755 return Ok(None);
756 }
757 decode_ok(&response).map(Some)
758 }
759
760 pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
762 self.get(http::KV_PATH.to_owned()).await
763 }
764
765 pub async fn kv_delete_many(
768 &self,
769 namespace: &str,
770 filter: &KvScanQuery,
771 ) -> ClientResult<usize, T::Error> {
772 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
773 let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
774 Ok(view.deleted)
775 }
776
777 pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
780 let view: PromotedView = self
781 .send_empty(Method::Post, http::fork_promote_path(id))
782 .await?;
783 Ok(view.rows)
784 }
785
786 pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
788 self.expect_ok(Method::Delete, http::fork_path(id), None)
789 .await
790 }
791
792 pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
794 self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
795 .await
796 }
797
798 pub async fn graph_query(
802 &self,
803 name: &str,
804 query: &GraphQuery,
805 ) -> ClientResult<GraphResultView, T::Error> {
806 self.send_json(Method::Post, http::graph_query_path(name), query)
807 .await
808 }
809
810 pub async fn graph_neighbors(
815 &self,
816 name: &str,
817 node: &str,
818 query: &GraphNeighborsQuery,
819 ) -> ClientResult<GraphResultView, T::Error> {
820 self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
821 .await
822 }
823
824 pub async fn list_graphs(
828 &self,
829 filter: &ProjectionListQuery,
830 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
831 self.get(with_query(http::GRAPHS_PATH, filter)?).await
832 }
833
834 pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
838 self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
839 .await
840 }
841
842 pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
845 self.get_optional(http::graph_path(id)).await
846 }
847
848 pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
851 self.expect_ok(Method::Delete, http::graph_path(id), None)
852 .await
853 }
854
855 pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
859 self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
860 }
861
862 pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
864 self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
865 }
866
867 pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
869 self.get_optional(http::authz_role_path(name)).await
870 }
871
872 pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
875 self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
876 .await
877 }
878
879 pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
881 self.expect_ok(Method::Delete, http::authz_role_path(name), None)
882 .await
883 }
884
885 pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
887 self.get(http::authz_user_roles_path(user_id)).await
888 }
889
890 pub async fn bind_user_roles(
892 &self,
893 user_id: u32,
894 roles: &[String],
895 ) -> ClientResult<(), T::Error> {
896 self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
897 .await
898 }
899
900 async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
901 let response = self.dispatch(Method::Get, path, None).await?;
902 decode_ok(&response)
903 }
904
905 async fn get_optional<R: DeserializeOwned>(
907 &self,
908 path: String,
909 ) -> ClientResult<Option<R>, T::Error> {
910 let response = self.dispatch(Method::Get, path, None).await?;
911 if response.status == 404 {
912 return Ok(None);
913 }
914 decode_ok(&response).map(Some)
915 }
916
917 async fn send_json<B: Serialize, R: DeserializeOwned>(
918 &self,
919 method: Method,
920 path: String,
921 body: &B,
922 ) -> ClientResult<R, T::Error> {
923 let payload = serde_json::to_vec(body)
924 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
925 let response = self.dispatch(method, path, Some(payload)).await?;
926 decode_ok(&response)
927 }
928
929 async fn send_empty<R: DeserializeOwned>(
930 &self,
931 method: Method,
932 path: String,
933 ) -> ClientResult<R, T::Error> {
934 let response = self.dispatch(method, path, None).await?;
935 decode_ok(&response)
936 }
937
938 async fn send_json_ok<B: Serialize>(
941 &self,
942 method: Method,
943 path: String,
944 body: &B,
945 ) -> ClientResult<(), T::Error> {
946 let payload = serde_json::to_vec(body)
947 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
948 let response = self.dispatch(method, path, Some(payload)).await?;
949 check_status(&response)
950 }
951
952 async fn expect_ok(
953 &self,
954 method: Method,
955 path: String,
956 body: Option<Vec<u8>>,
957 ) -> ClientResult<(), T::Error> {
958 let response = self.dispatch(method, path, body).await?;
959 check_status(&response)
960 }
961
962 async fn dispatch(
963 &self,
964 method: Method,
965 path: String,
966 body: Option<Vec<u8>>,
967 ) -> ClientResult<HttpResponse, T::Error> {
968 self.transport
969 .send(HttpRequest { method, path, body })
970 .await
971 .map_err(ClientError::Transport)
972 }
973}
974
975fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
978 let query = serde_urlencoded::to_string(params)
979 .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
980 if query.is_empty() {
981 Ok(path.to_owned())
982 } else {
983 Ok(format!("{path}?{query}"))
984 }
985}
986
987fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
991 if (200..300).contains(&response.status) {
992 serde_json::from_slice(&response.body)
993 .map_err(|error| ClientError::Decode(format!("response body: {error}")))
994 } else {
995 Err(api_error(response))
996 }
997}
998
999fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
1001 if (200..300).contains(&response.status) {
1002 Ok(())
1003 } else {
1004 Err(api_error(response))
1005 }
1006}
1007
1008fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
1009 let body = serde_json::from_slice::<ErrorBody>(&response.body).unwrap_or_else(|_| {
1010 ErrorBody::new(
1011 code_for_status(response.status),
1012 String::from_utf8_lossy(&response.body).into_owned(),
1013 )
1014 });
1015 ClientError::Api(body)
1016}
1017
1018fn code_for_status(status: u16) -> ResultCode {
1022 match status {
1023 404 => ResultCode::NotFound,
1024 400 => ResultCode::InvalidArgument,
1025 401 => ResultCode::Unauthenticated,
1026 403 => ResultCode::Forbidden,
1027 409 => ResultCode::Conflict,
1028 413 => ResultCode::TooLarge,
1029 501 => ResultCode::Unsupported,
1030 503 => ResultCode::Stale,
1031 _ => ResultCode::Backend,
1032 }
1033}
1034
1035const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1039
1040pub fn base64url_encode(input: &[u8]) -> String {
1042 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1043 for chunk in input.chunks(3) {
1044 let b0 = chunk[0] as usize;
1045 out.push(B64URL[b0 >> 2] as char);
1046 match chunk.len() {
1047 1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
1048 2 => {
1049 let b1 = chunk[1] as usize;
1050 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1051 out.push(B64URL[(b1 & 0b1111) << 2] as char);
1052 }
1053 _ => {
1054 let b1 = chunk[1] as usize;
1055 let b2 = chunk[2] as usize;
1056 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1057 out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
1058 out.push(B64URL[b2 & 0b111111] as char);
1059 }
1060 }
1061 }
1062 out
1063}
1064
1065pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
1068 fn val(byte: u8) -> Option<u8> {
1069 match byte {
1070 b'A'..=b'Z' => Some(byte - b'A'),
1071 b'a'..=b'z' => Some(byte - b'a' + 26),
1072 b'0'..=b'9' => Some(byte - b'0' + 52),
1073 b'-' => Some(62),
1074 b'_' => Some(63),
1075 _ => None,
1076 }
1077 }
1078 let bytes = input.as_bytes();
1079 if bytes.len() % 4 == 1 {
1080 return None;
1081 }
1082 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1083 for chunk in bytes.chunks(4) {
1084 let mut acc = 0u32;
1085 for &byte in chunk {
1086 acc = (acc << 6) | u32::from(val(byte)?);
1087 }
1088 acc <<= 6 * (4 - chunk.len());
1090 match chunk.len() {
1091 2 => out.push((acc >> 16) as u8),
1092 3 => {
1093 out.push((acc >> 16) as u8);
1094 out.push((acc >> 8) as u8);
1095 }
1096 _ => {
1097 out.push((acc >> 16) as u8);
1098 out.push((acc >> 8) as u8);
1099 out.push(acc as u8);
1100 }
1101 }
1102 }
1103 Some(out)
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108 use super::*;
1109
1110 #[test]
1111 fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
1112 for case in [
1113 &b""[..],
1114 &b"f"[..],
1115 &b"fo"[..],
1116 &b"foo"[..],
1117 &b"foob"[..],
1118 &b"fooba"[..],
1119 &b"foobar"[..],
1120 &[0x00, 0xff, 0x10, 0x80][..],
1121 ] {
1122 let encoded = base64url_encode(case);
1123 assert!(
1124 !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1125 "url-safe unpadded: {encoded}"
1126 );
1127 assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1128 }
1129 }
1130
1131 #[test]
1132 fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1133 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1134 assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1135 }
1136
1137 #[test]
1138 fn given_checkpoint_reads_when_paths_are_built_then_should_require_the_selected_consistency() {
1139 let single = with_query::<(), _>(
1140 &http::destination_status_path(DestinationId::from_u128(1)),
1141 &CheckpointReadQuery::new(CheckpointReadConsistency::Linearizable),
1142 )
1143 .expect("single checkpoint read query encodes");
1144 assert!(single.ends_with("?consistency=linearizable"));
1145
1146 let list = with_query::<(), _>(
1147 http::DESTINATIONS_PATH,
1148 &DestinationListQuery {
1149 source_stream: None,
1150 source_topic: None,
1151 name_contains: None,
1152 after: None,
1153 limit: None,
1154 consistency: CheckpointReadConsistency::PotentiallyStale,
1155 },
1156 )
1157 .expect("checkpoint list query encodes");
1158 assert!(list.ends_with("?consistency=potentially_stale"));
1159 }
1160
1161 #[test]
1162 fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1163 assert!(
1164 base64url_decode("====").is_none(),
1165 "padding is not alphabet"
1166 );
1167 assert!(
1168 base64url_decode("A").is_none(),
1169 "a lone char carries no byte"
1170 );
1171 assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1172 }
1173
1174 struct CannedTransport {
1177 response: HttpResponse,
1178 }
1179
1180 impl Transport for CannedTransport {
1181 type Error = std::convert::Infallible;
1182 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1183 Ok(self.response.clone())
1184 }
1185 }
1186
1187 fn block_on<F: core::future::Future>(future: F) -> F::Output {
1188 use core::task::{Context, Poll, Waker};
1192 let mut context = Context::from_waker(Waker::noop());
1193 let mut future = core::pin::pin!(future);
1194 loop {
1195 if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1196 return output;
1197 }
1198 }
1199 }
1200
1201 #[test]
1202 fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1203 let body = serde_json::to_vec(&Capabilities::new(
1204 true,
1205 crate::hello::OpVersions::new(1, 1, 1, 1),
1206 ))
1207 .unwrap();
1208 let client = HttpClient::new(CannedTransport {
1209 response: HttpResponse::new(200, body),
1210 });
1211 let caps = block_on(client.capabilities()).expect("decodes");
1212 assert!(caps.managed && !caps.kv.cas);
1213 }
1214
1215 #[test]
1216 fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1217 let body =
1218 serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1219 let client = HttpClient::new(CannedTransport {
1220 response: HttpResponse::new(404, body),
1221 });
1222 let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1223 assert_eq!(error.code(), Some(ResultCode::NotFound));
1224 }
1225
1226 #[test]
1227 fn given_a_missing_binding_when_fetched_then_should_distinguish_absence_from_missing_source() {
1228 let absent = HttpClient::new(CannedTransport {
1229 response: HttpResponse::new(404, Vec::new()),
1230 });
1231 assert!(
1232 block_on(absent.get_filter_binding("stream", "topic", "group"))
1233 .unwrap()
1234 .is_none()
1235 );
1236 let body = serde_json::to_vec(&ErrorBody::new(
1237 ResultCode::NotFound,
1238 "topic does not exist",
1239 ))
1240 .unwrap();
1241 let missing_source = HttpClient::new(CannedTransport {
1242 response: HttpResponse::new(404, body),
1243 });
1244 let error =
1245 block_on(missing_source.get_filter_binding("stream", "topic", "group")).unwrap_err();
1246 assert_eq!(error.code(), Some(ResultCode::NotFound));
1247 }
1248
1249 #[test]
1250 fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
1251 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1252 let client = HttpClient::new(CannedTransport {
1253 response: HttpResponse::new(404, body),
1254 });
1255 let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1256 assert!(entry.is_none());
1257 }
1258
1259 #[test]
1260 fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
1261 let response = HttpResponse::new(200, b"world".to_vec())
1262 .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
1263 let client = HttpClient::new(CannedTransport { response });
1264 let entry = block_on(client.kv_get("sessions", b"user:1"))
1265 .expect("decodes")
1266 .expect("present");
1267 assert_eq!(entry.value, b"world");
1268 assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1269 }
1270
1271 #[test]
1272 fn given_a_missing_projection_when_fetched_then_should_be_none() {
1273 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1274 let client = HttpClient::new(CannedTransport {
1275 response: HttpResponse::new(404, body),
1276 });
1277 let info = block_on(client.get_projection("order.v1")).expect("404 maps to None");
1278 assert!(info.is_none());
1279 }
1280
1281 #[test]
1282 fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1283 let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1284 let client = HttpClient::new(CannedTransport {
1285 response: HttpResponse::new(200, body),
1286 });
1287 let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1288 .expect("decodes the count");
1289 assert_eq!(removed, 7);
1290 }
1291
1292 #[test]
1293 fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1294 let client = HttpClient::new(CannedTransport {
1295 response: HttpResponse::new(204, Vec::new()),
1296 });
1297 block_on(client.drop_projection("order.v1")).expect("a 204 is a success");
1298 }
1299
1300 #[test]
1301 fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1302 let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1303 let client = HttpClient::new(CannedTransport {
1304 response: HttpResponse::new(200, body),
1305 });
1306 let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1307 .expect("a commit returns the new version");
1308 assert_eq!(version, 4);
1309 }
1310
1311 #[test]
1312 fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1313 let reply = WhoamiReply {
1314 v: 1,
1315 roles: vec!["admin".to_owned()],
1316 grants: vec![crate::authz::Grant {
1317 effect: crate::authz::Effect::Allow,
1318 feature: crate::authz::Feature::Authz,
1319 action: crate::authz::Action::Admin,
1320 resource: crate::authz::ResourcePattern::all(),
1321 }],
1322 };
1323 let body = serde_json::to_vec(&reply).unwrap();
1324 let client = HttpClient::new(CannedTransport {
1325 response: HttpResponse::new(200, body),
1326 });
1327 let whoami = block_on(client.authz_whoami()).expect("decodes");
1328 assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1329 assert_eq!(whoami.grants.len(), 1);
1330 }
1331
1332 #[test]
1333 fn given_a_missing_role_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 role = block_on(client.get_role("ghost")).expect("404 maps to None");
1339 assert!(role.is_none());
1340 }
1341
1342 #[test]
1343 fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1344 let body = serde_json::to_vec(
1345 &ErrorBody::new(ResultCode::Conflict, "version conflict")
1346 .with_detail(serde_json::json!({ "current": 3 })),
1347 )
1348 .unwrap();
1349 let client = HttpClient::new(CannedTransport {
1350 response: HttpResponse::new(409, body),
1351 });
1352 let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1353 .expect_err("a precondition miss is an error");
1354 assert_eq!(error.code(), Some(ResultCode::Conflict));
1355 }
1356}