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