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::authz::{Role, WhoamiReply};
15use crate::browse::{ProjectionInfo, SchemaInfo};
16use crate::checkpoint::CheckpointReadConsistency;
17use crate::control::{Projection, ProjectionBinding, SchemaSource, SourceSelector};
18use crate::destination::{DestinationId, DestinationOperationId};
19use crate::filter::{
20    ConsumerFilter, FilterBinding, FilterBindingPage, FilterDetail, FilterMutationOutcome,
21    FilterMutationRequest, FilterPage, FilterPreview, FilterPreviewRequest, FilterRevisionPage,
22    FilterTestResult, FilterValidation,
23};
24use crate::fork::ForkInfo;
25use crate::graph::GraphQuery;
26use crate::http::{
27    self, AcceptedOperationView, Capabilities, CasCommittedView, CheckpointReadQuery,
28    ClientMetadataListView, ClientsQuery, DecodeRecordBody, DeletedManyView, DestinationIssueView,
29    DestinationListQuery, DestinationMutationBody, DestinationPageView, DestinationView, ErrorBody,
30    FilterBindingListQuery, FilterListQuery, FilterRevisionListQuery, FilterTestBody,
31    ForkCreateBody, ForkPutBody, GraphNeighborsQuery, GraphResultView, KvCasQuery, KvPageView,
32    KvPutQuery, KvScanQuery, ProjectionListQuery, PromotedView, QueryExecutionView, QueryPageBody,
33    QueryRouteListQuery, QueryRoutePageView, RemoveBindingBody, SchemaListQuery,
34    SessionChangesQuery, SessionEventsQuery, SessionLinksQuery, SessionStateQuery, SessionsQuery,
35    SnapshotListQuery, SnapshotPageView, TableFileListQuery, TableFilePageView, TableMetricsView,
36    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. `stream`
421    /// names the stream whose registry allocates it, `None` the
422    /// deployment-wide registry.
423    pub async fn register_schema(
424        &self,
425        source: SchemaSource,
426        name: Option<String>,
427        version: Option<u32>,
428        stream: Option<String>,
429    ) -> ClientResult<u32, T::Error> {
430        let body = http::RegisterSchemaBody {
431            source,
432            name,
433            version,
434            stream,
435        };
436        self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
437            .await
438    }
439
440    /// `GET /agdx/kv/{namespace}/{key}`: fetch one entry, or `None` on a 404.
441    /// The value rides the raw response body (no base64 inflation, symmetric with
442    /// `kv_set`), and the optional expiry rides the
443    /// [`KV_EXPIRES_AT_MICROS_HEADER`](http::KV_EXPIRES_AT_MICROS_HEADER) response
444    /// header.
445    pub async fn kv_get(
446        &self,
447        namespace: &str,
448        key: &[u8],
449    ) -> ClientResult<Option<KvValue>, T::Error> {
450        let path = http::kv_entry_path(namespace, &base64url_encode(key));
451        let response = self.dispatch(Method::Get, path, None).await?;
452        if response.status == 404 {
453            return Ok(None);
454        }
455        if !(200..300).contains(&response.status) {
456            return Err(api_error(&response));
457        }
458        let expires_at_micros = response
459            .header(http::KV_EXPIRES_AT_MICROS_HEADER)
460            .and_then(|value| value.parse::<u64>().ok());
461        Ok(Some(KvValue {
462            value: response.body,
463            expires_at_micros,
464        }))
465    }
466
467    /// `PUT /agdx/kv/{namespace}/{key}`: set a value, with an optional absolute
468    /// expiry (epoch microseconds).
469    pub async fn kv_set(
470        &self,
471        namespace: &str,
472        key: &[u8],
473        value: &[u8],
474        expires_at_micros: Option<u64>,
475    ) -> ClientResult<(), T::Error> {
476        let path = with_query(
477            &http::kv_entry_path(namespace, &base64url_encode(key)),
478            &KvPutQuery { expires_at_micros },
479        )?;
480        // The key is base64url in the path, but the value rides the raw request
481        // body, so a large value carries no base64 inflation.
482        self.expect_ok(Method::Put, path, Some(value.to_vec()))
483            .await
484    }
485
486    /// `PUT /agdx/kv/{namespace}/{key}/cas`: a conditional write
487    /// (compare-and-swap). Applies `value` only if `expect` holds, returning the
488    /// new version on commit. A precondition miss surfaces as
489    /// `ClientError::Api` with `ResultCode::Conflict` (the response's
490    /// `ErrorBody.detail` carries the current version). Backend-gated by the
491    /// `kv_cas` capability: a deployment that does not serve it answers
492    /// unsupported.
493    pub async fn kv_cas(
494        &self,
495        namespace: &str,
496        key: &[u8],
497        value: &[u8],
498        expect: CasExpect,
499        expires_at_micros: Option<u64>,
500    ) -> ClientResult<u64, T::Error> {
501        let (expect_version, expect_absent) = match expect {
502            CasExpect::Match(version) => (Some(version), None),
503            CasExpect::Absent => (None, Some(true)),
504        };
505        let path = with_query(
506            &http::kv_cas_path(namespace, &base64url_encode(key)),
507            &KvCasQuery {
508                expect_version,
509                expect_absent,
510                expires_at_micros,
511            },
512        )?;
513        // The value rides the raw body like the plain PUT.
514        let response = self
515            .dispatch(Method::Put, path, Some(value.to_vec()))
516            .await?;
517        let view: CasCommittedView = decode_ok(&response)?;
518        Ok(view.version)
519    }
520
521    /// `DELETE /agdx/kv/{namespace}/{key}`: delete one entry, returning `true` if it existed.
522    pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
523        let path = http::kv_entry_path(namespace, &base64url_encode(key));
524        self.send_empty(Method::Delete, path).await
525    }
526
527    /// `GET /agdx/kv/{namespace}`: scan a page of entries.
528    pub async fn kv_scan(
529        &self,
530        namespace: &str,
531        filter: &KvScanQuery,
532    ) -> ClientResult<KvPageView, T::Error> {
533        self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
534            .await
535    }
536
537    /// `POST /agdx/forks`: create a fork.
538    pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
539        self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
540            .await
541    }
542
543    /// `GET /agdx/forks`: list the caller's forks.
544    pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
545        self.get(http::FORKS_PATH.to_owned()).await
546    }
547
548    /// `GET /agdx/clients`: one page of live connections with their advertised
549    /// metadata, filtered and paginated per `query`. Follow `next_cursor` as the
550    /// next `after` to page.
551    pub async fn clients(
552        &self,
553        query: &ClientsQuery,
554    ) -> ClientResult<ClientMetadataListView, T::Error> {
555        self.get(with_query(http::CLIENTS_PATH, query)?).await
556    }
557
558    /// `GET /agdx/sessions/{stream}`: one page of the stream's sessions,
559    /// filtered and paged per `query`.
560    pub async fn list_sessions(
561        &self,
562        stream: &str,
563        query: &SessionsQuery,
564    ) -> ClientResult<crate::session::SessionPage, T::Error> {
565        self.get(with_query(&http::sessions_path(stream), query)?)
566            .await
567    }
568
569    /// `GET /agdx/sessions/{stream}/{id}`: one session's summary, or `None` on
570    /// a 404.
571    pub async fn session(
572        &self,
573        stream: &str,
574        id: crate::agent::ConversationId,
575    ) -> ClientResult<Option<crate::session::SessionInfo>, T::Error> {
576        self.get_optional(http::session_path(stream, id)).await
577    }
578
579    /// `GET /agdx/sessions/{stream}/{id}/events`: one page of a session's
580    /// events.
581    pub async fn session_events(
582        &self,
583        stream: &str,
584        id: crate::agent::ConversationId,
585        query: &SessionEventsQuery,
586    ) -> ClientResult<crate::session::SessionEventsPage, T::Error> {
587        self.get(with_query(&http::session_events_path(stream, id), query)?)
588            .await
589    }
590
591    /// `GET /agdx/sessions/{stream}/{id}/state`: a session's state document.
592    pub async fn session_state(
593        &self,
594        stream: &str,
595        id: crate::agent::ConversationId,
596        query: &SessionStateQuery,
597    ) -> ClientResult<crate::session::SessionStateView, T::Error> {
598        self.get(with_query(&http::session_state_path(stream, id), query)?)
599            .await
600    }
601
602    /// `GET /agdx/sessions/{stream}/{id}/links`: the resources a session
603    /// wrote, recalled, or touched.
604    pub async fn session_links(
605        &self,
606        stream: &str,
607        id: crate::agent::ConversationId,
608        query: &SessionLinksQuery,
609    ) -> ClientResult<crate::session::SessionLinksView, T::Error> {
610        self.get(with_query(&http::session_links_path(stream, id), query)?)
611            .await
612    }
613
614    /// `GET /agdx/sessions/{stream}/{id}/sources`: the source partitions that
615    /// hold a session's records.
616    pub async fn session_sources(
617        &self,
618        stream: &str,
619        id: crate::agent::ConversationId,
620    ) -> ClientResult<crate::session::SessionSources, T::Error> {
621        self.get(http::session_sources_path(stream, id)).await
622    }
623
624    /// `GET /agdx/sessions/{stream}/changes`: the stream's change rows after
625    /// `query.after`.
626    pub async fn session_changes(
627        &self,
628        stream: &str,
629        query: &SessionChangesQuery,
630    ) -> ClientResult<crate::session::SessionChanges, T::Error> {
631        self.get(with_query(&http::session_changes_path(stream), query)?)
632            .await
633    }
634
635    /// `GET /agdx/projections/{id}`: read one projection and its bindings, or
636    /// `None` on a 404.
637    pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
638        self.get_optional(http::projection_path(id)).await
639    }
640
641    /// `POST /agdx/projections`: register or replace a projection (a control
642    /// command, durable on the control topic).
643    pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
644        self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
645            .await
646    }
647
648    /// `DELETE /agdx/projections/{id}`: drop a projection.
649    pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
650        self.expect_ok(Method::Delete, http::projection_path(id), None)
651            .await
652    }
653
654    /// `POST /agdx/bindings`: apply (add or update) a binding.
655    pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
656        self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
657            .await
658    }
659
660    /// `DELETE /agdx/bindings`: remove a binding for a source, or one projection
661    /// from it when `projection_ref` is set.
662    pub async fn remove_binding(
663        &self,
664        source: &SourceSelector,
665        projection_ref: Option<String>,
666    ) -> ClientResult<(), T::Error> {
667        let body = RemoveBindingBody {
668            stream: source.stream.clone(),
669            topic: source.topic.clone(),
670            projection_ref,
671        };
672        self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
673            .await
674    }
675
676    /// `GET /agdx/schemas/{id}`: read one writer schema, or `None` on a 404.
677    /// `stream` names the stream whose registry holds the id, `None` the
678    /// deployment-wide registry.
679    pub async fn get_schema(
680        &self,
681        id: u32,
682        stream: Option<&str>,
683    ) -> ClientResult<Option<SchemaInfo>, T::Error> {
684        self.get_optional(schema_route(http::schema_path(id), stream)?)
685            .await
686    }
687
688    /// `DELETE /agdx/schemas/{id}`: drop (tombstone) a schema. `stream` names
689    /// the stream whose registry holds the id.
690    pub async fn drop_schema(&self, id: u32, stream: Option<&str>) -> ClientResult<(), T::Error> {
691        self.expect_ok(
692            Method::Delete,
693            schema_route(http::schema_path(id), stream)?,
694            None,
695        )
696        .await
697    }
698
699    /// `POST /agdx/schemas/{id}/decode`: decode a record body under the schema,
700    /// returning its JSON form, or `None` when the body does not decode under it.
701    /// `stream` names the stream whose registry holds the id.
702    pub async fn decode_record(
703        &self,
704        id: u32,
705        payload: &[u8],
706        stream: Option<&str>,
707    ) -> ClientResult<Option<serde_json::Value>, T::Error> {
708        let body = DecodeRecordBody {
709            payload: base64url_encode(payload),
710        };
711        self.send_json(
712            Method::Post,
713            schema_route(http::schema_decode_path(id), stream)?,
714            &body,
715        )
716        .await
717    }
718
719    /// `GET /agdx/filters`: list saved consumer filters, newest first.
720    pub async fn list_filters(
721        &self,
722        query: &FilterListQuery,
723    ) -> ClientResult<FilterPage, T::Error> {
724        self.get(with_query(http::FILTERS_PATH, query)?).await
725    }
726
727    /// `GET /agdx/filters/{id}`: one saved filter, or `None` on a 404.
728    pub async fn get_filter(&self, id: u32) -> ClientResult<Option<FilterDetail>, T::Error> {
729        self.get_optional(http::filter_path(id)).await
730    }
731
732    /// `GET /agdx/filters/{id}/revisions`: the filter's revisions, newest first.
733    pub async fn list_filter_revisions(
734        &self,
735        id: u32,
736        query: &FilterRevisionListQuery,
737    ) -> ClientResult<FilterRevisionPage, T::Error> {
738        self.get(with_query(&http::filter_revisions_path(id), query)?)
739            .await
740    }
741
742    /// `POST /agdx/filters/mutations`: apply one catalog mutation. The reply is
743    /// the authoritative outcome: applied, rejected (with the typed reason), or
744    /// pending (read it later with [`filter_operation`](Self::filter_operation)).
745    /// A retry with the same operation id returns the first outcome.
746    pub async fn mutate_filter(
747        &self,
748        request: &FilterMutationRequest,
749    ) -> ClientResult<FilterMutationOutcome, T::Error> {
750        let payload = serde_json::to_vec(request)
751            .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
752        let response = self
753            .dispatch(
754                Method::Post,
755                http::FILTER_MUTATIONS_PATH.to_owned(),
756                Some(payload),
757            )
758            .await?;
759        if let Ok(outcome) = serde_json::from_slice::<FilterMutationOutcome>(&response.body) {
760            return Ok(outcome);
761        }
762        decode_ok(&response)
763    }
764
765    /// `GET /agdx/filters/operations/{id}`: a mutation's outcome, or `None` when
766    /// the catalog has no record of it.
767    pub async fn filter_operation(
768        &self,
769        operation_id: u128,
770    ) -> ClientResult<Option<FilterMutationOutcome>, T::Error> {
771        self.get_optional(http::filter_operation_path(operation_id))
772            .await
773    }
774
775    /// `POST /agdx/filters/validate`: validate and compile a filter.
776    pub async fn validate_filter(
777        &self,
778        filter: &ConsumerFilter,
779    ) -> ClientResult<FilterValidation, T::Error> {
780        self.send_json(Method::Post, http::FILTER_VALIDATE_PATH.to_owned(), filter)
781            .await
782    }
783
784    /// `POST /agdx/filters/test`: evaluate a filter against one sample and
785    /// explain the verdict.
786    pub async fn test_filter(
787        &self,
788        body: &FilterTestBody,
789    ) -> ClientResult<FilterTestResult, T::Error> {
790        self.send_json(Method::Post, http::FILTER_TEST_PATH.to_owned(), body)
791            .await
792    }
793
794    /// `POST /agdx/filters/preview`: preview a filter over stored records of
795    /// one partition. Joins no group and stores no offset.
796    pub async fn preview_filter(
797        &self,
798        request: &FilterPreviewRequest,
799    ) -> ClientResult<FilterPreview, T::Error> {
800        self.send_json(Method::Post, http::FILTER_PREVIEW_PATH.to_owned(), request)
801            .await
802    }
803
804    /// `GET /agdx/filter-bindings`: list consumer-group filter bindings.
805    pub async fn list_filter_bindings(
806        &self,
807        query: &FilterBindingListQuery,
808    ) -> ClientResult<FilterBindingPage, T::Error> {
809        self.get(with_query(http::FILTER_BINDINGS_PATH, query)?)
810            .await
811    }
812
813    /// `GET /agdx/filter-bindings/{stream}/{topic}/{group}`: one group's
814    /// binding, or `None` when the group is not bound.
815    pub async fn get_filter_binding(
816        &self,
817        stream: &str,
818        topic: &str,
819        group: &str,
820    ) -> ClientResult<Option<FilterBinding>, T::Error> {
821        let response = self
822            .dispatch(
823                Method::Get,
824                http::filter_binding_path(stream, topic, group),
825                None,
826            )
827            .await?;
828        if response.status == 404 && response.body.is_empty() {
829            return Ok(None);
830        }
831        decode_ok(&response).map(Some)
832    }
833
834    /// `GET /agdx/kv`: list the caller's namespaces and their entry counts.
835    pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
836        self.get(http::KV_PATH.to_owned()).await
837    }
838
839    /// `DELETE /agdx/kv/{namespace}`: bulk-delete entries matching the bounds
840    /// (no bounds clears the namespace). Returns the number removed.
841    pub async fn kv_delete_many(
842        &self,
843        namespace: &str,
844        filter: &KvScanQuery,
845    ) -> ClientResult<usize, T::Error> {
846        let path = with_query(&http::kv_namespace_path(namespace), filter)?;
847        let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
848        Ok(view.deleted)
849    }
850
851    /// `POST /agdx/forks/{id}/promote`: promote a fork's rows onto the trunk.
852    /// Returns the number of rows applied.
853    pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
854        let view: PromotedView = self
855            .send_empty(Method::Post, http::fork_promote_path(id))
856            .await?;
857        Ok(view.rows)
858    }
859
860    /// `DELETE /agdx/forks/{id}`: squash (discard) a fork.
861    pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
862        self.expect_ok(Method::Delete, http::fork_path(id), None)
863            .await
864    }
865
866    /// `PUT /agdx/forks/{id}/rows`: write one speculative row into a fork.
867    pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
868        self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
869            .await
870    }
871
872    /// `POST /agdx/graph/{name}/query`: run a traversal over a named graph, get
873    /// back the reachable nodes and traversed edges. Backend-gated by the `graph`
874    /// capability: a deployment without a graph backend answers unsupported.
875    pub async fn graph_query(
876        &self,
877        name: &str,
878        query: &GraphQuery,
879    ) -> ClientResult<GraphResultView, T::Error> {
880        self.send_json(Method::Post, http::graph_query_path(name), query)
881            .await
882    }
883
884    /// `GET /agdx/graph/{name}/neighbors/{node}`: the neighbor read, the cheap
885    /// common traversal. `node` is the Crockford-base32 node id. `query` carries
886    /// the direction, an optional edge-type filter, the hop depth, and a limit (a
887    /// default `query` reads one hop outward with no filter).
888    pub async fn graph_neighbors(
889        &self,
890        name: &str,
891        node: &str,
892        query: &GraphNeighborsQuery,
893    ) -> ClientResult<GraphResultView, T::Error> {
894        self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
895            .await
896    }
897
898    /// `GET /agdx/graphs`: list the registered graph projections, the discovery
899    /// surface a graph explorer reads to offer the available graphs. Reuses the
900    /// projection-list filter, narrowed to graph-kind projections server-side.
901    pub async fn list_graphs(
902        &self,
903        filter: &ProjectionListQuery,
904    ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
905        self.get(with_query(http::GRAPHS_PATH, filter)?).await
906    }
907
908    /// `POST /agdx/graphs`: register a graph projection (a [`Projection`] with
909    /// `kind = Graph` and an entity schema). Applied asynchronously, like every
910    /// control command.
911    pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
912        self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
913            .await
914    }
915
916    /// `GET /agdx/graphs/{id}`: read one graph projection by id, or `None` when no
917    /// graph projection has it.
918    pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
919        self.get_optional(http::graph_path(id)).await
920    }
921
922    /// `DELETE /agdx/graphs/{id}`: drop the graph projection registered under
923    /// `id`. The materialized nodes and edges are left untouched.
924    pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
925        self.expect_ok(Method::Delete, http::graph_path(id), None)
926            .await
927    }
928
929    /// `GET /agdx/authz/whoami`: the caller's own bound roles and effective
930    /// grants. Backend-gated by the `authz` capability: a deployment that does
931    /// not serve the authorization band answers unsupported.
932    pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
933        self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
934    }
935
936    /// `GET /agdx/authz/roles`: every defined role with its full grant set.
937    pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
938        self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
939    }
940
941    /// `GET /agdx/authz/roles/{name}`: one role by name, or `None` on a 404.
942    pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
943        self.get_optional(http::authz_role_path(name)).await
944    }
945
946    /// `PUT /agdx/authz/roles/{name}`: define or replace a role. The path name is
947    /// authoritative, so `role.name` must match it.
948    pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
949        self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
950            .await
951    }
952
953    /// `DELETE /agdx/authz/roles/{name}`: delete a role.
954    pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
955        self.expect_ok(Method::Delete, http::authz_role_path(name), None)
956            .await
957    }
958
959    /// `GET /agdx/authz/users/{id}/roles`: one user's bound role names.
960    pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
961        self.get(http::authz_user_roles_path(user_id)).await
962    }
963
964    /// `PUT /agdx/authz/users/{id}/roles`: replace the user's whole role set.
965    pub async fn bind_user_roles(
966        &self,
967        user_id: u32,
968        roles: &[String],
969    ) -> ClientResult<(), T::Error> {
970        self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
971            .await
972    }
973
974    async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
975        let response = self.dispatch(Method::Get, path, None).await?;
976        decode_ok(&response)
977    }
978
979    /// A `GET` whose 404 means "absent" rather than an error.
980    async fn get_optional<R: DeserializeOwned>(
981        &self,
982        path: String,
983    ) -> ClientResult<Option<R>, T::Error> {
984        let response = self.dispatch(Method::Get, path, None).await?;
985        if response.status == 404 {
986            return Ok(None);
987        }
988        decode_ok(&response).map(Some)
989    }
990
991    async fn send_json<B: Serialize, R: DeserializeOwned>(
992        &self,
993        method: Method,
994        path: String,
995        body: &B,
996    ) -> ClientResult<R, T::Error> {
997        let payload = serde_json::to_vec(body)
998            .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
999        let response = self.dispatch(method, path, Some(payload)).await?;
1000        decode_ok(&response)
1001    }
1002
1003    async fn send_empty<R: DeserializeOwned>(
1004        &self,
1005        method: Method,
1006        path: String,
1007    ) -> ClientResult<R, T::Error> {
1008        let response = self.dispatch(method, path, None).await?;
1009        decode_ok(&response)
1010    }
1011
1012    /// Send a JSON body and only check the status, for a control route whose 2xx
1013    /// body is empty.
1014    async fn send_json_ok<B: Serialize>(
1015        &self,
1016        method: Method,
1017        path: String,
1018        body: &B,
1019    ) -> ClientResult<(), T::Error> {
1020        let payload = serde_json::to_vec(body)
1021            .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1022        let response = self.dispatch(method, path, Some(payload)).await?;
1023        check_status(&response)
1024    }
1025
1026    async fn expect_ok(
1027        &self,
1028        method: Method,
1029        path: String,
1030        body: Option<Vec<u8>>,
1031    ) -> ClientResult<(), T::Error> {
1032        let response = self.dispatch(method, path, body).await?;
1033        check_status(&response)
1034    }
1035
1036    async fn dispatch(
1037        &self,
1038        method: Method,
1039        path: String,
1040        body: Option<Vec<u8>>,
1041    ) -> ClientResult<HttpResponse, T::Error> {
1042        self.transport
1043            .send(HttpRequest { method, path, body })
1044            .await
1045            .map_err(ClientError::Transport)
1046    }
1047}
1048
1049/// Append `?<urlencoded>` to a path when `params` serializes to a non-empty
1050/// query string, else return the path unchanged.
1051fn schema_route<E>(path: String, stream: Option<&str>) -> Result<String, ClientError<E>> {
1052    with_query(
1053        &path,
1054        &http::SchemaQuery {
1055            stream: stream.map(str::to_owned),
1056        },
1057    )
1058}
1059
1060fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
1061    let query = serde_urlencoded::to_string(params)
1062        .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
1063    if query.is_empty() {
1064        Ok(path.to_owned())
1065    } else {
1066        Ok(format!("{path}?{query}"))
1067    }
1068}
1069
1070/// On a 2xx, decode the bare `Ok` payload, otherwise turn the body into a typed
1071/// [`ClientError::Api`] (falling back to a synthetic body if the error body
1072/// itself does not decode, so a malformed 500 still classifies).
1073fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
1074    if (200..300).contains(&response.status) {
1075        serde_json::from_slice(&response.body)
1076            .map_err(|error| ClientError::Decode(format!("response body: {error}")))
1077    } else {
1078        Err(api_error(response))
1079    }
1080}
1081
1082/// Like [`decode_ok`] but for a route whose 2xx body is empty.
1083fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
1084    if (200..300).contains(&response.status) {
1085        Ok(())
1086    } else {
1087        Err(api_error(response))
1088    }
1089}
1090
1091fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
1092    let body = serde_json::from_slice::<ErrorBody>(&response.body).unwrap_or_else(|_| {
1093        ErrorBody::new(
1094            code_for_status(response.status),
1095            String::from_utf8_lossy(&response.body).into_owned(),
1096        )
1097    });
1098    ClientError::Api(body)
1099}
1100
1101/// A best-effort classification for an `ErrorBody`-less failure response,
1102/// inferred from the HTTP status alone. Used only as the fallback when the body
1103/// did not carry a [`ResultCode`].
1104fn code_for_status(status: u16) -> ResultCode {
1105    match status {
1106        404 => ResultCode::NotFound,
1107        400 => ResultCode::InvalidArgument,
1108        401 => ResultCode::Unauthenticated,
1109        403 => ResultCode::Forbidden,
1110        409 => ResultCode::Conflict,
1111        413 => ResultCode::TooLarge,
1112        501 => ResultCode::Unsupported,
1113        503 => ResultCode::Stale,
1114        _ => ResultCode::Backend,
1115    }
1116}
1117
1118// base64url (unpadded, RFC 4648 section 5), the encoding this surface uses for
1119// every binary value. Hand-rolled to keep the portable graph dependency-free.
1120
1121const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1122
1123/// Encode bytes as URL-safe unpadded base64 (RFC 4648 ยง5, no `=`).
1124pub fn base64url_encode(input: &[u8]) -> String {
1125    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1126    for chunk in input.chunks(3) {
1127        let b0 = chunk[0] as usize;
1128        out.push(B64URL[b0 >> 2] as char);
1129        match chunk.len() {
1130            1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
1131            2 => {
1132                let b1 = chunk[1] as usize;
1133                out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1134                out.push(B64URL[(b1 & 0b1111) << 2] as char);
1135            }
1136            _ => {
1137                let b1 = chunk[1] as usize;
1138                let b2 = chunk[2] as usize;
1139                out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1140                out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
1141                out.push(B64URL[b2 & 0b111111] as char);
1142            }
1143        }
1144    }
1145    out
1146}
1147
1148/// Decode URL-safe unpadded base64. `None` on any non-alphabet byte or an
1149/// impossible length (a single trailing char carries no whole byte).
1150pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
1151    fn val(byte: u8) -> Option<u8> {
1152        match byte {
1153            b'A'..=b'Z' => Some(byte - b'A'),
1154            b'a'..=b'z' => Some(byte - b'a' + 26),
1155            b'0'..=b'9' => Some(byte - b'0' + 52),
1156            b'-' => Some(62),
1157            b'_' => Some(63),
1158            _ => None,
1159        }
1160    }
1161    let bytes = input.as_bytes();
1162    if bytes.len() % 4 == 1 {
1163        return None;
1164    }
1165    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1166    for chunk in bytes.chunks(4) {
1167        let mut acc = 0u32;
1168        for &byte in chunk {
1169            acc = (acc << 6) | u32::from(val(byte)?);
1170        }
1171        // Left-align the accumulated bits for a short final chunk.
1172        acc <<= 6 * (4 - chunk.len());
1173        match chunk.len() {
1174            2 => out.push((acc >> 16) as u8),
1175            3 => {
1176                out.push((acc >> 16) as u8);
1177                out.push((acc >> 8) as u8);
1178            }
1179            _ => {
1180                out.push((acc >> 16) as u8);
1181                out.push((acc >> 8) as u8);
1182                out.push(acc as u8);
1183            }
1184        }
1185    }
1186    Some(out)
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192
1193    #[test]
1194    fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
1195        for case in [
1196            &b""[..],
1197            &b"f"[..],
1198            &b"fo"[..],
1199            &b"foo"[..],
1200            &b"foob"[..],
1201            &b"fooba"[..],
1202            &b"foobar"[..],
1203            &[0x00, 0xff, 0x10, 0x80][..],
1204        ] {
1205            let encoded = base64url_encode(case);
1206            assert!(
1207                !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1208                "url-safe unpadded: {encoded}"
1209            );
1210            assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1211        }
1212    }
1213
1214    #[test]
1215    fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1216        assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1217        assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1218    }
1219
1220    #[test]
1221    fn given_checkpoint_reads_when_paths_are_built_then_should_require_the_selected_consistency() {
1222        let single = with_query::<(), _>(
1223            &http::destination_status_path(DestinationId::from_u128(1)),
1224            &CheckpointReadQuery::new(CheckpointReadConsistency::Linearizable),
1225        )
1226        .expect("single checkpoint read query encodes");
1227        assert!(single.ends_with("?consistency=linearizable"));
1228
1229        let list = with_query::<(), _>(
1230            http::DESTINATIONS_PATH,
1231            &DestinationListQuery {
1232                source_stream: None,
1233                source_topic: None,
1234                name_contains: None,
1235                after: None,
1236                limit: None,
1237                consistency: CheckpointReadConsistency::PotentiallyStale,
1238            },
1239        )
1240        .expect("checkpoint list query encodes");
1241        assert!(list.ends_with("?consistency=potentially_stale"));
1242    }
1243
1244    #[test]
1245    fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1246        assert!(
1247            base64url_decode("====").is_none(),
1248            "padding is not alphabet"
1249        );
1250        assert!(
1251            base64url_decode("A").is_none(),
1252            "a lone char carries no byte"
1253        );
1254        assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1255    }
1256
1257    // A transport that replays a canned response, so the typed methods are
1258    // testable without any IO.
1259    struct CannedTransport {
1260        response: HttpResponse,
1261    }
1262
1263    impl Transport for CannedTransport {
1264        type Error = std::convert::Infallible;
1265        async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1266            Ok(self.response.clone())
1267        }
1268    }
1269
1270    fn block_on<F: core::future::Future>(future: F) -> F::Output {
1271        // A minimal executor: these futures never yield (the canned transport
1272        // is ready immediately), so a busy poll with the no-op waker resolves
1273        // them. No `unsafe`, so the crate's `forbid(unsafe_code)` holds.
1274        use core::task::{Context, Poll, Waker};
1275        let mut context = Context::from_waker(Waker::noop());
1276        let mut future = core::pin::pin!(future);
1277        loop {
1278            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1279                return output;
1280            }
1281        }
1282    }
1283
1284    #[test]
1285    fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1286        let body = serde_json::to_vec(&Capabilities::new(
1287            true,
1288            crate::hello::OpVersions::new(1, 1, 1, 1),
1289        ))
1290        .unwrap();
1291        let client = HttpClient::new(CannedTransport {
1292            response: HttpResponse::new(200, body),
1293        });
1294        let caps = block_on(client.capabilities()).expect("decodes");
1295        assert!(caps.managed && !caps.kv.cas);
1296    }
1297
1298    #[test]
1299    fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1300        let body =
1301            serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1302        let client = HttpClient::new(CannedTransport {
1303            response: HttpResponse::new(404, body),
1304        });
1305        let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1306        assert_eq!(error.code(), Some(ResultCode::NotFound));
1307    }
1308
1309    #[test]
1310    fn given_a_missing_binding_when_fetched_then_should_distinguish_absence_from_missing_source() {
1311        let absent = HttpClient::new(CannedTransport {
1312            response: HttpResponse::new(404, Vec::new()),
1313        });
1314        assert!(
1315            block_on(absent.get_filter_binding("stream", "topic", "group"))
1316                .unwrap()
1317                .is_none()
1318        );
1319        let body = serde_json::to_vec(&ErrorBody::new(
1320            ResultCode::NotFound,
1321            "topic does not exist",
1322        ))
1323        .unwrap();
1324        let missing_source = HttpClient::new(CannedTransport {
1325            response: HttpResponse::new(404, body),
1326        });
1327        let error =
1328            block_on(missing_source.get_filter_binding("stream", "topic", "group")).unwrap_err();
1329        assert_eq!(error.code(), Some(ResultCode::NotFound));
1330    }
1331
1332    #[test]
1333    fn given_a_missing_kv_entry_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 entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1339        assert!(entry.is_none());
1340    }
1341
1342    #[test]
1343    fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
1344        let response = HttpResponse::new(200, b"world".to_vec())
1345            .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
1346        let client = HttpClient::new(CannedTransport { response });
1347        let entry = block_on(client.kv_get("sessions", b"user:1"))
1348            .expect("decodes")
1349            .expect("present");
1350        assert_eq!(entry.value, b"world");
1351        assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1352    }
1353
1354    #[test]
1355    fn given_a_missing_projection_when_fetched_then_should_be_none() {
1356        let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1357        let client = HttpClient::new(CannedTransport {
1358            response: HttpResponse::new(404, body),
1359        });
1360        let info = block_on(client.get_projection("reading.v1")).expect("404 maps to None");
1361        assert!(info.is_none());
1362    }
1363
1364    #[test]
1365    fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1366        let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1367        let client = HttpClient::new(CannedTransport {
1368            response: HttpResponse::new(200, body),
1369        });
1370        let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1371            .expect("decodes the count");
1372        assert_eq!(removed, 7);
1373    }
1374
1375    #[test]
1376    fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1377        let client = HttpClient::new(CannedTransport {
1378            response: HttpResponse::new(204, Vec::new()),
1379        });
1380        block_on(client.drop_projection("reading.v1")).expect("a 204 is a success");
1381    }
1382
1383    #[test]
1384    fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1385        let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1386        let client = HttpClient::new(CannedTransport {
1387            response: HttpResponse::new(200, body),
1388        });
1389        let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1390            .expect("a commit returns the new version");
1391        assert_eq!(version, 4);
1392    }
1393
1394    #[test]
1395    fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1396        let reply = WhoamiReply {
1397            v: 1,
1398            roles: vec!["admin".to_owned()],
1399            grants: vec![crate::authz::Grant {
1400                effect: crate::authz::Effect::Allow,
1401                feature: crate::authz::Feature::Authz,
1402                action: crate::authz::Action::Admin,
1403                resource: crate::authz::ResourcePattern::all(),
1404            }],
1405        };
1406        let body = serde_json::to_vec(&reply).unwrap();
1407        let client = HttpClient::new(CannedTransport {
1408            response: HttpResponse::new(200, body),
1409        });
1410        let whoami = block_on(client.authz_whoami()).expect("decodes");
1411        assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1412        assert_eq!(whoami.grants.len(), 1);
1413    }
1414
1415    #[test]
1416    fn given_a_missing_role_when_fetched_then_should_be_none() {
1417        let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1418        let client = HttpClient::new(CannedTransport {
1419            response: HttpResponse::new(404, body),
1420        });
1421        let role = block_on(client.get_role("ghost")).expect("404 maps to None");
1422        assert!(role.is_none());
1423    }
1424
1425    #[test]
1426    fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1427        let body = serde_json::to_vec(
1428            &ErrorBody::new(ResultCode::Conflict, "version conflict")
1429                .with_detail(serde_json::json!({ "current": 3 })),
1430        )
1431        .unwrap();
1432        let client = HttpClient::new(CannedTransport {
1433            response: HttpResponse::new(409, body),
1434        });
1435        let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1436            .expect_err("a precondition miss is an error");
1437        assert_eq!(error.code(), Some(ResultCode::Conflict));
1438    }
1439}