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::fork::ForkInfo;
19use crate::graph::GraphQuery;
20use crate::http::{
21 self, Capabilities, CasCommittedView, ClientMetadataListView, ClientsQuery, DecodeRecordBody,
22 DeletedManyView, ErrorBody, ForkCreateBody, ForkPutBody, GraphNeighborsQuery, GraphResultView,
23 KvCasQuery, KvPageView, KvPutQuery, KvScanQuery, ProjectionListQuery, PromotedView,
24 RemoveBindingBody, RunPageView, RunsQuery, SchemaListQuery,
25};
26use crate::kv::{CasExpect, KvNamespaceInfo};
27use crate::query::{Query, QueryResult};
28use crate::result::ResultCode;
29use serde::Serialize;
30use serde::de::DeserializeOwned;
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
34#[strum(serialize_all = "UPPERCASE")]
35pub enum Method {
36 Get,
37 Post,
38 Put,
39 Delete,
40}
41
42impl Method {
43 pub fn as_str(self) -> &'static str {
45 self.into()
46 }
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct HttpRequest {
56 pub method: Method,
57 pub path: String,
58 pub body: Option<Vec<u8>>,
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, Default)]
66pub struct HttpResponse {
67 pub status: u16,
68 pub headers: Vec<(String, String)>,
69 pub body: Vec<u8>,
70}
71
72impl HttpResponse {
73 pub fn new(status: u16, body: Vec<u8>) -> Self {
75 Self {
76 status,
77 headers: Vec::new(),
78 body,
79 }
80 }
81
82 #[must_use]
84 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
85 self.headers.push((name.into(), value.into()));
86 self
87 }
88
89 pub fn header(&self, name: &str) -> Option<&str> {
92 self.headers
93 .iter()
94 .find(|(key, _)| key.eq_ignore_ascii_case(name))
95 .map(|(_, value)| value.as_str())
96 }
97}
98
99#[derive(Clone, Debug, PartialEq, Eq)]
105pub struct KvValue {
106 pub value: Vec<u8>,
107 pub expires_at_micros: Option<u64>,
108}
109
110#[allow(async_fn_in_trait)]
126pub trait Transport {
127 type Error: core::fmt::Display;
130
131 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
135}
136
137#[derive(Debug)]
139pub enum ClientError<E> {
140 Transport(E),
142 Decode(String),
144 Api(ErrorBody),
146}
147
148impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
149 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
150 match self {
151 ClientError::Transport(error) => write!(f, "transport error: {error}"),
152 ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
153 ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
154 }
155 }
156}
157
158impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
159
160impl<E> ClientError<E> {
161 pub fn code(&self) -> Option<ResultCode> {
165 match self {
166 ClientError::Api(body) => Some(body.code),
167 _ => None,
168 }
169 }
170}
171
172type ClientResult<T, E> = Result<T, ClientError<E>>;
173
174#[derive(Clone, Debug)]
182pub struct HttpClient<T> {
183 transport: T,
184}
185
186impl<T: Transport> HttpClient<T> {
187 pub fn new(transport: T) -> Self {
189 Self { transport }
190 }
191
192 pub fn transport(&self) -> &T {
194 &self.transport
195 }
196
197 pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
199 self.get(http::CAPABILITIES_PATH.to_owned()).await
200 }
201
202 pub async fn query(&self, query: &Query) -> ClientResult<QueryResult, T::Error> {
204 self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
205 .await
206 }
207
208 pub async fn list_projections(
211 &self,
212 filter: &ProjectionListQuery,
213 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
214 self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
215 }
216
217 pub async fn list_schemas(
219 &self,
220 filter: &SchemaListQuery,
221 ) -> ClientResult<Vec<SchemaInfo>, T::Error> {
222 self.get(with_query(http::SCHEMAS_PATH, filter)?).await
223 }
224
225 pub async fn register_schema(
227 &self,
228 source: SchemaSource,
229 name: Option<String>,
230 version: Option<u32>,
231 ) -> ClientResult<u32, T::Error> {
232 let body = http::RegisterSchemaBody {
233 source,
234 name,
235 version,
236 };
237 self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
238 .await
239 }
240
241 pub async fn kv_get(
247 &self,
248 namespace: &str,
249 key: &[u8],
250 ) -> ClientResult<Option<KvValue>, T::Error> {
251 let path = http::kv_entry_path(namespace, &base64url_encode(key));
252 let response = self.dispatch(Method::Get, path, None).await?;
253 if response.status == 404 {
254 return Ok(None);
255 }
256 if !(200..300).contains(&response.status) {
257 return Err(api_error(&response));
258 }
259 let expires_at_micros = response
260 .header(http::KV_EXPIRES_AT_MICROS_HEADER)
261 .and_then(|value| value.parse::<u64>().ok());
262 Ok(Some(KvValue {
263 value: response.body,
264 expires_at_micros,
265 }))
266 }
267
268 pub async fn kv_set(
271 &self,
272 namespace: &str,
273 key: &[u8],
274 value: &[u8],
275 expires_at_micros: Option<u64>,
276 ) -> ClientResult<(), T::Error> {
277 let path = with_query(
278 &http::kv_entry_path(namespace, &base64url_encode(key)),
279 &KvPutQuery { expires_at_micros },
280 )?;
281 self.expect_ok(Method::Put, path, Some(value.to_vec()))
284 .await
285 }
286
287 pub async fn kv_cas(
295 &self,
296 namespace: &str,
297 key: &[u8],
298 value: &[u8],
299 expect: CasExpect,
300 expires_at_micros: Option<u64>,
301 ) -> ClientResult<u64, T::Error> {
302 let (expect_version, expect_absent) = match expect {
303 CasExpect::Match(version) => (Some(version), None),
304 CasExpect::Absent => (None, Some(true)),
305 };
306 let path = with_query(
307 &http::kv_cas_path(namespace, &base64url_encode(key)),
308 &KvCasQuery {
309 expect_version,
310 expect_absent,
311 expires_at_micros,
312 },
313 )?;
314 let response = self
316 .dispatch(Method::Put, path, Some(value.to_vec()))
317 .await?;
318 let view: CasCommittedView = decode_ok(&response)?;
319 Ok(view.version)
320 }
321
322 pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
324 let path = http::kv_entry_path(namespace, &base64url_encode(key));
325 self.send_empty(Method::Delete, path).await
326 }
327
328 pub async fn kv_scan(
330 &self,
331 namespace: &str,
332 filter: &KvScanQuery,
333 ) -> ClientResult<KvPageView, T::Error> {
334 self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
335 .await
336 }
337
338 pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
340 self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
341 .await
342 }
343
344 pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
346 self.get(http::FORKS_PATH.to_owned()).await
347 }
348
349 pub async fn clients(
353 &self,
354 query: &ClientsQuery,
355 ) -> ClientResult<ClientMetadataListView, T::Error> {
356 self.get(with_query(http::CLIENTS_PATH, query)?).await
357 }
358
359 pub async fn submit_run(&self, body: &AgentSubmit) -> ClientResult<AgentRunInfo, T::Error> {
362 self.send_json(Method::Post, http::RUNS_PATH.to_owned(), body)
363 .await
364 }
365
366 pub async fn run_status(&self, id: &str) -> ClientResult<Option<AgentRunInfo>, T::Error> {
368 self.get_optional(http::run_path(id)).await
369 }
370
371 pub async fn list_runs(&self, query: &RunsQuery) -> ClientResult<RunPageView, T::Error> {
374 self.get(with_query(http::RUNS_PATH, query)?).await
375 }
376
377 pub async fn cancel_run(&self, id: &str) -> ClientResult<AgentRunInfo, T::Error> {
380 self.send_empty(Method::Post, http::run_cancel_path(id))
381 .await
382 }
383
384 pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
387 self.get_optional(http::projection_path(id)).await
388 }
389
390 pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
393 self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
394 .await
395 }
396
397 pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
399 self.expect_ok(Method::Delete, http::projection_path(id), None)
400 .await
401 }
402
403 pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
405 self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
406 .await
407 }
408
409 pub async fn remove_binding(
412 &self,
413 source: &SourceSelector,
414 projection_ref: Option<String>,
415 ) -> ClientResult<(), T::Error> {
416 let body = RemoveBindingBody {
417 stream: source.stream.clone(),
418 topic: source.topic.clone(),
419 projection_ref,
420 };
421 self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
422 .await
423 }
424
425 pub async fn get_schema(&self, id: u32) -> ClientResult<Option<SchemaInfo>, T::Error> {
427 self.get_optional(http::schema_path(id)).await
428 }
429
430 pub async fn drop_schema(&self, id: u32) -> ClientResult<(), T::Error> {
432 self.expect_ok(Method::Delete, http::schema_path(id), None)
433 .await
434 }
435
436 pub async fn decode_record(
439 &self,
440 id: u32,
441 payload: &[u8],
442 ) -> ClientResult<Option<serde_json::Value>, T::Error> {
443 let body = DecodeRecordBody {
444 payload: base64url_encode(payload),
445 };
446 self.send_json(Method::Post, http::schema_decode_path(id), &body)
447 .await
448 }
449
450 pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
452 self.get(http::KV_PATH.to_owned()).await
453 }
454
455 pub async fn kv_delete_many(
458 &self,
459 namespace: &str,
460 filter: &KvScanQuery,
461 ) -> ClientResult<usize, T::Error> {
462 let path = with_query(&http::kv_namespace_path(namespace), filter)?;
463 let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
464 Ok(view.deleted)
465 }
466
467 pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
470 let view: PromotedView = self
471 .send_empty(Method::Post, http::fork_promote_path(id))
472 .await?;
473 Ok(view.rows)
474 }
475
476 pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
478 self.expect_ok(Method::Delete, http::fork_path(id), None)
479 .await
480 }
481
482 pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
484 self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
485 .await
486 }
487
488 pub async fn graph_query(
492 &self,
493 name: &str,
494 query: &GraphQuery,
495 ) -> ClientResult<GraphResultView, T::Error> {
496 self.send_json(Method::Post, http::graph_query_path(name), query)
497 .await
498 }
499
500 pub async fn graph_neighbors(
505 &self,
506 name: &str,
507 node: &str,
508 query: &GraphNeighborsQuery,
509 ) -> ClientResult<GraphResultView, T::Error> {
510 self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
511 .await
512 }
513
514 pub async fn list_graphs(
518 &self,
519 filter: &ProjectionListQuery,
520 ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
521 self.get(with_query(http::GRAPHS_PATH, filter)?).await
522 }
523
524 pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
528 self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
529 .await
530 }
531
532 pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
535 self.get_optional(http::graph_path(id)).await
536 }
537
538 pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
541 self.expect_ok(Method::Delete, http::graph_path(id), None)
542 .await
543 }
544
545 pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
549 self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
550 }
551
552 pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
554 self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
555 }
556
557 pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
559 self.get_optional(http::authz_role_path(name)).await
560 }
561
562 pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
565 self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
566 .await
567 }
568
569 pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
571 self.expect_ok(Method::Delete, http::authz_role_path(name), None)
572 .await
573 }
574
575 pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
577 self.get(http::authz_user_roles_path(user_id)).await
578 }
579
580 pub async fn bind_user_roles(
582 &self,
583 user_id: u32,
584 roles: &[String],
585 ) -> ClientResult<(), T::Error> {
586 self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
587 .await
588 }
589
590 async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
591 let response = self.dispatch(Method::Get, path, None).await?;
592 decode_ok(&response)
593 }
594
595 async fn get_optional<R: DeserializeOwned>(
597 &self,
598 path: String,
599 ) -> ClientResult<Option<R>, T::Error> {
600 let response = self.dispatch(Method::Get, path, None).await?;
601 if response.status == 404 {
602 return Ok(None);
603 }
604 decode_ok(&response).map(Some)
605 }
606
607 async fn send_json<B: Serialize, R: DeserializeOwned>(
608 &self,
609 method: Method,
610 path: String,
611 body: &B,
612 ) -> ClientResult<R, T::Error> {
613 let payload = serde_json::to_vec(body)
614 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
615 let response = self.dispatch(method, path, Some(payload)).await?;
616 decode_ok(&response)
617 }
618
619 async fn send_empty<R: DeserializeOwned>(
620 &self,
621 method: Method,
622 path: String,
623 ) -> ClientResult<R, T::Error> {
624 let response = self.dispatch(method, path, None).await?;
625 decode_ok(&response)
626 }
627
628 async fn send_json_ok<B: Serialize>(
631 &self,
632 method: Method,
633 path: String,
634 body: &B,
635 ) -> ClientResult<(), T::Error> {
636 let payload = serde_json::to_vec(body)
637 .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
638 let response = self.dispatch(method, path, Some(payload)).await?;
639 check_status(&response)
640 }
641
642 async fn expect_ok(
643 &self,
644 method: Method,
645 path: String,
646 body: Option<Vec<u8>>,
647 ) -> ClientResult<(), T::Error> {
648 let response = self.dispatch(method, path, body).await?;
649 check_status(&response)
650 }
651
652 async fn dispatch(
653 &self,
654 method: Method,
655 path: String,
656 body: Option<Vec<u8>>,
657 ) -> ClientResult<HttpResponse, T::Error> {
658 self.transport
659 .send(HttpRequest { method, path, body })
660 .await
661 .map_err(ClientError::Transport)
662 }
663}
664
665fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
668 let query = serde_urlencoded::to_string(params)
669 .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
670 if query.is_empty() {
671 Ok(path.to_owned())
672 } else {
673 Ok(format!("{path}?{query}"))
674 }
675}
676
677fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
681 if (200..300).contains(&response.status) {
682 serde_json::from_slice(&response.body)
683 .map_err(|error| ClientError::Decode(format!("response body: {error}")))
684 } else {
685 Err(api_error(response))
686 }
687}
688
689fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
691 if (200..300).contains(&response.status) {
692 Ok(())
693 } else {
694 Err(api_error(response))
695 }
696}
697
698fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
699 let body = serde_json::from_slice::<ErrorBody>(&response.body).unwrap_or_else(|_| {
700 ErrorBody::new(
701 code_for_status(response.status),
702 String::from_utf8_lossy(&response.body).into_owned(),
703 )
704 });
705 ClientError::Api(body)
706}
707
708fn code_for_status(status: u16) -> ResultCode {
712 match status {
713 404 => ResultCode::NotFound,
714 400 => ResultCode::InvalidArgument,
715 401 => ResultCode::Unauthenticated,
716 403 => ResultCode::Forbidden,
717 409 => ResultCode::Conflict,
718 413 => ResultCode::TooLarge,
719 501 => ResultCode::Unsupported,
720 503 => ResultCode::Stale,
721 _ => ResultCode::Backend,
722 }
723}
724
725const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
729
730pub fn base64url_encode(input: &[u8]) -> String {
732 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
733 for chunk in input.chunks(3) {
734 let b0 = chunk[0] as usize;
735 out.push(B64URL[b0 >> 2] as char);
736 match chunk.len() {
737 1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
738 2 => {
739 let b1 = chunk[1] as usize;
740 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
741 out.push(B64URL[(b1 & 0b1111) << 2] as char);
742 }
743 _ => {
744 let b1 = chunk[1] as usize;
745 let b2 = chunk[2] as usize;
746 out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
747 out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
748 out.push(B64URL[b2 & 0b111111] as char);
749 }
750 }
751 }
752 out
753}
754
755pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
758 fn val(byte: u8) -> Option<u8> {
759 match byte {
760 b'A'..=b'Z' => Some(byte - b'A'),
761 b'a'..=b'z' => Some(byte - b'a' + 26),
762 b'0'..=b'9' => Some(byte - b'0' + 52),
763 b'-' => Some(62),
764 b'_' => Some(63),
765 _ => None,
766 }
767 }
768 let bytes = input.as_bytes();
769 if bytes.len() % 4 == 1 {
770 return None;
771 }
772 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
773 for chunk in bytes.chunks(4) {
774 let mut acc = 0u32;
775 for &byte in chunk {
776 acc = (acc << 6) | u32::from(val(byte)?);
777 }
778 acc <<= 6 * (4 - chunk.len());
780 match chunk.len() {
781 2 => out.push((acc >> 16) as u8),
782 3 => {
783 out.push((acc >> 16) as u8);
784 out.push((acc >> 8) as u8);
785 }
786 _ => {
787 out.push((acc >> 16) as u8);
788 out.push((acc >> 8) as u8);
789 out.push(acc as u8);
790 }
791 }
792 }
793 Some(out)
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
801 fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
802 for case in [
803 &b""[..],
804 &b"f"[..],
805 &b"fo"[..],
806 &b"foo"[..],
807 &b"foob"[..],
808 &b"fooba"[..],
809 &b"foobar"[..],
810 &[0x00, 0xff, 0x10, 0x80][..],
811 ] {
812 let encoded = base64url_encode(case);
813 assert!(
814 !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
815 "url-safe unpadded: {encoded}"
816 );
817 assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
818 }
819 }
820
821 #[test]
822 fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
823 assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
824 assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
825 }
826
827 #[test]
828 fn given_a_bad_base64_string_when_decoded_then_should_reject() {
829 assert!(
830 base64url_decode("====").is_none(),
831 "padding is not alphabet"
832 );
833 assert!(
834 base64url_decode("A").is_none(),
835 "a lone char carries no byte"
836 );
837 assert!(base64url_decode("a b").is_none(), "space is not alphabet");
838 }
839
840 struct CannedTransport {
843 response: HttpResponse,
844 }
845
846 impl Transport for CannedTransport {
847 type Error = std::convert::Infallible;
848 async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
849 Ok(self.response.clone())
850 }
851 }
852
853 fn block_on<F: core::future::Future>(future: F) -> F::Output {
854 use core::task::{Context, Poll, Waker};
858 let mut context = Context::from_waker(Waker::noop());
859 let mut future = core::pin::pin!(future);
860 loop {
861 if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
862 return output;
863 }
864 }
865 }
866
867 #[test]
868 fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
869 let body = serde_json::to_vec(&Capabilities::new(
870 true,
871 crate::hello::OpVersions::new(1, 1, 1, 1),
872 ))
873 .unwrap();
874 let client = HttpClient::new(CannedTransport {
875 response: HttpResponse::new(200, body),
876 });
877 let caps = block_on(client.capabilities()).expect("decodes");
878 assert!(caps.managed && !caps.kv.cas);
879 }
880
881 #[test]
882 fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
883 let body =
884 serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
885 let client = HttpClient::new(CannedTransport {
886 response: HttpResponse::new(404, body),
887 });
888 let error = block_on(client.list_forks()).expect_err("a 404 is an error");
889 assert_eq!(error.code(), Some(ResultCode::NotFound));
890 }
891
892 #[test]
893 fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
894 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
895 let client = HttpClient::new(CannedTransport {
896 response: HttpResponse::new(404, body),
897 });
898 let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
899 assert!(entry.is_none());
900 }
901
902 #[test]
903 fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
904 let response = HttpResponse::new(200, b"world".to_vec())
905 .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
906 let client = HttpClient::new(CannedTransport { response });
907 let entry = block_on(client.kv_get("sessions", b"user:1"))
908 .expect("decodes")
909 .expect("present");
910 assert_eq!(entry.value, b"world");
911 assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
912 }
913
914 #[test]
915 fn given_a_missing_projection_when_fetched_then_should_be_none() {
916 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
917 let client = HttpClient::new(CannedTransport {
918 response: HttpResponse::new(404, body),
919 });
920 let info = block_on(client.get_projection("order.v1")).expect("404 maps to None");
921 assert!(info.is_none());
922 }
923
924 #[test]
925 fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
926 let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
927 let client = HttpClient::new(CannedTransport {
928 response: HttpResponse::new(200, body),
929 });
930 let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
931 .expect("decodes the count");
932 assert_eq!(removed, 7);
933 }
934
935 #[test]
936 fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
937 let client = HttpClient::new(CannedTransport {
938 response: HttpResponse::new(204, Vec::new()),
939 });
940 block_on(client.drop_projection("order.v1")).expect("a 204 is a success");
941 }
942
943 #[test]
944 fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
945 let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
946 let client = HttpClient::new(CannedTransport {
947 response: HttpResponse::new(200, body),
948 });
949 let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
950 .expect("a commit returns the new version");
951 assert_eq!(version, 4);
952 }
953
954 #[test]
955 fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
956 let reply = WhoamiReply {
957 v: 1,
958 roles: vec!["admin".to_owned()],
959 grants: vec![crate::authz::Grant {
960 effect: crate::authz::Effect::Allow,
961 feature: crate::authz::Feature::Authz,
962 action: crate::authz::Action::Admin,
963 resource: crate::authz::ResourcePattern::all(),
964 }],
965 };
966 let body = serde_json::to_vec(&reply).unwrap();
967 let client = HttpClient::new(CannedTransport {
968 response: HttpResponse::new(200, body),
969 });
970 let whoami = block_on(client.authz_whoami()).expect("decodes");
971 assert_eq!(whoami.roles, vec!["admin".to_owned()]);
972 assert_eq!(whoami.grants.len(), 1);
973 }
974
975 #[test]
976 fn given_a_missing_role_when_fetched_then_should_be_none() {
977 let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
978 let client = HttpClient::new(CannedTransport {
979 response: HttpResponse::new(404, body),
980 });
981 let role = block_on(client.get_role("ghost")).expect("404 maps to None");
982 assert!(role.is_none());
983 }
984
985 #[test]
986 fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
987 let body = serde_json::to_vec(
988 &ErrorBody::new(ResultCode::Conflict, "version conflict")
989 .with_detail(serde_json::json!({ "current": 3 })),
990 )
991 .unwrap();
992 let client = HttpClient::new(CannedTransport {
993 response: HttpResponse::new(409, body),
994 });
995 let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
996 .expect_err("a precondition miss is an error");
997 assert_eq!(error.code(), Some(ResultCode::Conflict));
998 }
999}