Skip to main content

laser_wire/
http_client.rs

1// A typed client for the `/agdx/*` HTTP surface (feature `http-client`). Every
2// browser UI and native tool otherwise re-implements the same glue: route
3// strings, base64url of binary KV keys, query-string composition, and unwrapping
4// the bare-`Ok`-or-`ErrorBody` reply contract. This owns all of it once, so a
5// consumer writes `client.kv_get(ns, key).await?` and gets a typed value or a
6// typed [`ClientError`] carrying the [`ResultCode`].
7//
8// The crate stays runtime-free: the caller injects the actual IO by
9// implementing [`Transport`] over `gloo-net` (wasm) or `reqwest` (native). This
10// is the crate's one async surface, and it is runtime-agnostic: an `async fn`
11// is just a `Future`, with no executor dependency, so it still compiles for
12// `wasm32-unknown-unknown`.
13
14use 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/// The HTTP verb a [`Transport`] must perform.
45#[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    /// The uppercase method token (`"GET"`, ...).
56    pub fn as_str(self) -> &'static str {
57        self.into()
58    }
59}
60
61/// A request the [`Transport`] performs. `path` is already the full path plus
62/// query string (e.g. `/agdx/kv/sessions/dXNlcjox?expires_at_micros=10`). The
63/// transport prepends its own base URL. A JSON `body` is present only on
64/// `POST`/`PUT`, and when present the transport sends `Content-Type:
65/// application/json`.
66#[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/// What the [`Transport`] returns: the numeric status, the response headers (for
74/// the routes that carry metadata out of band, like the KV get expiry), and the
75/// raw body. A transport that does not surface headers leaves `headers` empty,
76/// in which case header-carried metadata reads as absent.
77#[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    /// A response with no headers.
86    pub fn new(status: u16, body: Vec<u8>) -> Self {
87        Self {
88            status,
89            headers: Vec::new(),
90            body,
91        }
92    }
93
94    /// Builder helper: attach a header (used by transports and tests).
95    #[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    /// The first value of header `name`, matched case-insensitively (HTTP header
102    /// names are case-insensitive). `None` if absent.
103    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/// The result of a single-key KV read: the raw value bytes plus the optional
112/// absolute expiry (epoch microseconds). A `kv_get` builds it from the raw
113/// response body and the [`KV_EXPIRES_AT_MICROS_HEADER`](http::KV_EXPIRES_AT_MICROS_HEADER)
114/// header. A scan page carries the base64url [`KvEntryView`](http::KvEntryView)
115/// shape instead, since a JSON array cannot hold raw bytes.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct KvValue {
118    pub value: Vec<u8>,
119    pub expires_at_micros: Option<u64>,
120}
121
122/// The IO seam. A wasm consumer implements it over `gloo-net`, a native one over
123/// `reqwest`. The client never constructs URLs beyond the path: the transport
124/// owns the base URL, credentials, and headers.
125///
126/// Authentication and 401 refresh-retry live here, in the transport, not in the
127/// client. A transport attaches the credential to every request, and on a 401 it
128/// may refresh the token and retry once before returning the response. This is
129/// the injection point a real UI needs, and it keeps the client itself
130/// auth-agnostic.
131///
132/// The trait uses an `async fn` in a trait, so the returned future is not bound
133/// `Send`. A browser (single-threaded wasm) needs nothing more. A native caller
134/// that drives the client on a multi-threaded executor and needs a `Send` future
135/// (for example to `tokio::spawn` it) should run the client on a current-thread
136/// runtime or wrap the call in a `Send`-producing adapter.
137#[allow(async_fn_in_trait)]
138pub trait Transport {
139    /// The transport's own failure type (a network error, a timeout). Surfaced
140    /// verbatim through [`ClientError::Transport`].
141    type Error: core::fmt::Display;
142
143    /// Perform one request. A non-2xx status is **not** an error here: the
144    /// client inspects the status and decodes the [`ErrorBody`]. Return `Err`
145    /// only when the request never produced a response.
146    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
147}
148
149/// Why a typed call failed.
150#[derive(Debug)]
151pub enum ClientError<E> {
152    /// The transport never got a response (network down, timeout).
153    Transport(E),
154    /// A response arrived but its body did not decode as the expected type.
155    Decode(String),
156    /// The server returned a non-2xx status with a classified [`ErrorBody`].
157    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    /// The classified [`ResultCode`] for an [`ClientError::Api`], so a caller
174    /// branches on the kind (`NotFound`, `Conflict`, ...) without matching the
175    /// message text. `None` for a transport or decode failure.
176    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/// A typed `/agdx/*` client over an injected [`Transport`].
187///
188/// Binary KV keys are base64url-encoded into the path by the client, so raw user
189/// bytes never enter a path segment. A `namespace`, `fork id`, or `projection
190/// id` argument, by contrast, is placed in the path verbatim, so the caller must
191/// pass a path-safe identifier (no `/`, `?`, or `#`). Fork ids are already
192/// constrained by [`validate_fork_id`](crate::fork::validate_fork_id).
193#[derive(Clone, Debug)]
194pub struct HttpClient<T> {
195    transport: T,
196}
197
198impl<T: Transport> HttpClient<T> {
199    /// Wrap a transport. The transport owns the base URL and auth.
200    pub fn new(transport: T) -> Self {
201        Self { transport }
202    }
203
204    /// The underlying transport, for a caller that needs an escape hatch.
205    pub fn transport(&self) -> &T {
206        &self.transport
207    }
208
209    /// `GET /agdx/capabilities`: feature-detect before showing a view.
210    pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
211        self.get(http::CAPABILITIES_PATH.to_owned()).await
212    }
213
214    /// `POST /agdx/query`: submit a query and return its current execution view.
215    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    /// `GET /agdx/projections`: list projections and their bindings, optionally
404    /// filtered.
405    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    /// `GET /agdx/schemas`: list registered schemas, optionally filtered.
413    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    /// `POST /agdx/schemas`: register a schema, get its allocated id.
421    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    /// `GET /agdx/kv/{namespace}/{key}`: fetch one entry, or `None` on a 404.
437    /// The value rides the raw response body (no base64 inflation, symmetric with
438    /// `kv_set`), and the optional expiry rides the
439    /// [`KV_EXPIRES_AT_MICROS_HEADER`](http::KV_EXPIRES_AT_MICROS_HEADER) response
440    /// header.
441    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    /// `PUT /agdx/kv/{namespace}/{key}`: set a value, with an optional absolute
464    /// expiry (epoch microseconds).
465    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        // The key is base64url in the path, but the value rides the raw request
477        // body, so a large value carries no base64 inflation.
478        self.expect_ok(Method::Put, path, Some(value.to_vec()))
479            .await
480    }
481
482    /// `PUT /agdx/kv/{namespace}/{key}/cas`: a conditional write
483    /// (compare-and-swap). Applies `value` only if `expect` holds, returning the
484    /// new version on commit. A precondition miss surfaces as
485    /// `ClientError::Api` with `ResultCode::Conflict` (the response's
486    /// `ErrorBody.detail` carries the current version). Backend-gated by the
487    /// `kv_cas` capability: a deployment that does not serve it answers
488    /// unsupported.
489    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        // The value rides the raw body like the plain PUT.
510        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    /// `DELETE /agdx/kv/{namespace}/{key}`: delete one entry, returning `true` if it existed.
518    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    /// `GET /agdx/kv/{namespace}`: scan a page of entries.
524    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    /// `POST /agdx/forks`: create a fork.
534    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    /// `GET /agdx/forks`: list the caller's forks.
540    pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
541        self.get(http::FORKS_PATH.to_owned()).await
542    }
543
544    /// `GET /agdx/clients`: one page of live connections with their advertised
545    /// metadata, filtered and paginated per `query`. Follow `next_cursor` as the
546    /// next `after` to page.
547    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    /// `POST /agdx/runs`: submit a task to an agent or workflow, returning the
555    /// minted (or converged) run.
556    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    /// `GET /agdx/runs/{id}`: read one run's status, or `None` on a 404.
562    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    /// `GET /agdx/runs`: one page of runs, filtered and paged per `query`.
567    /// Follow `cursor` as the next request's cursor to page.
568    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    /// `POST /agdx/runs/{id}/cancel`: record the cancel intent, returning the
573    /// run (the engine observes the intent at its next step boundary).
574    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    /// `GET /agdx/projections/{id}`: read one projection and its bindings, or
580    /// `None` on a 404.
581    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    /// `POST /agdx/projections`: register or replace a projection (a control
586    /// command, durable on the control topic).
587    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    /// `DELETE /agdx/projections/{id}`: drop a projection.
593    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    /// `POST /agdx/bindings`: apply (add or update) a binding.
599    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    /// `DELETE /agdx/bindings`: remove a binding for a source, or one projection
605    /// from it when `projection_ref` is set.
606    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    /// `GET /agdx/schemas/{id}`: read one writer schema, or `None` on a 404.
621    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    /// `DELETE /agdx/schemas/{id}`: drop (tombstone) a schema.
626    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    /// `POST /agdx/schemas/{id}/decode`: decode a record body under the schema,
632    /// returning its JSON form, or `None` when the body does not decode under it.
633    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    /// `GET /agdx/filters`: list saved consumer filters, newest first.
646    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    /// `GET /agdx/filters/{id}`: one saved filter, or `None` on a 404.
654    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    /// `GET /agdx/filters/{id}/revisions`: the filter's revisions, newest first.
659    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    /// `POST /agdx/filters/mutations`: apply one catalog mutation. The reply is
669    /// the authoritative outcome: applied, rejected (with the typed reason), or
670    /// pending (read it later with [`filter_operation`](Self::filter_operation)).
671    /// A retry with the same operation id returns the first outcome.
672    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    /// `GET /agdx/filters/operations/{id}`: a mutation's outcome, or `None` when
692    /// the catalog has no record of it.
693    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    /// `POST /agdx/filters/validate`: validate and compile a filter.
702    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    /// `POST /agdx/filters/test`: evaluate a filter against one sample and
711    /// explain the verdict.
712    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    /// `POST /agdx/filters/preview`: preview a filter over stored records of
721    /// one partition. Joins no group and stores no offset.
722    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    /// `GET /agdx/filter-bindings`: list consumer-group filter bindings.
731    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    /// `GET /agdx/filter-bindings/{stream}/{topic}/{group}`: one group's
740    /// binding, or `None` when the group is not bound.
741    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    /// `GET /agdx/kv`: list the caller's namespaces and their entry counts.
761    pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
762        self.get(http::KV_PATH.to_owned()).await
763    }
764
765    /// `DELETE /agdx/kv/{namespace}`: bulk-delete entries matching the bounds
766    /// (no bounds clears the namespace). Returns the number removed.
767    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    /// `POST /agdx/forks/{id}/promote`: promote a fork's rows onto the trunk.
778    /// Returns the number of rows applied.
779    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    /// `DELETE /agdx/forks/{id}`: squash (discard) a fork.
787    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    /// `PUT /agdx/forks/{id}/rows`: write one speculative row into a fork.
793    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    /// `POST /agdx/graph/{name}/query`: run a traversal over a named graph, get
799    /// back the reachable nodes and traversed edges. Backend-gated by the `graph`
800    /// capability: a deployment without a graph backend answers unsupported.
801    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    /// `GET /agdx/graph/{name}/neighbors/{node}`: the neighbor read, the cheap
811    /// common traversal. `node` is the Crockford-base32 node id. `query` carries
812    /// the direction, an optional edge-type filter, the hop depth, and a limit (a
813    /// default `query` reads one hop outward with no filter).
814    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    /// `GET /agdx/graphs`: list the registered graph projections, the discovery
825    /// surface a graph explorer reads to offer the available graphs. Reuses the
826    /// projection-list filter, narrowed to graph-kind projections server-side.
827    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    /// `POST /agdx/graphs`: register a graph projection (a [`Projection`] with
835    /// `kind = Graph` and an entity schema). Applied asynchronously, like every
836    /// control command.
837    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    /// `GET /agdx/graphs/{id}`: read one graph projection by id, or `None` when no
843    /// graph projection has it.
844    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    /// `DELETE /agdx/graphs/{id}`: drop the graph projection registered under
849    /// `id`. The materialized nodes and edges are left untouched.
850    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    /// `GET /agdx/authz/whoami`: the caller's own bound roles and effective
856    /// grants. Backend-gated by the `authz` capability: a deployment that does
857    /// not serve the authorization band answers unsupported.
858    pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
859        self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
860    }
861
862    /// `GET /agdx/authz/roles`: every defined role with its full grant set.
863    pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
864        self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
865    }
866
867    /// `GET /agdx/authz/roles/{name}`: one role by name, or `None` on a 404.
868    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    /// `PUT /agdx/authz/roles/{name}`: define or replace a role. The path name is
873    /// authoritative, so `role.name` must match it.
874    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    /// `DELETE /agdx/authz/roles/{name}`: delete a role.
880    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    /// `GET /agdx/authz/users/{id}/roles`: one user's bound role names.
886    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    /// `PUT /agdx/authz/users/{id}/roles`: replace the user's whole role set.
891    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    /// A `GET` whose 404 means "absent" rather than an error.
906    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    /// Send a JSON body and only check the status, for a control route whose 2xx
939    /// body is empty.
940    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
975/// Append `?<urlencoded>` to a path when `params` serializes to a non-empty
976/// query string, else return the path unchanged.
977fn 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
987/// On a 2xx, decode the bare `Ok` payload, otherwise turn the body into a typed
988/// [`ClientError::Api`] (falling back to a synthetic body if the error body
989/// itself does not decode, so a malformed 500 still classifies).
990fn 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
999/// Like [`decode_ok`] but for a route whose 2xx body is empty.
1000fn 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
1018/// A best-effort classification for an `ErrorBody`-less failure response,
1019/// inferred from the HTTP status alone. Used only as the fallback when the body
1020/// did not carry a [`ResultCode`].
1021fn 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
1035// base64url (unpadded, RFC 4648 section 5), the encoding this surface uses for
1036// every binary value. Hand-rolled to keep the portable graph dependency-free.
1037
1038const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1039
1040/// Encode bytes as URL-safe unpadded base64 (RFC 4648 ยง5, no `=`).
1041pub 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
1065/// Decode URL-safe unpadded base64. `None` on any non-alphabet byte or an
1066/// impossible length (a single trailing char carries no whole byte).
1067pub 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        // Left-align the accumulated bits for a short final chunk.
1089        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    // A transport that replays a canned response, so the typed methods are
1175    // testable without any IO.
1176    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        // A minimal executor: these futures never yield (the canned transport
1189        // is ready immediately), so a busy poll with the no-op waker resolves
1190        // them. No `unsafe`, so the crate's `forbid(unsafe_code)` holds.
1191        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}