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::fork::ForkInfo;
21use crate::graph::GraphQuery;
22use crate::http::{
23 self, AcceptedOperationView, Capabilities, CasCommittedView, CheckpointReadQuery,
24 ClientMetadataListView, ClientsQuery, DecodeRecordBody, DeletedManyView, DestinationIssueView,
25 DestinationListQuery, DestinationMutationBody, DestinationPageView, DestinationView, ErrorBody,
26 ForkCreateBody, ForkPutBody, GraphNeighborsQuery, GraphResultView, KvCasQuery, KvPageView,
27 KvPutQuery, KvScanQuery, ProjectionListQuery, PromotedView, QueryExecutionView, QueryPageBody,
28 QueryRouteListQuery, QueryRoutePageView, RemoveBindingBody, RunPageView, RunsQuery,
29 SchemaListQuery, SnapshotListQuery, SnapshotPageView, TableFileListQuery, TableFilePageView,
30 TableMetricsView, TableSchemaView, TableSnapshotView, TableView,
31};
32use crate::kv::{CasExpect, KvNamespaceInfo};
33use crate::query::{Query, QueryResult};
34use crate::result::ResultCode;
35use serde::Serialize;
36use serde::de::DeserializeOwned;
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
40#[strum(serialize_all = "UPPERCASE")]
41pub enum Method {
42 Get,
43 Post,
44 Put,
45 Delete,
46}
47
48impl Method {
49 pub fn as_str(self) -> &'static str {
51 self.into()
52 }
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct HttpRequest {
62 pub method: Method,
63 pub path: String,
64 pub body: Option<Vec<u8>>,
65}
66
67#[derive(Clone, Debug, PartialEq, Eq, Default)]
72pub struct HttpResponse {
73 pub status: u16,
74 pub headers: Vec<(String, String)>,
75 pub body: Vec<u8>,
76}
77
78impl HttpResponse {
79 pub fn new(status: u16, body: Vec<u8>) -> Self {
81 Self {
82 status,
83 headers: Vec::new(),
84 body,
85 }
86 }
87
88 #[must_use]
90 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
91 self.headers.push((name.into(), value.into()));
92 self
93 }
94
95 pub fn header(&self, name: &str) -> Option<&str> {
98 self.headers
99 .iter()
100 .find(|(key, _)| key.eq_ignore_ascii_case(name))
101 .map(|(_, value)| value.as_str())
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct KvValue {
112 pub value: Vec<u8>,
113 pub expires_at_micros: Option<u64>,
114}
115
116#[allow(async_fn_in_trait)]
132pub trait Transport {
133 type Error: core::fmt::Display;
136
137 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
141}
142
143#[derive(Debug)]
145pub enum ClientError<E> {
146 Transport(E),
148 Decode(String),
150 Api(ErrorBody),
152}
153
154impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
155 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
156 match self {
157 ClientError::Transport(error) => write!(f, "transport error: {error}"),
158 ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
159 ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
160 }
161 }
162}
163
164impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
165
166impl<E> ClientError<E> {
167 pub fn code(&self) -> Option<ResultCode> {
171 match self {
172 ClientError::Api(body) => Some(body.code),
173 _ => None,
174 }
175 }
176}
177
178type ClientResult<T, E> = Result<T, ClientError<E>>;
179
180#[derive(Clone, Debug)]
188pub struct HttpClient<T> {
189 transport: T,
190}
191
192impl<T: Transport> HttpClient<T> {
193 pub fn new(transport: T) -> Self {
195 Self { transport }
196 }
197
198 pub fn transport(&self) -> &T {
200 &self.transport
201 }
202
203 pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
205 self.get(http::CAPABILITIES_PATH.to_owned()).await
206 }
207
208 pub async fn query(&self, query: &Query) -> ClientResult<QueryExecutionView, T::Error> {
210 self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
211 .await
212 }
213
214 pub async fn query_page(
215 &self,
216 execution_id: crate::query::QueryExecutionId,
217 body: &QueryPageBody,
218 ) -> ClientResult<QueryResult, T::Error> {
219 self.send_json(Method::Post, http::query_page_path(execution_id), body)
220 .await
221 }
222
223 pub async fn query_status(
224 &self,
225 execution_id: crate::query::QueryExecutionId,
226 ) -> ClientResult<QueryExecutionView, T::Error> {
227 self.get(http::query_execution_path(execution_id)).await
228 }
229
230 pub async fn cancel_query(
231 &self,
232 execution_id: crate::query::QueryExecutionId,
233 ) -> ClientResult<crate::query::QueryExecutionStatus, T::Error> {
234 self.send_empty(Method::Post, http::query_cancel_path(execution_id))
235 .await
236 }
237
238 pub async fn list_destinations(
239 &self,
240 query: &DestinationListQuery,
241 ) -> ClientResult<DestinationPageView, T::Error> {
242 self.get(with_query(http::DESTINATIONS_PATH, query)?).await
243 }
244
245 pub async fn destination(
246 &self,
247 id: DestinationId,
248 consistency: CheckpointReadConsistency,
249 ) -> ClientResult<Option<DestinationView>, T::Error> {
250 self.get_optional(with_query(
251 &http::destination_path(id),
252 &CheckpointReadQuery::new(consistency),
253 )?)
254 .await
255 }
256
257 pub async fn create_destination(
258 &self,
259 body: &DestinationMutationBody,
260 ) -> ClientResult<AcceptedOperationView, T::Error> {
261 self.send_json(Method::Post, http::DESTINATIONS_PATH.to_owned(), body)
262 .await
263 }
264
265 pub async fn enable_destination(
266 &self,
267 id: DestinationId,
268 body: &DestinationMutationBody,
269 ) -> ClientResult<AcceptedOperationView, T::Error> {
270 self.send_json(Method::Post, http::destination_enable_path(id), body)
271 .await
272 }
273
274 pub async fn disable_destination(
275 &self,
276 id: DestinationId,
277 body: &DestinationMutationBody,
278 ) -> ClientResult<AcceptedOperationView, T::Error> {
279 self.send_json(Method::Post, http::destination_disable_path(id), body)
280 .await
281 }
282
283 pub async fn destination_operation(
284 &self,
285 id: DestinationOperationId,
286 ) -> ClientResult<AcceptedOperationView, T::Error> {
287 self.get(http::destination_operation_path(id)).await
288 }
289
290 pub async fn destination_status(
291 &self,
292 id: DestinationId,
293 consistency: CheckpointReadConsistency,
294 ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
295 self.get(with_query(
296 &http::destination_status_path(id),
297 &CheckpointReadQuery::new(consistency),
298 )?)
299 .await
300 }
301
302 pub async fn destination_checkpoint(
303 &self,
304 id: DestinationId,
305 consistency: CheckpointReadConsistency,
306 ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
307 self.get(with_query(
308 &http::destination_checkpoint_path(id),
309 &CheckpointReadQuery::new(consistency),
310 )?)
311 .await
312 }
313
314 pub async fn destination_retention_gap(
315 &self,
316 id: DestinationId,
317 consistency: CheckpointReadConsistency,
318 ) -> ClientResult<DestinationIssueView, T::Error> {
319 self.get(with_query(
320 &http::destination_retention_gap_path(id),
321 &CheckpointReadQuery::new(consistency),
322 )?)
323 .await
324 }
325
326 pub async fn destination_prepared_attempt(
327 &self,
328 id: DestinationId,
329 consistency: CheckpointReadConsistency,
330 ) -> ClientResult<DestinationIssueView, T::Error> {
331 self.get(with_query(
332 &http::destination_prepared_attempt_path(id),
333 &CheckpointReadQuery::new(consistency),
334 )?)
335 .await
336 }
337
338 pub async fn query_routes(
339 &self,
340 query: &QueryRouteListQuery,
341 ) -> ClientResult<QueryRoutePageView, T::Error> {
342 self.get(with_query(http::QUERY_ROUTES_PATH, query)?).await
343 }
344
345 pub async fn destination_table(&self, id: DestinationId) -> ClientResult<TableView, T::Error> {
346 self.get(http::destination_table_path(id)).await
347 }
348
349 pub async fn destination_table_schema(
350 &self,
351 id: DestinationId,
352 ) -> ClientResult<TableSchemaView, T::Error> {
353 self.get(http::destination_table_schema_path(id)).await
354 }
355
356 pub async fn destination_current_snapshot(
357 &self,
358 id: DestinationId,
359 ) -> ClientResult<TableSnapshotView, T::Error> {
360 self.get(http::destination_current_snapshot_path(id)).await
361 }
362
363 pub async fn destination_snapshots(
364 &self,
365 id: DestinationId,
366 query: &SnapshotListQuery,
367 ) -> ClientResult<SnapshotPageView, T::Error> {
368 self.get(with_query(&http::destination_snapshots_path(id), query)?)
369 .await
370 }
371
372 pub async fn destination_snapshot(
373 &self,
374 id: DestinationId,
375 snapshot_id: i64,
376 ) -> ClientResult<TableSnapshotView, T::Error> {
377 self.get(http::destination_snapshot_path(id, snapshot_id))
378 .await
379 }
380
381 pub async fn destination_files(
382 &self,
383 id: DestinationId,
384 query: &TableFileListQuery,
385 ) -> ClientResult<TableFilePageView, T::Error> {
386 self.get(with_query(&http::destination_files_path(id), query)?)
387 .await
388 }
389
390 pub async fn destination_metrics(
391 &self,
392 id: DestinationId,
393 ) -> ClientResult<TableMetricsView, T::Error> {
394 self.get(http::destination_metrics_path(id)).await
395 }
396
397 pub async fn list_projections(
400 &self,
401 filter: &ProjectionListQuery,
402 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
403 self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
404 }
405
406 pub async fn list_schemas(
408 &self,
409 filter: &SchemaListQuery,
410 ) -> ClientResult<Vec<SchemaInfo>, T::Error> {
411 self.get(with_query(http::SCHEMAS_PATH, filter)?).await
412 }
413
414 pub async fn register_schema(
416 &self,
417 source: SchemaSource,
418 name: Option<String>,
419 version: Option<u32>,
420 ) -> ClientResult<u32, T::Error> {
421 let body = http::RegisterSchemaBody {
422 source,
423 name,
424 version,
425 };
426 self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
427 .await
428 }
429
430 pub async fn kv_get(
436 &self,
437 namespace: &str,
438 key: &[u8],
439 ) -> ClientResult<Option<KvValue>, T::Error> {
440 let path = http::kv_entry_path(namespace, &base64url_encode(key));
441 let response = self.dispatch(Method::Get, path, None).await?;
442 if response.status == 404 {
443 return Ok(None);
444 }
445 if !(200..300).contains(&response.status) {
446 return Err(api_error(&response));
447 }
448 let expires_at_micros = response
449 .header(http::KV_EXPIRES_AT_MICROS_HEADER)
450 .and_then(|value| value.parse::<u64>().ok());
451 Ok(Some(KvValue {
452 value: response.body,
453 expires_at_micros,
454 }))
455 }
456
457 pub async fn kv_set(
460 &self,
461 namespace: &str,
462 key: &[u8],
463 value: &[u8],
464 expires_at_micros: Option<u64>,
465 ) -> ClientResult<(), T::Error> {
466 let path = with_query(
467 &http::kv_entry_path(namespace, &base64url_encode(key)),
468 &KvPutQuery { expires_at_micros },
469 )?;
470 self.expect_ok(Method::Put, path, Some(value.to_vec()))
473 .await
474 }
475
476 pub async fn kv_cas(
484 &self,
485 namespace: &str,
486 key: &[u8],
487 value: &[u8],
488 expect: CasExpect,
489 expires_at_micros: Option<u64>,
490 ) -> ClientResult<u64, T::Error> {
491 let (expect_version, expect_absent) = match expect {
492 CasExpect::Match(version) => (Some(version), None),
493 CasExpect::Absent => (None, Some(true)),
494 };
495 let path = with_query(
496 &http::kv_cas_path(namespace, &base64url_encode(key)),
497 &KvCasQuery {
498 expect_version,
499 expect_absent,
500 expires_at_micros,
501 },
502 )?;
503 let response = self
505 .dispatch(Method::Put, path, Some(value.to_vec()))
506 .await?;
507 let view: CasCommittedView = decode_ok(&response)?;
508 Ok(view.version)
509 }
510
511 pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
513 let path = http::kv_entry_path(namespace, &base64url_encode(key));
514 self.send_empty(Method::Delete, path).await
515 }
516
517 pub async fn kv_scan(
519 &self,
520 namespace: &str,
521 filter: &KvScanQuery,
522 ) -> ClientResult<KvPageView, T::Error> {
523 self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
524 .await
525 }
526
527 pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
529 self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
530 .await
531 }
532
533 pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
535 self.get(http::FORKS_PATH.to_owned()).await
536 }
537
538 pub async fn clients(
542 &self,
543 query: &ClientsQuery,
544 ) -> ClientResult<ClientMetadataListView, T::Error> {
545 self.get(with_query(http::CLIENTS_PATH, query)?).await
546 }
547
548 pub async fn submit_run(&self, body: &AgentSubmit) -> ClientResult<AgentRunInfo, T::Error> {
551 self.send_json(Method::Post, http::RUNS_PATH.to_owned(), body)
552 .await
553 }
554
555 pub async fn run_status(&self, id: &str) -> ClientResult<Option<AgentRunInfo>, T::Error> {
557 self.get_optional(http::run_path(id)).await
558 }
559
560 pub async fn list_runs(&self, query: &RunsQuery) -> ClientResult<RunPageView, T::Error> {
563 self.get(with_query(http::RUNS_PATH, query)?).await
564 }
565
566 pub async fn cancel_run(&self, id: &str) -> ClientResult<AgentRunInfo, T::Error> {
569 self.send_empty(Method::Post, http::run_cancel_path(id))
570 .await
571 }
572
573 pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
576 self.get_optional(http::projection_path(id)).await
577 }
578
579 pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
582 self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
583 .await
584 }
585
586 pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
588 self.expect_ok(Method::Delete, http::projection_path(id), None)
589 .await
590 }
591
592 pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
594 self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
595 .await
596 }
597
598 pub async fn remove_binding(
601 &self,
602 source: &SourceSelector,
603 projection_ref: Option<String>,
604 ) -> ClientResult<(), T::Error> {
605 let body = RemoveBindingBody {
606 stream: source.stream.clone(),
607 topic: source.topic.clone(),
608 projection_ref,
609 };
610 self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
611 .await
612 }
613
614 pub async fn get_schema(&self, id: u32) -> ClientResult<Option<SchemaInfo>, T::Error> {
616 self.get_optional(http::schema_path(id)).await
617 }
618
619 pub async fn drop_schema(&self, id: u32) -> ClientResult<(), T::Error> {
621 self.expect_ok(Method::Delete, http::schema_path(id), None)
622 .await
623 }
624
625 pub async fn decode_record(
628 &self,
629 id: u32,
630 payload: &[u8],
631 ) -> ClientResult<Option<serde_json::Value>, T::Error> {
632 let body = DecodeRecordBody {
633 payload: base64url_encode(payload),
634 };
635 self.send_json(Method::Post, http::schema_decode_path(id), &body)
636 .await
637 }
638
639 pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
641 self.get(http::KV_PATH.to_owned()).await
642 }
643
644 pub async fn kv_delete_many(
647 &self,
648 namespace: &str,
649 filter: &KvScanQuery,
650 ) -> ClientResult<usize, T::Error> {
651 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
652 let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
653 Ok(view.deleted)
654 }
655
656 pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
659 let view: PromotedView = self
660 .send_empty(Method::Post, http::fork_promote_path(id))
661 .await?;
662 Ok(view.rows)
663 }
664
665 pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
667 self.expect_ok(Method::Delete, http::fork_path(id), None)
668 .await
669 }
670
671 pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
673 self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
674 .await
675 }
676
677 pub async fn graph_query(
681 &self,
682 name: &str,
683 query: &GraphQuery,
684 ) -> ClientResult<GraphResultView, T::Error> {
685 self.send_json(Method::Post, http::graph_query_path(name), query)
686 .await
687 }
688
689 pub async fn graph_neighbors(
694 &self,
695 name: &str,
696 node: &str,
697 query: &GraphNeighborsQuery,
698 ) -> ClientResult<GraphResultView, T::Error> {
699 self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
700 .await
701 }
702
703 pub async fn list_graphs(
707 &self,
708 filter: &ProjectionListQuery,
709 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
710 self.get(with_query(http::GRAPHS_PATH, filter)?).await
711 }
712
713 pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
717 self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
718 .await
719 }
720
721 pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
724 self.get_optional(http::graph_path(id)).await
725 }
726
727 pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
730 self.expect_ok(Method::Delete, http::graph_path(id), None)
731 .await
732 }
733
734 pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
738 self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
739 }
740
741 pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
743 self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
744 }
745
746 pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
748 self.get_optional(http::authz_role_path(name)).await
749 }
750
751 pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
754 self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
755 .await
756 }
757
758 pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
760 self.expect_ok(Method::Delete, http::authz_role_path(name), None)
761 .await
762 }
763
764 pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
766 self.get(http::authz_user_roles_path(user_id)).await
767 }
768
769 pub async fn bind_user_roles(
771 &self,
772 user_id: u32,
773 roles: &[String],
774 ) -> ClientResult<(), T::Error> {
775 self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
776 .await
777 }
778
779 async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
780 let response = self.dispatch(Method::Get, path, None).await?;
781 decode_ok(&response)
782 }
783
784 async fn get_optional<R: DeserializeOwned>(
786 &self,
787 path: String,
788 ) -> ClientResult<Option<R>, T::Error> {
789 let response = self.dispatch(Method::Get, path, None).await?;
790 if response.status == 404 {
791 return Ok(None);
792 }
793 decode_ok(&response).map(Some)
794 }
795
796 async fn send_json<B: Serialize, R: DeserializeOwned>(
797 &self,
798 method: Method,
799 path: String,
800 body: &B,
801 ) -> ClientResult<R, T::Error> {
802 let payload = serde_json::to_vec(body)
803 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
804 let response = self.dispatch(method, path, Some(payload)).await?;
805 decode_ok(&response)
806 }
807
808 async fn send_empty<R: DeserializeOwned>(
809 &self,
810 method: Method,
811 path: String,
812 ) -> ClientResult<R, T::Error> {
813 let response = self.dispatch(method, path, None).await?;
814 decode_ok(&response)
815 }
816
817 async fn send_json_ok<B: Serialize>(
820 &self,
821 method: Method,
822 path: String,
823 body: &B,
824 ) -> ClientResult<(), T::Error> {
825 let payload = serde_json::to_vec(body)
826 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
827 let response = self.dispatch(method, path, Some(payload)).await?;
828 check_status(&response)
829 }
830
831 async fn expect_ok(
832 &self,
833 method: Method,
834 path: String,
835 body: Option<Vec<u8>>,
836 ) -> ClientResult<(), T::Error> {
837 let response = self.dispatch(method, path, body).await?;
838 check_status(&response)
839 }
840
841 async fn dispatch(
842 &self,
843 method: Method,
844 path: String,
845 body: Option<Vec<u8>>,
846 ) -> ClientResult<HttpResponse, T::Error> {
847 self.transport
848 .send(HttpRequest { method, path, body })
849 .await
850 .map_err(ClientError::Transport)
851 }
852}
853
854fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
857 let query = serde_urlencoded::to_string(params)
858 .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
859 if query.is_empty() {
860 Ok(path.to_owned())
861 } else {
862 Ok(format!("{path}?{query}"))
863 }
864}
865
866fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
870 if (200..300).contains(&response.status) {
871 serde_json::from_slice(&response.body)
872 .map_err(|error| ClientError::Decode(format!("response body: {error}")))
873 } else {
874 Err(api_error(response))
875 }
876}
877
878fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
880 if (200..300).contains(&response.status) {
881 Ok(())
882 } else {
883 Err(api_error(response))
884 }
885}
886
887fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
888 let body = serde_json::from_slice::<ErrorBody>(&response.body).unwrap_or_else(|_| {
889 ErrorBody::new(
890 code_for_status(response.status),
891 String::from_utf8_lossy(&response.body).into_owned(),
892 )
893 });
894 ClientError::Api(body)
895}
896
897fn code_for_status(status: u16) -> ResultCode {
901 match status {
902 404 => ResultCode::NotFound,
903 400 => ResultCode::InvalidArgument,
904 401 => ResultCode::Unauthenticated,
905 403 => ResultCode::Forbidden,
906 409 => ResultCode::Conflict,
907 413 => ResultCode::TooLarge,
908 501 => ResultCode::Unsupported,
909 503 => ResultCode::Stale,
910 _ => ResultCode::Backend,
911 }
912}
913
914const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
918
919pub fn base64url_encode(input: &[u8]) -> String {
921 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
922 for chunk in input.chunks(3) {
923 let b0 = chunk[0] as usize;
924 out.push(B64URL[b0 >> 2] as char);
925 match chunk.len() {
926 1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
927 2 => {
928 let b1 = chunk[1] as usize;
929 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
930 out.push(B64URL[(b1 & 0b1111) << 2] as char);
931 }
932 _ => {
933 let b1 = chunk[1] as usize;
934 let b2 = chunk[2] as usize;
935 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
936 out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
937 out.push(B64URL[b2 & 0b111111] as char);
938 }
939 }
940 }
941 out
942}
943
944pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
947 fn val(byte: u8) -> Option<u8> {
948 match byte {
949 b'A'..=b'Z' => Some(byte - b'A'),
950 b'a'..=b'z' => Some(byte - b'a' + 26),
951 b'0'..=b'9' => Some(byte - b'0' + 52),
952 b'-' => Some(62),
953 b'_' => Some(63),
954 _ => None,
955 }
956 }
957 let bytes = input.as_bytes();
958 if bytes.len() % 4 == 1 {
959 return None;
960 }
961 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
962 for chunk in bytes.chunks(4) {
963 let mut acc = 0u32;
964 for &byte in chunk {
965 acc = (acc << 6) | u32::from(val(byte)?);
966 }
967 acc <<= 6 * (4 - chunk.len());
969 match chunk.len() {
970 2 => out.push((acc >> 16) as u8),
971 3 => {
972 out.push((acc >> 16) as u8);
973 out.push((acc >> 8) as u8);
974 }
975 _ => {
976 out.push((acc >> 16) as u8);
977 out.push((acc >> 8) as u8);
978 out.push(acc as u8);
979 }
980 }
981 }
982 Some(out)
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 #[test]
990 fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
991 for case in [
992 &b""[..],
993 &b"f"[..],
994 &b"fo"[..],
995 &b"foo"[..],
996 &b"foob"[..],
997 &b"fooba"[..],
998 &b"foobar"[..],
999 &[0x00, 0xff, 0x10, 0x80][..],
1000 ] {
1001 let encoded = base64url_encode(case);
1002 assert!(
1003 !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1004 "url-safe unpadded: {encoded}"
1005 );
1006 assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1007 }
1008 }
1009
1010 #[test]
1011 fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1012 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1013 assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1014 }
1015
1016 #[test]
1017 fn given_checkpoint_reads_when_paths_are_built_then_should_require_the_selected_consistency() {
1018 let single = with_query::<(), _>(
1019 &http::destination_status_path(DestinationId::from_u128(1)),
1020 &CheckpointReadQuery::new(CheckpointReadConsistency::Linearizable),
1021 )
1022 .expect("single checkpoint read query encodes");
1023 assert!(single.ends_with("?consistency=linearizable"));
1024
1025 let list = with_query::<(), _>(
1026 http::DESTINATIONS_PATH,
1027 &DestinationListQuery {
1028 source_stream: None,
1029 source_topic: None,
1030 name_contains: None,
1031 after: None,
1032 limit: None,
1033 consistency: CheckpointReadConsistency::PotentiallyStale,
1034 },
1035 )
1036 .expect("checkpoint list query encodes");
1037 assert!(list.ends_with("?consistency=potentially_stale"));
1038 }
1039
1040 #[test]
1041 fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1042 assert!(
1043 base64url_decode("====").is_none(),
1044 "padding is not alphabet"
1045 );
1046 assert!(
1047 base64url_decode("A").is_none(),
1048 "a lone char carries no byte"
1049 );
1050 assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1051 }
1052
1053 struct CannedTransport {
1056 response: HttpResponse,
1057 }
1058
1059 impl Transport for CannedTransport {
1060 type Error = std::convert::Infallible;
1061 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1062 Ok(self.response.clone())
1063 }
1064 }
1065
1066 fn block_on<F: core::future::Future>(future: F) -> F::Output {
1067 use core::task::{Context, Poll, Waker};
1071 let mut context = Context::from_waker(Waker::noop());
1072 let mut future = core::pin::pin!(future);
1073 loop {
1074 if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1075 return output;
1076 }
1077 }
1078 }
1079
1080 #[test]
1081 fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1082 let body = serde_json::to_vec(&Capabilities::new(
1083 true,
1084 crate::hello::OpVersions::new(1, 1, 1, 1),
1085 ))
1086 .unwrap();
1087 let client = HttpClient::new(CannedTransport {
1088 response: HttpResponse::new(200, body),
1089 });
1090 let caps = block_on(client.capabilities()).expect("decodes");
1091 assert!(caps.managed && !caps.kv.cas);
1092 }
1093
1094 #[test]
1095 fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1096 let body =
1097 serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1098 let client = HttpClient::new(CannedTransport {
1099 response: HttpResponse::new(404, body),
1100 });
1101 let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1102 assert_eq!(error.code(), Some(ResultCode::NotFound));
1103 }
1104
1105 #[test]
1106 fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
1107 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1108 let client = HttpClient::new(CannedTransport {
1109 response: HttpResponse::new(404, body),
1110 });
1111 let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1112 assert!(entry.is_none());
1113 }
1114
1115 #[test]
1116 fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
1117 let response = HttpResponse::new(200, b"world".to_vec())
1118 .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
1119 let client = HttpClient::new(CannedTransport { response });
1120 let entry = block_on(client.kv_get("sessions", b"user:1"))
1121 .expect("decodes")
1122 .expect("present");
1123 assert_eq!(entry.value, b"world");
1124 assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1125 }
1126
1127 #[test]
1128 fn given_a_missing_projection_when_fetched_then_should_be_none() {
1129 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1130 let client = HttpClient::new(CannedTransport {
1131 response: HttpResponse::new(404, body),
1132 });
1133 let info = block_on(client.get_projection("order.v1")).expect("404 maps to None");
1134 assert!(info.is_none());
1135 }
1136
1137 #[test]
1138 fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1139 let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1140 let client = HttpClient::new(CannedTransport {
1141 response: HttpResponse::new(200, body),
1142 });
1143 let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1144 .expect("decodes the count");
1145 assert_eq!(removed, 7);
1146 }
1147
1148 #[test]
1149 fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1150 let client = HttpClient::new(CannedTransport {
1151 response: HttpResponse::new(204, Vec::new()),
1152 });
1153 block_on(client.drop_projection("order.v1")).expect("a 204 is a success");
1154 }
1155
1156 #[test]
1157 fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1158 let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1159 let client = HttpClient::new(CannedTransport {
1160 response: HttpResponse::new(200, body),
1161 });
1162 let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1163 .expect("a commit returns the new version");
1164 assert_eq!(version, 4);
1165 }
1166
1167 #[test]
1168 fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1169 let reply = WhoamiReply {
1170 v: 1,
1171 roles: vec!["admin".to_owned()],
1172 grants: vec![crate::authz::Grant {
1173 effect: crate::authz::Effect::Allow,
1174 feature: crate::authz::Feature::Authz,
1175 action: crate::authz::Action::Admin,
1176 resource: crate::authz::ResourcePattern::all(),
1177 }],
1178 };
1179 let body = serde_json::to_vec(&reply).unwrap();
1180 let client = HttpClient::new(CannedTransport {
1181 response: HttpResponse::new(200, body),
1182 });
1183 let whoami = block_on(client.authz_whoami()).expect("decodes");
1184 assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1185 assert_eq!(whoami.grants.len(), 1);
1186 }
1187
1188 #[test]
1189 fn given_a_missing_role_when_fetched_then_should_be_none() {
1190 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1191 let client = HttpClient::new(CannedTransport {
1192 response: HttpResponse::new(404, body),
1193 });
1194 let role = block_on(client.get_role("ghost")).expect("404 maps to None");
1195 assert!(role.is_none());
1196 }
1197
1198 #[test]
1199 fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1200 let body = serde_json::to_vec(
1201 &ErrorBody::new(ResultCode::Conflict, "version conflict")
1202 .with_detail(serde_json::json!({ "current": 3 })),
1203 )
1204 .unwrap();
1205 let client = HttpClient::new(CannedTransport {
1206 response: HttpResponse::new(409, body),
1207 });
1208 let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1209 .expect_err("a precondition miss is an error");
1210 assert_eq!(error.code(), Some(ResultCode::Conflict));
1211 }
1212}