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, OperationQuery, ProjectionListQuery, PromotedView, QueryExecutionView,
33    QueryPageBody, 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;
43use std::num::NonZeroU128;
44
45/// The HTTP verb a [`Transport`] must perform.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
47#[strum(serialize_all = "UPPERCASE")]
48pub enum Method {
49    Get,
50    Post,
51    Put,
52    Delete,
53}
54
55impl Method {
56    /// The uppercase method token (`"GET"`, ...).
57    pub fn as_str(self) -> &'static str {
58        self.into()
59    }
60}
61
62/// A request the [`Transport`] performs. `path` is already the full path plus
63/// query string (e.g. `/agdx/kv/sessions/dXNlcjox?expires_at_micros=10`). The
64/// transport prepends its own base URL. A JSON `body` is present only on
65/// `POST`/`PUT`, and when present the transport sends `Content-Type:
66/// application/json`.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct HttpRequest {
69    pub method: Method,
70    pub path: String,
71    pub body: Option<Vec<u8>>,
72}
73
74/// What the [`Transport`] returns: the numeric status, the response headers (for
75/// the routes that carry metadata out of band, like the KV get expiry), and the
76/// raw body. A transport that does not surface headers leaves `headers` empty,
77/// in which case header-carried metadata reads as absent.
78#[derive(Clone, Debug, PartialEq, Eq, Default)]
79pub struct HttpResponse {
80    pub status: u16,
81    pub headers: Vec<(String, String)>,
82    pub body: Vec<u8>,
83}
84
85impl HttpResponse {
86    /// A response with no headers.
87    pub fn new(status: u16, body: Vec<u8>) -> Self {
88        Self {
89            status,
90            headers: Vec::new(),
91            body,
92        }
93    }
94
95    /// Builder helper: attach a header (used by transports and tests).
96    #[must_use]
97    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
98        self.headers.push((name.into(), value.into()));
99        self
100    }
101
102    /// The first value of header `name`, matched case-insensitively (HTTP header
103    /// names are case-insensitive). `None` if absent.
104    pub fn header(&self, name: &str) -> Option<&str> {
105        self.headers
106            .iter()
107            .find(|(key, _)| key.eq_ignore_ascii_case(name))
108            .map(|(_, value)| value.as_str())
109    }
110}
111
112/// The result of a single-key KV read: the raw value bytes plus the optional
113/// absolute expiry (epoch microseconds). A `kv_get` builds it from the raw
114/// response body and the [`KV_EXPIRES_AT_MICROS_HEADER`](http::KV_EXPIRES_AT_MICROS_HEADER)
115/// header. A scan page carries the base64url [`KvEntryView`](http::KvEntryView)
116/// shape instead, since a JSON array cannot hold raw bytes.
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct KvValue {
119    pub value: Vec<u8>,
120    pub expires_at_micros: Option<u64>,
121}
122
123/// The IO seam. A wasm consumer implements it over `gloo-net`, a native one over
124/// `reqwest`. The client never constructs URLs beyond the path: the transport
125/// owns the base URL, credentials, and headers.
126///
127/// Authentication and 401 refresh-retry live here, in the transport, not in the
128/// client. A transport attaches the credential to every request, and on a 401 it
129/// may refresh the token and retry once before returning the response. This is
130/// the injection point a real UI needs, and it keeps the client itself
131/// auth-agnostic.
132///
133/// The trait uses an `async fn` in a trait, so the returned future is not bound
134/// `Send`. A browser (single-threaded wasm) needs nothing more. A native caller
135/// that drives the client on a multi-threaded executor and needs a `Send` future
136/// (for example to `tokio::spawn` it) should run the client on a current-thread
137/// runtime or wrap the call in a `Send`-producing adapter.
138#[allow(async_fn_in_trait)]
139pub trait Transport {
140    /// The transport's own failure type (a network error, a timeout). Surfaced
141    /// verbatim through [`ClientError::Transport`].
142    type Error: core::fmt::Display;
143
144    /// Perform one request. A non-2xx status is **not** an error here: the
145    /// client inspects the status and decodes the [`ErrorBody`]. Return `Err`
146    /// only when the request never produced a response.
147    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
148}
149
150/// Why a typed call failed.
151#[derive(Debug)]
152pub enum ClientError<E> {
153    /// The transport never got a response (network down, timeout).
154    Transport(E),
155    /// A response arrived but its body did not decode as the expected type.
156    Decode(String),
157    /// The server returned a non-2xx status with a classified [`ErrorBody`].
158    Api(ErrorBody),
159    /// A non-2xx reply without an [`ErrorBody`], such as a gateway's plain-text 502 or 504.
160    /// `body` carries the code derived from the status and the reply text.
161    Status { status: u16, body: ErrorBody },
162}
163
164impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
165    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
166        match self {
167            ClientError::Transport(error) => write!(f, "transport error: {error}"),
168            ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
169            ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
170            ClientError::Status { status, body } => {
171                write!(
172                    f,
173                    "http status {status} ({:?}): {}",
174                    body.code, body.message
175                )
176            }
177        }
178    }
179}
180
181impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
182
183impl<E> ClientError<E> {
184    /// The classified [`ResultCode`] for an [`ClientError::Api`] or [`ClientError::Status`],
185    /// so a caller branches on the kind (`NotFound`, `Conflict`, ...) without matching the
186    /// message text. `None` for a transport or decode failure.
187    pub fn code(&self) -> Option<ResultCode> {
188        match self {
189            ClientError::Api(body) | ClientError::Status { body, .. } => Some(body.code),
190            _ => None,
191        }
192    }
193}
194
195type ClientResult<T, E> = Result<T, ClientError<E>>;
196
197/// A typed `/agdx/*` client over an injected [`Transport`].
198///
199/// Binary KV keys are base64url-encoded into the path by the client, so raw user
200/// bytes never enter a path segment. A `namespace`, `fork id`, or `projection
201/// id` argument, by contrast, is placed in the path verbatim, so the caller must
202/// pass a path-safe identifier (no `/`, `?`, or `#`). Fork ids are already
203/// constrained by [`validate_fork_id`](crate::fork::validate_fork_id).
204#[derive(Clone, Debug)]
205pub struct HttpClient<T> {
206    transport: T,
207}
208
209impl<T: Transport> HttpClient<T> {
210    /// Wrap a transport. The transport owns the base URL and auth.
211    pub fn new(transport: T) -> Self {
212        Self { transport }
213    }
214
215    /// The underlying transport, for a caller that needs an escape hatch.
216    pub fn transport(&self) -> &T {
217        &self.transport
218    }
219
220    /// `GET /agdx/capabilities`: feature-detect before showing a view.
221    pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
222        self.get(http::CAPABILITIES_PATH.to_owned()).await
223    }
224
225    /// `POST /agdx/query`: submit a query and return its current execution view.
226    pub async fn query(&self, query: &Query) -> ClientResult<QueryExecutionView, T::Error> {
227        self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
228            .await
229    }
230
231    pub async fn query_page(
232        &self,
233        execution_id: crate::query::QueryExecutionId,
234        body: &QueryPageBody,
235    ) -> ClientResult<QueryResult, T::Error> {
236        self.send_json(Method::Post, http::query_page_path(execution_id), body)
237            .await
238    }
239
240    pub async fn query_status(
241        &self,
242        execution_id: crate::query::QueryExecutionId,
243    ) -> ClientResult<QueryExecutionView, T::Error> {
244        self.get(http::query_execution_path(execution_id)).await
245    }
246
247    pub async fn cancel_query(
248        &self,
249        execution_id: crate::query::QueryExecutionId,
250    ) -> ClientResult<crate::query::QueryExecutionStatus, T::Error> {
251        self.send_empty(Method::Post, http::query_cancel_path(execution_id))
252            .await
253    }
254
255    pub async fn list_destinations(
256        &self,
257        query: &DestinationListQuery,
258    ) -> ClientResult<DestinationPageView, T::Error> {
259        self.get(with_query(http::DESTINATIONS_PATH, query)?).await
260    }
261
262    pub async fn destination(
263        &self,
264        id: DestinationId,
265        consistency: CheckpointReadConsistency,
266    ) -> ClientResult<Option<DestinationView>, T::Error> {
267        self.get_optional(with_query(
268            &http::destination_path(id),
269            &CheckpointReadQuery::new(consistency),
270        )?)
271        .await
272    }
273
274    pub async fn create_destination(
275        &self,
276        body: &DestinationMutationBody,
277    ) -> ClientResult<AcceptedOperationView, T::Error> {
278        self.send_json(Method::Post, http::DESTINATIONS_PATH.to_owned(), body)
279            .await
280    }
281
282    pub async fn enable_destination(
283        &self,
284        id: DestinationId,
285        body: &DestinationMutationBody,
286    ) -> ClientResult<AcceptedOperationView, T::Error> {
287        self.send_json(Method::Post, http::destination_enable_path(id), body)
288            .await
289    }
290
291    pub async fn disable_destination(
292        &self,
293        id: DestinationId,
294        body: &DestinationMutationBody,
295    ) -> ClientResult<AcceptedOperationView, T::Error> {
296        self.send_json(Method::Post, http::destination_disable_path(id), body)
297            .await
298    }
299
300    pub async fn destination_operation(
301        &self,
302        id: DestinationOperationId,
303    ) -> ClientResult<AcceptedOperationView, T::Error> {
304        self.get(http::destination_operation_path(id)).await
305    }
306
307    pub async fn destination_status(
308        &self,
309        id: DestinationId,
310        consistency: CheckpointReadConsistency,
311    ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
312        self.get(with_query(
313            &http::destination_status_path(id),
314            &CheckpointReadQuery::new(consistency),
315        )?)
316        .await
317    }
318
319    pub async fn destination_checkpoint(
320        &self,
321        id: DestinationId,
322        consistency: CheckpointReadConsistency,
323    ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
324        self.get(with_query(
325            &http::destination_checkpoint_path(id),
326            &CheckpointReadQuery::new(consistency),
327        )?)
328        .await
329    }
330
331    pub async fn destination_retention_gap(
332        &self,
333        id: DestinationId,
334        consistency: CheckpointReadConsistency,
335    ) -> ClientResult<DestinationIssueView, T::Error> {
336        self.get(with_query(
337            &http::destination_retention_gap_path(id),
338            &CheckpointReadQuery::new(consistency),
339        )?)
340        .await
341    }
342
343    pub async fn destination_prepared_attempt(
344        &self,
345        id: DestinationId,
346        consistency: CheckpointReadConsistency,
347    ) -> ClientResult<DestinationIssueView, T::Error> {
348        self.get(with_query(
349            &http::destination_prepared_attempt_path(id),
350            &CheckpointReadQuery::new(consistency),
351        )?)
352        .await
353    }
354
355    pub async fn query_routes(
356        &self,
357        query: &QueryRouteListQuery,
358    ) -> ClientResult<QueryRoutePageView, T::Error> {
359        self.get(with_query(http::QUERY_ROUTES_PATH, query)?).await
360    }
361
362    pub async fn destination_table(&self, id: DestinationId) -> ClientResult<TableView, T::Error> {
363        self.get(http::destination_table_path(id)).await
364    }
365
366    pub async fn destination_table_schema(
367        &self,
368        id: DestinationId,
369    ) -> ClientResult<TableSchemaView, T::Error> {
370        self.get(http::destination_table_schema_path(id)).await
371    }
372
373    pub async fn destination_current_snapshot(
374        &self,
375        id: DestinationId,
376    ) -> ClientResult<TableSnapshotView, T::Error> {
377        self.get(http::destination_current_snapshot_path(id)).await
378    }
379
380    pub async fn destination_snapshots(
381        &self,
382        id: DestinationId,
383        query: &SnapshotListQuery,
384    ) -> ClientResult<SnapshotPageView, T::Error> {
385        self.get(with_query(&http::destination_snapshots_path(id), query)?)
386            .await
387    }
388
389    pub async fn destination_snapshot(
390        &self,
391        id: DestinationId,
392        snapshot_id: i64,
393    ) -> ClientResult<TableSnapshotView, T::Error> {
394        self.get(http::destination_snapshot_path(id, snapshot_id))
395            .await
396    }
397
398    pub async fn destination_files(
399        &self,
400        id: DestinationId,
401        query: &TableFileListQuery,
402    ) -> ClientResult<TableFilePageView, T::Error> {
403        self.get(with_query(&http::destination_files_path(id), query)?)
404            .await
405    }
406
407    pub async fn destination_metrics(
408        &self,
409        id: DestinationId,
410    ) -> ClientResult<TableMetricsView, T::Error> {
411        self.get(http::destination_metrics_path(id)).await
412    }
413
414    /// `GET /agdx/projections`: list projections and their bindings, optionally
415    /// filtered.
416    pub async fn list_projections(
417        &self,
418        filter: &ProjectionListQuery,
419    ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
420        self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
421    }
422
423    /// `GET /agdx/schemas`: list registered schemas, optionally filtered.
424    pub async fn list_schemas(
425        &self,
426        filter: &SchemaListQuery,
427    ) -> ClientResult<Vec<SchemaInfo>, T::Error> {
428        self.get(with_query(http::SCHEMAS_PATH, filter)?).await
429    }
430
431    /// `POST /agdx/schemas`: register a schema, get its allocated id. `stream`
432    /// names the stream whose registry allocates it, `None` the
433    /// deployment-wide registry.
434    pub async fn register_schema(
435        &self,
436        source: SchemaSource,
437        name: Option<String>,
438        version: Option<u32>,
439        stream: Option<String>,
440    ) -> ClientResult<u32, T::Error> {
441        let body = http::RegisterSchemaBody {
442            source,
443            name,
444            version,
445            stream,
446        };
447        self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
448            .await
449    }
450
451    /// `GET /agdx/kv/{namespace}/{key}`: fetch one entry, or `None` on a 404.
452    /// The value rides the raw response body (no base64 inflation, symmetric with
453    /// `kv_set`), and the optional expiry rides the
454    /// [`KV_EXPIRES_AT_MICROS_HEADER`](http::KV_EXPIRES_AT_MICROS_HEADER) response
455    /// header.
456    pub async fn kv_get(
457        &self,
458        namespace: &str,
459        key: &[u8],
460    ) -> ClientResult<Option<KvValue>, T::Error> {
461        let path = http::kv_entry_path(namespace, &base64url_encode(key));
462        let response = self.dispatch(Method::Get, path, None).await?;
463        if response.status == 404 {
464            return Ok(None);
465        }
466        if !(200..300).contains(&response.status) {
467            return Err(api_error(&response));
468        }
469        let expires_at_micros = response
470            .header(http::KV_EXPIRES_AT_MICROS_HEADER)
471            .and_then(|value| value.parse::<u64>().ok());
472        Ok(Some(KvValue {
473            value: response.body,
474            expires_at_micros,
475        }))
476    }
477
478    /// `PUT /agdx/kv/{namespace}/{key}`: set a value, with an optional absolute
479    /// expiry (epoch microseconds).
480    pub async fn kv_set(
481        &self,
482        namespace: &str,
483        key: &[u8],
484        value: &[u8],
485        expires_at_micros: Option<u64>,
486    ) -> ClientResult<(), T::Error> {
487        self.kv_set_with(namespace, key, value, expires_at_micros, None)
488            .await
489    }
490
491    /// [`kv_set`](Self::kv_set) under the caller's `operation_id`. Re-send a
492    /// write whose outcome is unknown with the same id: the server answers the
493    /// recorded outcome instead of writing again.
494    pub async fn kv_set_as(
495        &self,
496        namespace: &str,
497        key: &[u8],
498        value: &[u8],
499        expires_at_micros: Option<u64>,
500        operation_id: NonZeroU128,
501    ) -> ClientResult<(), T::Error> {
502        self.kv_set_with(namespace, key, value, expires_at_micros, Some(operation_id))
503            .await
504    }
505
506    async fn kv_set_with(
507        &self,
508        namespace: &str,
509        key: &[u8],
510        value: &[u8],
511        expires_at_micros: Option<u64>,
512        operation_id: Option<NonZeroU128>,
513    ) -> ClientResult<(), T::Error> {
514        let path = with_query(
515            &http::kv_entry_path(namespace, &base64url_encode(key)),
516            &KvPutQuery { expires_at_micros },
517        )?;
518        let path = operation_route(path, operation_id)?;
519        // The key is base64url in the path, but the value rides the raw request
520        // body, so a large value carries no base64 inflation.
521        self.expect_ok(Method::Put, path, Some(value.to_vec()))
522            .await
523    }
524
525    /// `PUT /agdx/kv/{namespace}/{key}/cas`: a conditional write
526    /// (compare-and-swap). Applies `value` only if `expect` holds, returning the
527    /// new version on commit. A precondition miss surfaces as
528    /// `ClientError::Api` with `ResultCode::Conflict` (the response's
529    /// `ErrorBody.detail` carries the current version). Backend-gated by the
530    /// `kv_cas` capability: a deployment that does not serve it answers
531    /// unsupported.
532    pub async fn kv_cas(
533        &self,
534        namespace: &str,
535        key: &[u8],
536        value: &[u8],
537        expect: CasExpect,
538        expires_at_micros: Option<u64>,
539    ) -> ClientResult<u64, T::Error> {
540        self.kv_cas_with(namespace, key, value, expect, expires_at_micros, None)
541            .await
542    }
543
544    /// [`kv_cas`](Self::kv_cas) under the caller's `operation_id`. A re-send
545    /// with the same id answers the recorded commit instead of a conflict
546    /// against the version the first attempt wrote.
547    pub async fn kv_cas_as(
548        &self,
549        namespace: &str,
550        key: &[u8],
551        value: &[u8],
552        expect: CasExpect,
553        expires_at_micros: Option<u64>,
554        operation_id: NonZeroU128,
555    ) -> ClientResult<u64, T::Error> {
556        self.kv_cas_with(
557            namespace,
558            key,
559            value,
560            expect,
561            expires_at_micros,
562            Some(operation_id),
563        )
564        .await
565    }
566
567    async fn kv_cas_with(
568        &self,
569        namespace: &str,
570        key: &[u8],
571        value: &[u8],
572        expect: CasExpect,
573        expires_at_micros: Option<u64>,
574        operation_id: Option<NonZeroU128>,
575    ) -> ClientResult<u64, T::Error> {
576        let (expect_version, expect_absent) = match expect {
577            CasExpect::Match(version) => (Some(version), None),
578            CasExpect::Absent => (None, Some(true)),
579        };
580        let path = with_query(
581            &http::kv_cas_path(namespace, &base64url_encode(key)),
582            &KvCasQuery {
583                expect_version,
584                expect_absent,
585                expires_at_micros,
586            },
587        )?;
588        let path = operation_route(path, operation_id)?;
589        // The value rides the raw body like the plain PUT.
590        let response = self
591            .dispatch(Method::Put, path, Some(value.to_vec()))
592            .await?;
593        let view: CasCommittedView = decode_ok(&response)?;
594        Ok(view.version)
595    }
596
597    /// `DELETE /agdx/kv/{namespace}/{key}`: delete one entry, returning `true` if it existed.
598    pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
599        let path = http::kv_entry_path(namespace, &base64url_encode(key));
600        self.send_empty(Method::Delete, path).await
601    }
602
603    /// [`kv_delete`](Self::kv_delete) under the caller's `operation_id`.
604    pub async fn kv_delete_as(
605        &self,
606        namespace: &str,
607        key: &[u8],
608        operation_id: NonZeroU128,
609    ) -> ClientResult<bool, T::Error> {
610        let path = http::kv_entry_path(namespace, &base64url_encode(key));
611        self.send_empty(Method::Delete, with_operation(&path, operation_id)?)
612            .await
613    }
614
615    /// `GET /agdx/kv/{namespace}`: scan a page of entries.
616    pub async fn kv_scan(
617        &self,
618        namespace: &str,
619        filter: &KvScanQuery,
620    ) -> ClientResult<KvPageView, T::Error> {
621        self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
622            .await
623    }
624
625    /// `POST /agdx/forks`: create a fork.
626    pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
627        self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
628            .await
629    }
630
631    /// [`create_fork`](Self::create_fork) under the caller's `operation_id`.
632    pub async fn create_fork_as(
633        &self,
634        body: &ForkCreateBody,
635        operation_id: NonZeroU128,
636    ) -> ClientResult<ForkInfo, T::Error> {
637        self.send_json(
638            Method::Post,
639            with_operation(http::FORKS_PATH, operation_id)?,
640            body,
641        )
642        .await
643    }
644
645    /// `GET /agdx/forks`: list the caller's forks.
646    pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
647        self.get(http::FORKS_PATH.to_owned()).await
648    }
649
650    /// `GET /agdx/clients`: one page of live connections with their advertised
651    /// metadata, filtered and paginated per `query`. Follow `next_cursor` as the
652    /// next `after` to page.
653    pub async fn clients(
654        &self,
655        query: &ClientsQuery,
656    ) -> ClientResult<ClientMetadataListView, T::Error> {
657        self.get(with_query(http::CLIENTS_PATH, query)?).await
658    }
659
660    /// `GET /agdx/sessions/{stream}`: one page of the stream's sessions,
661    /// filtered and paged per `query`.
662    pub async fn list_sessions(
663        &self,
664        stream: &str,
665        query: &SessionsQuery,
666    ) -> ClientResult<crate::session::SessionPage, T::Error> {
667        self.get(with_query(&http::sessions_path(stream), query)?)
668            .await
669    }
670
671    /// `GET /agdx/sessions/{stream}/{id}`: one session's summary, or `None` on
672    /// a 404.
673    pub async fn session(
674        &self,
675        stream: &str,
676        id: crate::agent::ConversationId,
677    ) -> ClientResult<Option<crate::session::SessionInfo>, T::Error> {
678        self.get_optional(http::session_path(stream, id)).await
679    }
680
681    /// `GET /agdx/sessions/{stream}/{id}/events`: one page of a session's
682    /// events.
683    pub async fn session_events(
684        &self,
685        stream: &str,
686        id: crate::agent::ConversationId,
687        query: &SessionEventsQuery,
688    ) -> ClientResult<crate::session::SessionEventsPage, T::Error> {
689        self.get(with_query(&http::session_events_path(stream, id), query)?)
690            .await
691    }
692
693    /// `GET /agdx/sessions/{stream}/{id}/state`: a session's state document.
694    pub async fn session_state(
695        &self,
696        stream: &str,
697        id: crate::agent::ConversationId,
698        query: &SessionStateQuery,
699    ) -> ClientResult<crate::session::SessionStateView, T::Error> {
700        self.get(with_query(&http::session_state_path(stream, id), query)?)
701            .await
702    }
703
704    /// `GET /agdx/sessions/{stream}/{id}/links`: the resources a session
705    /// wrote, recalled, or touched.
706    pub async fn session_links(
707        &self,
708        stream: &str,
709        id: crate::agent::ConversationId,
710        query: &SessionLinksQuery,
711    ) -> ClientResult<crate::session::SessionLinksView, T::Error> {
712        self.get(with_query(&http::session_links_path(stream, id), query)?)
713            .await
714    }
715
716    /// `GET /agdx/sessions/{stream}/{id}/sources`: the source partitions that
717    /// hold a session's records.
718    pub async fn session_sources(
719        &self,
720        stream: &str,
721        id: crate::agent::ConversationId,
722    ) -> ClientResult<crate::session::SessionSources, T::Error> {
723        self.get(http::session_sources_path(stream, id)).await
724    }
725
726    /// `GET /agdx/sessions/{stream}/changes`: the stream's change rows after
727    /// `query.after`.
728    pub async fn session_changes(
729        &self,
730        stream: &str,
731        query: &SessionChangesQuery,
732    ) -> ClientResult<crate::session::SessionChanges, T::Error> {
733        self.get(with_query(&http::session_changes_path(stream), query)?)
734            .await
735    }
736
737    /// `GET /agdx/projections/{id}`: read one projection and its bindings, or
738    /// `None` on a 404.
739    pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
740        self.get_optional(http::projection_path(id)).await
741    }
742
743    /// `POST /agdx/projections`: register or replace a projection (a control
744    /// command, durable on the control topic).
745    pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
746        self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
747            .await
748    }
749
750    /// [`register_projection`](Self::register_projection) under the caller's
751    /// `operation_id`. A control write whose outcome is unknown (`Pending`) is
752    /// safe to re-send with the same id: the managed backend applies it once.
753    pub async fn register_projection_as(
754        &self,
755        projection: &Projection,
756        operation_id: NonZeroU128,
757    ) -> ClientResult<(), T::Error> {
758        let path = with_operation(http::PROJECTIONS_PATH, operation_id)?;
759        self.send_json_ok(Method::Post, path, projection).await
760    }
761
762    /// `DELETE /agdx/projections/{id}`: drop a projection.
763    pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
764        self.expect_ok(Method::Delete, http::projection_path(id), None)
765            .await
766    }
767
768    /// [`drop_projection`](Self::drop_projection) under the caller's
769    /// `operation_id`.
770    pub async fn drop_projection_as(
771        &self,
772        id: &str,
773        operation_id: NonZeroU128,
774    ) -> ClientResult<(), T::Error> {
775        let path = with_operation(&http::projection_path(id), operation_id)?;
776        self.expect_ok(Method::Delete, path, None).await
777    }
778
779    /// `POST /agdx/bindings`: apply (add or update) a binding.
780    pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
781        self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
782            .await
783    }
784
785    /// [`apply_binding`](Self::apply_binding) under the caller's
786    /// `operation_id`.
787    pub async fn apply_binding_as(
788        &self,
789        binding: &ProjectionBinding,
790        operation_id: NonZeroU128,
791    ) -> ClientResult<(), T::Error> {
792        let path = with_operation(http::BINDINGS_PATH, operation_id)?;
793        self.send_json_ok(Method::Post, path, binding).await
794    }
795
796    /// `DELETE /agdx/bindings`: remove a binding for a source, or one projection
797    /// from it when `projection_ref` is set.
798    pub async fn remove_binding(
799        &self,
800        source: &SourceSelector,
801        projection_ref: Option<String>,
802    ) -> ClientResult<(), T::Error> {
803        let body = RemoveBindingBody {
804            stream: source.stream.clone(),
805            topic: source.topic.clone(),
806            projection_ref,
807        };
808        self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
809            .await
810    }
811
812    /// [`remove_binding`](Self::remove_binding) under the caller's
813    /// `operation_id`.
814    pub async fn remove_binding_as(
815        &self,
816        source: &SourceSelector,
817        projection_ref: Option<String>,
818        operation_id: NonZeroU128,
819    ) -> ClientResult<(), T::Error> {
820        let body = RemoveBindingBody {
821            stream: source.stream.clone(),
822            topic: source.topic.clone(),
823            projection_ref,
824        };
825        let path = with_operation(http::BINDINGS_PATH, operation_id)?;
826        self.send_json_ok(Method::Delete, path, &body).await
827    }
828
829    /// `GET /agdx/schemas/{id}`: read one writer schema, or `None` on a 404.
830    /// `stream` names the stream whose registry holds the id, `None` the
831    /// deployment-wide registry.
832    pub async fn get_schema(
833        &self,
834        id: u32,
835        stream: Option<&str>,
836    ) -> ClientResult<Option<SchemaInfo>, T::Error> {
837        self.get_optional(schema_route(http::schema_path(id), stream)?)
838            .await
839    }
840
841    /// `DELETE /agdx/schemas/{id}`: drop (tombstone) a schema. `stream` names
842    /// the stream whose registry holds the id.
843    pub async fn drop_schema(&self, id: u32, stream: Option<&str>) -> ClientResult<(), T::Error> {
844        self.expect_ok(
845            Method::Delete,
846            schema_route(http::schema_path(id), stream)?,
847            None,
848        )
849        .await
850    }
851
852    /// [`drop_schema`](Self::drop_schema) under the caller's `operation_id`.
853    pub async fn drop_schema_as(
854        &self,
855        id: u32,
856        stream: Option<&str>,
857        operation_id: NonZeroU128,
858    ) -> ClientResult<(), T::Error> {
859        let path = schema_route(http::schema_path(id), stream)?;
860        self.expect_ok(Method::Delete, with_operation(&path, operation_id)?, None)
861            .await
862    }
863
864    /// `POST /agdx/schemas/{id}/decode`: decode a record body under the schema,
865    /// returning its JSON form, or `None` when the body does not decode under it.
866    /// `stream` names the stream whose registry holds the id.
867    pub async fn decode_record(
868        &self,
869        id: u32,
870        payload: &[u8],
871        stream: Option<&str>,
872    ) -> ClientResult<Option<serde_json::Value>, T::Error> {
873        let body = DecodeRecordBody {
874            payload: base64url_encode(payload),
875        };
876        self.send_json(
877            Method::Post,
878            schema_route(http::schema_decode_path(id), stream)?,
879            &body,
880        )
881        .await
882    }
883
884    /// `GET /agdx/filters`: list saved consumer filters, newest first.
885    pub async fn list_filters(
886        &self,
887        query: &FilterListQuery,
888    ) -> ClientResult<FilterPage, T::Error> {
889        self.get(with_query(http::FILTERS_PATH, query)?).await
890    }
891
892    /// `GET /agdx/filters/{id}`: one saved filter, or `None` on a 404.
893    pub async fn get_filter(&self, id: u32) -> ClientResult<Option<FilterDetail>, T::Error> {
894        self.get_optional(http::filter_path(id)).await
895    }
896
897    /// `GET /agdx/filters/{id}/revisions`: the filter's revisions, newest first.
898    pub async fn list_filter_revisions(
899        &self,
900        id: u32,
901        query: &FilterRevisionListQuery,
902    ) -> ClientResult<FilterRevisionPage, T::Error> {
903        self.get(with_query(&http::filter_revisions_path(id), query)?)
904            .await
905    }
906
907    /// `POST /agdx/filters/mutations`: apply one catalog mutation. The reply is
908    /// the authoritative outcome: applied, rejected (with the typed reason), or
909    /// pending (read it later with [`filter_operation`](Self::filter_operation)).
910    /// A retry with the same operation id returns the first outcome.
911    pub async fn mutate_filter(
912        &self,
913        request: &FilterMutationRequest,
914    ) -> ClientResult<FilterMutationOutcome, T::Error> {
915        let payload = serde_json::to_vec(request)
916            .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
917        let response = self
918            .dispatch(
919                Method::Post,
920                http::FILTER_MUTATIONS_PATH.to_owned(),
921                Some(payload),
922            )
923            .await?;
924        if let Ok(outcome) = serde_json::from_slice::<FilterMutationOutcome>(&response.body) {
925            return Ok(outcome);
926        }
927        decode_ok(&response)
928    }
929
930    /// `GET /agdx/filters/operations/{id}`: a mutation's outcome, or `None` when
931    /// the catalog has no record of it.
932    pub async fn filter_operation(
933        &self,
934        operation_id: u128,
935    ) -> ClientResult<Option<FilterMutationOutcome>, T::Error> {
936        self.get_optional(http::filter_operation_path(operation_id))
937            .await
938    }
939
940    /// `POST /agdx/filters/validate`: validate and compile a filter.
941    pub async fn validate_filter(
942        &self,
943        filter: &ConsumerFilter,
944    ) -> ClientResult<FilterValidation, T::Error> {
945        self.send_json(Method::Post, http::FILTER_VALIDATE_PATH.to_owned(), filter)
946            .await
947    }
948
949    /// `POST /agdx/filters/test`: evaluate a filter against one sample and
950    /// explain the verdict.
951    pub async fn test_filter(
952        &self,
953        body: &FilterTestBody,
954    ) -> ClientResult<FilterTestResult, T::Error> {
955        self.send_json(Method::Post, http::FILTER_TEST_PATH.to_owned(), body)
956            .await
957    }
958
959    /// `POST /agdx/filters/preview`: preview a filter over stored records of
960    /// one partition. Joins no group and stores no offset.
961    pub async fn preview_filter(
962        &self,
963        request: &FilterPreviewRequest,
964    ) -> ClientResult<FilterPreview, T::Error> {
965        self.send_json(Method::Post, http::FILTER_PREVIEW_PATH.to_owned(), request)
966            .await
967    }
968
969    /// `GET /agdx/filter-bindings`: list consumer-group filter bindings.
970    pub async fn list_filter_bindings(
971        &self,
972        query: &FilterBindingListQuery,
973    ) -> ClientResult<FilterBindingPage, T::Error> {
974        self.get(with_query(http::FILTER_BINDINGS_PATH, query)?)
975            .await
976    }
977
978    /// `GET /agdx/filter-bindings/{stream}/{topic}/{group}`: one group's
979    /// binding, or `None` when the group is not bound.
980    pub async fn get_filter_binding(
981        &self,
982        stream: &str,
983        topic: &str,
984        group: &str,
985    ) -> ClientResult<Option<FilterBinding>, T::Error> {
986        let response = self
987            .dispatch(
988                Method::Get,
989                http::filter_binding_path(stream, topic, group),
990                None,
991            )
992            .await?;
993        if response.status == 404 && response.body.is_empty() {
994            return Ok(None);
995        }
996        decode_ok(&response).map(Some)
997    }
998
999    /// `GET /agdx/kv`: list the caller's namespaces and their entry counts.
1000    pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
1001        self.get(http::KV_PATH.to_owned()).await
1002    }
1003
1004    /// `DELETE /agdx/kv/{namespace}`: bulk-delete entries matching the bounds
1005    /// (no bounds clears the namespace). Returns the number removed.
1006    pub async fn kv_delete_many(
1007        &self,
1008        namespace: &str,
1009        filter: &KvScanQuery,
1010    ) -> ClientResult<usize, T::Error> {
1011        let path = with_query(&http::kv_namespace_path(namespace), filter)?;
1012        let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
1013        Ok(view.deleted)
1014    }
1015
1016    /// [`kv_delete_many`](Self::kv_delete_many) under the caller's
1017    /// `operation_id`.
1018    pub async fn kv_delete_many_as(
1019        &self,
1020        namespace: &str,
1021        filter: &KvScanQuery,
1022        operation_id: NonZeroU128,
1023    ) -> ClientResult<usize, T::Error> {
1024        let path = with_query(&http::kv_namespace_path(namespace), filter)?;
1025        let view: DeletedManyView = self
1026            .send_empty(Method::Delete, with_operation(&path, operation_id)?)
1027            .await?;
1028        Ok(view.deleted)
1029    }
1030
1031    /// `POST /agdx/forks/{id}/promote`: promote a fork's rows onto the trunk.
1032    /// Returns the number of rows applied.
1033    pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
1034        let view: PromotedView = self
1035            .send_empty(Method::Post, http::fork_promote_path(id))
1036            .await?;
1037        Ok(view.rows)
1038    }
1039
1040    /// [`promote_fork`](Self::promote_fork) under the caller's `operation_id`.
1041    pub async fn promote_fork_as(
1042        &self,
1043        id: &str,
1044        operation_id: NonZeroU128,
1045    ) -> ClientResult<usize, T::Error> {
1046        let path = with_operation(&http::fork_promote_path(id), operation_id)?;
1047        let view: PromotedView = self.send_empty(Method::Post, path).await?;
1048        Ok(view.rows)
1049    }
1050
1051    /// `DELETE /agdx/forks/{id}`: squash (discard) a fork.
1052    pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
1053        self.expect_ok(Method::Delete, http::fork_path(id), None)
1054            .await
1055    }
1056
1057    /// [`delete_fork`](Self::delete_fork) under the caller's `operation_id`.
1058    pub async fn delete_fork_as(
1059        &self,
1060        id: &str,
1061        operation_id: NonZeroU128,
1062    ) -> ClientResult<(), T::Error> {
1063        let path = with_operation(&http::fork_path(id), operation_id)?;
1064        self.expect_ok(Method::Delete, path, None).await
1065    }
1066
1067    /// `PUT /agdx/forks/{id}/rows`: write one speculative row into a fork.
1068    pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
1069        self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
1070            .await
1071    }
1072
1073    /// [`put_fork_row`](Self::put_fork_row) under the caller's `operation_id`.
1074    pub async fn put_fork_row_as(
1075        &self,
1076        id: &str,
1077        body: &ForkPutBody,
1078        operation_id: NonZeroU128,
1079    ) -> ClientResult<(), T::Error> {
1080        let path = with_operation(&http::fork_rows_path(id), operation_id)?;
1081        self.send_json_ok(Method::Put, path, body).await
1082    }
1083
1084    /// `POST /agdx/graph/{name}/query`: run a traversal over a named graph, get
1085    /// back the reachable nodes and traversed edges. Backend-gated by the `graph`
1086    /// capability: a deployment without a graph backend answers unsupported.
1087    pub async fn graph_query(
1088        &self,
1089        name: &str,
1090        query: &GraphQuery,
1091    ) -> ClientResult<GraphResultView, T::Error> {
1092        self.send_json(Method::Post, http::graph_query_path(name), query)
1093            .await
1094    }
1095
1096    /// `GET /agdx/graph/{name}/neighbors/{node}`: the neighbor read, the cheap
1097    /// common traversal. `node` is the Crockford-base32 node id. `query` carries
1098    /// the direction, an optional edge-type filter, the hop depth, and a limit (a
1099    /// default `query` reads one hop outward with no filter).
1100    pub async fn graph_neighbors(
1101        &self,
1102        name: &str,
1103        node: &str,
1104        query: &GraphNeighborsQuery,
1105    ) -> ClientResult<GraphResultView, T::Error> {
1106        self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
1107            .await
1108    }
1109
1110    /// `GET /agdx/graphs`: list the registered graph projections, the discovery
1111    /// surface a graph explorer reads to offer the available graphs. Reuses the
1112    /// projection-list filter, narrowed to graph-kind projections server-side.
1113    pub async fn list_graphs(
1114        &self,
1115        filter: &ProjectionListQuery,
1116    ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
1117        self.get(with_query(http::GRAPHS_PATH, filter)?).await
1118    }
1119
1120    /// `POST /agdx/graphs`: register a graph projection (a [`Projection`] with
1121    /// `kind = Graph` and an entity schema). Applied asynchronously, like every
1122    /// control command.
1123    pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
1124        self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
1125            .await
1126    }
1127
1128    /// [`register_graph`](Self::register_graph) under the caller's
1129    /// `operation_id`.
1130    pub async fn register_graph_as(
1131        &self,
1132        projection: &Projection,
1133        operation_id: NonZeroU128,
1134    ) -> ClientResult<(), T::Error> {
1135        let path = with_operation(http::GRAPHS_PATH, operation_id)?;
1136        self.send_json_ok(Method::Post, path, projection).await
1137    }
1138
1139    /// `GET /agdx/graphs/{id}`: read one graph projection by id, or `None` when no
1140    /// graph projection has it.
1141    pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
1142        self.get_optional(http::graph_path(id)).await
1143    }
1144
1145    /// `DELETE /agdx/graphs/{id}`: drop the graph projection registered under
1146    /// `id`. The materialized nodes and edges are left untouched.
1147    pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
1148        self.expect_ok(Method::Delete, http::graph_path(id), None)
1149            .await
1150    }
1151
1152    /// [`drop_graph`](Self::drop_graph) under the caller's `operation_id`.
1153    pub async fn drop_graph_as(
1154        &self,
1155        id: &str,
1156        operation_id: NonZeroU128,
1157    ) -> ClientResult<(), T::Error> {
1158        let path = with_operation(&http::graph_path(id), operation_id)?;
1159        self.expect_ok(Method::Delete, path, None).await
1160    }
1161
1162    /// `GET /agdx/authz/whoami`: the caller's own bound roles and effective
1163    /// grants. Backend-gated by the `authz` capability: a deployment that does
1164    /// not serve the authorization band answers unsupported.
1165    pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
1166        self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
1167    }
1168
1169    /// `GET /agdx/authz/roles`: every defined role with its full grant set.
1170    pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
1171        self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
1172    }
1173
1174    /// `GET /agdx/authz/roles/{name}`: one role by name, or `None` on a 404.
1175    pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
1176        self.get_optional(http::authz_role_path(name)).await
1177    }
1178
1179    /// `PUT /agdx/authz/roles/{name}`: define or replace a role. The path name is
1180    /// authoritative, so `role.name` must match it.
1181    pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
1182        self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
1183            .await
1184    }
1185
1186    /// `DELETE /agdx/authz/roles/{name}`: delete a role.
1187    pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
1188        self.expect_ok(Method::Delete, http::authz_role_path(name), None)
1189            .await
1190    }
1191
1192    /// `GET /agdx/authz/users/{id}/roles`: one user's bound role names.
1193    pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
1194        self.get(http::authz_user_roles_path(user_id)).await
1195    }
1196
1197    /// `PUT /agdx/authz/users/{id}/roles`: replace the user's whole role set.
1198    pub async fn bind_user_roles(
1199        &self,
1200        user_id: u32,
1201        roles: &[String],
1202    ) -> ClientResult<(), T::Error> {
1203        self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
1204            .await
1205    }
1206
1207    async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
1208        let response = self.dispatch(Method::Get, path, None).await?;
1209        decode_ok(&response)
1210    }
1211
1212    /// A `GET` whose 404 means "absent" rather than an error.
1213    async fn get_optional<R: DeserializeOwned>(
1214        &self,
1215        path: String,
1216    ) -> ClientResult<Option<R>, T::Error> {
1217        let response = self.dispatch(Method::Get, path, None).await?;
1218        if response.status == 404 {
1219            return Ok(None);
1220        }
1221        decode_ok(&response).map(Some)
1222    }
1223
1224    async fn send_json<B: Serialize, R: DeserializeOwned>(
1225        &self,
1226        method: Method,
1227        path: String,
1228        body: &B,
1229    ) -> ClientResult<R, T::Error> {
1230        let payload = serde_json::to_vec(body)
1231            .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1232        let response = self.dispatch(method, path, Some(payload)).await?;
1233        decode_ok(&response)
1234    }
1235
1236    async fn send_empty<R: DeserializeOwned>(
1237        &self,
1238        method: Method,
1239        path: String,
1240    ) -> ClientResult<R, T::Error> {
1241        let response = self.dispatch(method, path, None).await?;
1242        decode_ok(&response)
1243    }
1244
1245    /// Send a JSON body and only check the status, for a control route whose 2xx
1246    /// body is empty.
1247    async fn send_json_ok<B: Serialize>(
1248        &self,
1249        method: Method,
1250        path: String,
1251        body: &B,
1252    ) -> ClientResult<(), T::Error> {
1253        let payload = serde_json::to_vec(body)
1254            .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
1255        let response = self.dispatch(method, path, Some(payload)).await?;
1256        check_status(&response)
1257    }
1258
1259    async fn expect_ok(
1260        &self,
1261        method: Method,
1262        path: String,
1263        body: Option<Vec<u8>>,
1264    ) -> ClientResult<(), T::Error> {
1265        let response = self.dispatch(method, path, body).await?;
1266        check_status(&response)
1267    }
1268
1269    async fn dispatch(
1270        &self,
1271        method: Method,
1272        path: String,
1273        body: Option<Vec<u8>>,
1274    ) -> ClientResult<HttpResponse, T::Error> {
1275        self.transport
1276            .send(HttpRequest { method, path, body })
1277            .await
1278            .map_err(ClientError::Transport)
1279    }
1280}
1281
1282/// Append `?<urlencoded>` to a path when `params` serializes to a non-empty
1283/// query string, else return the path unchanged.
1284fn schema_route<E>(path: String, stream: Option<&str>) -> Result<String, ClientError<E>> {
1285    with_query(
1286        &path,
1287        &http::SchemaQuery {
1288            stream: stream.map(str::to_owned),
1289        },
1290    )
1291}
1292
1293/// Append `params` to `path`, which may already carry a query string.
1294fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
1295    let query = serde_urlencoded::to_string(params)
1296        .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
1297    if query.is_empty() {
1298        Ok(path.to_owned())
1299    } else if path.contains('?') {
1300        Ok(format!("{path}&{query}"))
1301    } else {
1302        Ok(format!("{path}?{query}"))
1303    }
1304}
1305
1306fn with_operation<E>(path: &str, operation_id: NonZeroU128) -> Result<String, ClientError<E>> {
1307    with_query(path, &OperationQuery::from(operation_id))
1308}
1309
1310fn operation_route<E>(
1311    path: String,
1312    operation_id: Option<NonZeroU128>,
1313) -> Result<String, ClientError<E>> {
1314    match operation_id {
1315        Some(operation_id) => with_operation(&path, operation_id),
1316        None => Ok(path),
1317    }
1318}
1319
1320/// On a 2xx, decode the bare `Ok` payload, otherwise turn the body into a typed
1321/// [`ClientError::Api`], or a [`ClientError::Status`] classified from the status
1322/// when the error body itself does not decode.
1323fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
1324    if (200..300).contains(&response.status) {
1325        serde_json::from_slice(&response.body)
1326            .map_err(|error| ClientError::Decode(format!("response body: {error}")))
1327    } else {
1328        Err(api_error(response))
1329    }
1330}
1331
1332/// Like [`decode_ok`] but for a route whose 2xx body is empty.
1333fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
1334    if (200..300).contains(&response.status) {
1335        Ok(())
1336    } else {
1337        Err(api_error(response))
1338    }
1339}
1340
1341fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
1342    match serde_json::from_slice::<ErrorBody>(&response.body) {
1343        Ok(body) => ClientError::Api(body),
1344        Err(_) => ClientError::Status {
1345            status: response.status,
1346            body: ErrorBody::new(
1347                code_for_status(response.status),
1348                String::from_utf8_lossy(&response.body).into_owned(),
1349            ),
1350        },
1351    }
1352}
1353
1354/// A best-effort classification for an `ErrorBody`-less failure response,
1355/// inferred from the HTTP status alone. Used only as the fallback when the body
1356/// did not carry a [`ResultCode`].
1357fn code_for_status(status: u16) -> ResultCode {
1358    match status {
1359        404 => ResultCode::NotFound,
1360        400 => ResultCode::InvalidArgument,
1361        401 => ResultCode::Unauthenticated,
1362        403 => ResultCode::Forbidden,
1363        409 => ResultCode::Conflict,
1364        413 => ResultCode::TooLarge,
1365        501 => ResultCode::Unsupported,
1366        503 => ResultCode::Stale,
1367        _ => ResultCode::Backend,
1368    }
1369}
1370
1371// base64url (unpadded, RFC 4648 section 5), the encoding this surface uses for
1372// every binary value. Hand-rolled to keep the portable graph dependency-free.
1373
1374const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1375
1376/// Encode bytes as URL-safe unpadded base64 (RFC 4648 ยง5, no `=`).
1377pub fn base64url_encode(input: &[u8]) -> String {
1378    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1379    for chunk in input.chunks(3) {
1380        let b0 = chunk[0] as usize;
1381        out.push(B64URL[b0 >> 2] as char);
1382        match chunk.len() {
1383            1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
1384            2 => {
1385                let b1 = chunk[1] as usize;
1386                out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1387                out.push(B64URL[(b1 & 0b1111) << 2] as char);
1388            }
1389            _ => {
1390                let b1 = chunk[1] as usize;
1391                let b2 = chunk[2] as usize;
1392                out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
1393                out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
1394                out.push(B64URL[b2 & 0b111111] as char);
1395            }
1396        }
1397    }
1398    out
1399}
1400
1401/// Decode URL-safe unpadded base64. `None` on any non-alphabet byte or an
1402/// impossible length (a single trailing char carries no whole byte).
1403pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
1404    fn val(byte: u8) -> Option<u8> {
1405        match byte {
1406            b'A'..=b'Z' => Some(byte - b'A'),
1407            b'a'..=b'z' => Some(byte - b'a' + 26),
1408            b'0'..=b'9' => Some(byte - b'0' + 52),
1409            b'-' => Some(62),
1410            b'_' => Some(63),
1411            _ => None,
1412        }
1413    }
1414    let bytes = input.as_bytes();
1415    if bytes.len() % 4 == 1 {
1416        return None;
1417    }
1418    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1419    for chunk in bytes.chunks(4) {
1420        let mut acc = 0u32;
1421        for &byte in chunk {
1422            acc = (acc << 6) | u32::from(val(byte)?);
1423        }
1424        // Left-align the accumulated bits for a short final chunk.
1425        acc <<= 6 * (4 - chunk.len());
1426        match chunk.len() {
1427            2 => out.push((acc >> 16) as u8),
1428            3 => {
1429                out.push((acc >> 16) as u8);
1430                out.push((acc >> 8) as u8);
1431            }
1432            _ => {
1433                out.push((acc >> 16) as u8);
1434                out.push((acc >> 8) as u8);
1435                out.push(acc as u8);
1436            }
1437        }
1438    }
1439    Some(out)
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445    use crate::agent::ConversationId;
1446
1447    #[test]
1448    fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
1449        for case in [
1450            &b""[..],
1451            &b"f"[..],
1452            &b"fo"[..],
1453            &b"foo"[..],
1454            &b"foob"[..],
1455            &b"fooba"[..],
1456            &b"foobar"[..],
1457            &[0x00, 0xff, 0x10, 0x80][..],
1458        ] {
1459            let encoded = base64url_encode(case);
1460            assert!(
1461                !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1462                "url-safe unpadded: {encoded}"
1463            );
1464            assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1465        }
1466    }
1467
1468    #[test]
1469    fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1470        assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1471        assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1472    }
1473
1474    #[test]
1475    fn given_checkpoint_reads_when_paths_are_built_then_should_require_the_selected_consistency() {
1476        let single = with_query::<(), _>(
1477            &http::destination_status_path(DestinationId::from_u128(1)),
1478            &CheckpointReadQuery::new(CheckpointReadConsistency::Linearizable),
1479        )
1480        .expect("single checkpoint read query encodes");
1481        assert!(single.ends_with("?consistency=linearizable"));
1482
1483        let list = with_query::<(), _>(
1484            http::DESTINATIONS_PATH,
1485            &DestinationListQuery {
1486                source_stream: None,
1487                source_topic: None,
1488                name_contains: None,
1489                after: None,
1490                limit: None,
1491                consistency: CheckpointReadConsistency::PotentiallyStale,
1492            },
1493        )
1494        .expect("checkpoint list query encodes");
1495        assert!(list.ends_with("?consistency=potentially_stale"));
1496    }
1497
1498    #[test]
1499    fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1500        assert!(
1501            base64url_decode("====").is_none(),
1502            "padding is not alphabet"
1503        );
1504        assert!(
1505            base64url_decode("A").is_none(),
1506            "a lone char carries no byte"
1507        );
1508        assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1509    }
1510
1511    // A transport that replays a canned response, so the typed methods are
1512    // testable without any IO.
1513    struct CannedTransport {
1514        response: HttpResponse,
1515    }
1516
1517    impl Transport for CannedTransport {
1518        type Error = std::convert::Infallible;
1519        async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1520            Ok(self.response.clone())
1521        }
1522    }
1523
1524    // Answers `202` with an empty body and keeps every request it was asked to
1525    // send, so a test can read the exact path a method built.
1526    #[derive(Default)]
1527    struct RecordingTransport {
1528        requests: std::cell::RefCell<Vec<HttpRequest>>,
1529    }
1530
1531    impl Transport for RecordingTransport {
1532        type Error = std::convert::Infallible;
1533        async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1534            self.requests.borrow_mut().push(request);
1535            Ok(HttpResponse::new(202, Vec::new()))
1536        }
1537    }
1538
1539    fn block_on<F: core::future::Future>(future: F) -> F::Output {
1540        // A minimal executor: these futures never yield (the canned transport
1541        // is ready immediately), so a busy poll with the no-op waker resolves
1542        // them. No `unsafe`, so the crate's `forbid(unsafe_code)` holds.
1543        use core::task::{Context, Poll, Waker};
1544        let mut context = Context::from_waker(Waker::noop());
1545        let mut future = core::pin::pin!(future);
1546        loop {
1547            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1548                return output;
1549            }
1550        }
1551    }
1552
1553    #[test]
1554    fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1555        let body = serde_json::to_vec(&Capabilities::new(
1556            true,
1557            crate::hello::OpVersions::new(1, 1, 1, 1),
1558        ))
1559        .unwrap();
1560        let client = HttpClient::new(CannedTransport {
1561            response: HttpResponse::new(200, body),
1562        });
1563        let caps = block_on(client.capabilities()).expect("decodes");
1564        assert!(caps.managed && !caps.kv.cas);
1565    }
1566
1567    #[test]
1568    fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1569        let body =
1570            serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1571        let client = HttpClient::new(CannedTransport {
1572            response: HttpResponse::new(404, body),
1573        });
1574        let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1575        assert_eq!(error.code(), Some(ResultCode::NotFound));
1576    }
1577
1578    #[test]
1579    fn given_a_missing_binding_when_fetched_then_should_distinguish_absence_from_missing_source() {
1580        let absent = HttpClient::new(CannedTransport {
1581            response: HttpResponse::new(404, Vec::new()),
1582        });
1583        assert!(
1584            block_on(absent.get_filter_binding("stream", "topic", "group"))
1585                .unwrap()
1586                .is_none()
1587        );
1588        let body = serde_json::to_vec(&ErrorBody::new(
1589            ResultCode::NotFound,
1590            "topic does not exist",
1591        ))
1592        .unwrap();
1593        let missing_source = HttpClient::new(CannedTransport {
1594            response: HttpResponse::new(404, body),
1595        });
1596        let error =
1597            block_on(missing_source.get_filter_binding("stream", "topic", "group")).unwrap_err();
1598        assert_eq!(error.code(), Some(ResultCode::NotFound));
1599    }
1600
1601    #[test]
1602    fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
1603        let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1604        let client = HttpClient::new(CannedTransport {
1605            response: HttpResponse::new(404, body),
1606        });
1607        let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1608        assert!(entry.is_none());
1609    }
1610
1611    #[test]
1612    fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
1613        let response = HttpResponse::new(200, b"world".to_vec())
1614            .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
1615        let client = HttpClient::new(CannedTransport { response });
1616        let entry = block_on(client.kv_get("sessions", b"user:1"))
1617            .expect("decodes")
1618            .expect("present");
1619        assert_eq!(entry.value, b"world");
1620        assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1621    }
1622
1623    #[test]
1624    fn given_a_missing_projection_when_fetched_then_should_be_none() {
1625        let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1626        let client = HttpClient::new(CannedTransport {
1627            response: HttpResponse::new(404, body),
1628        });
1629        let info = block_on(client.get_projection("reading.v1")).expect("404 maps to None");
1630        assert!(info.is_none());
1631    }
1632
1633    #[test]
1634    fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1635        let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1636        let client = HttpClient::new(CannedTransport {
1637            response: HttpResponse::new(200, body),
1638        });
1639        let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1640            .expect("decodes the count");
1641        assert_eq!(removed, 7);
1642    }
1643
1644    #[test]
1645    fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1646        let client = HttpClient::new(CannedTransport {
1647            response: HttpResponse::new(204, Vec::new()),
1648        });
1649        block_on(client.drop_projection("reading.v1")).expect("a 204 is a success");
1650    }
1651
1652    #[test]
1653    fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1654        let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1655        let client = HttpClient::new(CannedTransport {
1656            response: HttpResponse::new(200, body),
1657        });
1658        let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1659            .expect("a commit returns the new version");
1660        assert_eq!(version, 4);
1661    }
1662
1663    #[test]
1664    fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1665        let reply = WhoamiReply {
1666            v: 1,
1667            roles: vec!["admin".to_owned()],
1668            grants: vec![crate::authz::Grant {
1669                effect: crate::authz::Effect::Allow,
1670                feature: crate::authz::Feature::Authz,
1671                action: crate::authz::Action::Admin,
1672                resource: crate::authz::ResourcePattern::all(),
1673            }],
1674        };
1675        let body = serde_json::to_vec(&reply).unwrap();
1676        let client = HttpClient::new(CannedTransport {
1677            response: HttpResponse::new(200, body),
1678        });
1679        let whoami = block_on(client.authz_whoami()).expect("decodes");
1680        assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1681        assert_eq!(whoami.grants.len(), 1);
1682    }
1683
1684    #[test]
1685    fn given_a_missing_role_when_fetched_then_should_be_none() {
1686        let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1687        let client = HttpClient::new(CannedTransport {
1688            response: HttpResponse::new(404, body),
1689        });
1690        let role = block_on(client.get_role("ghost")).expect("404 maps to None");
1691        assert!(role.is_none());
1692    }
1693
1694    #[test]
1695    fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1696        let body = serde_json::to_vec(
1697            &ErrorBody::new(ResultCode::Conflict, "version conflict")
1698                .with_detail(serde_json::json!({ "current": 3 })),
1699        )
1700        .unwrap();
1701        let client = HttpClient::new(CannedTransport {
1702            response: HttpResponse::new(409, body),
1703        });
1704        let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1705            .expect_err("a precondition miss is an error");
1706        assert_eq!(error.code(), Some(ResultCode::Conflict));
1707    }
1708
1709    #[test]
1710    fn given_a_caller_operation_id_when_writing_the_registry_then_should_ride_the_query_string() {
1711        let operation_id = NonZeroU128::new(42).expect("non-zero");
1712        let client = HttpClient::new(RecordingTransport::default());
1713        let projection = Projection::builder("reading.v1").build();
1714        let source = SourceSelector::new("telemetry", "events");
1715
1716        block_on(client.register_projection_as(&projection, operation_id)).expect("register");
1717        block_on(client.drop_projection_as("reading.v1", operation_id)).expect("drop");
1718        block_on(client.remove_binding_as(&source, None, operation_id)).expect("remove");
1719        block_on(client.drop_schema_as(7, Some("telemetry"), operation_id)).expect("drop schema");
1720        block_on(client.drop_schema_as(7, None, operation_id)).expect("drop shared schema");
1721        block_on(client.register_graph_as(&projection, operation_id)).expect("register graph");
1722        block_on(client.drop_graph_as("topology.v1", operation_id)).expect("drop graph");
1723
1724        let paths: Vec<String> = client
1725            .transport()
1726            .requests
1727            .borrow()
1728            .iter()
1729            .map(|request| request.path.clone())
1730            .collect();
1731        assert_eq!(
1732            paths,
1733            [
1734                "/agdx/projections?operation_id=42",
1735                "/agdx/projections/reading.v1?operation_id=42",
1736                "/agdx/bindings?operation_id=42",
1737                "/agdx/schemas/7?stream=telemetry&operation_id=42",
1738                "/agdx/schemas/7?operation_id=42",
1739                "/agdx/graphs?operation_id=42",
1740                "/agdx/graphs/topology.v1?operation_id=42",
1741            ]
1742        );
1743    }
1744
1745    #[test]
1746    fn given_a_caller_operation_id_when_writing_key_values_then_should_follow_the_other_parameters()
1747    {
1748        let operation_id = NonZeroU128::new(7).expect("non-zero");
1749        let client = HttpClient::new(RecordingTransport::default());
1750
1751        let _ = block_on(client.kv_set_as("notes", b"k", b"v", Some(10), operation_id));
1752        let _ =
1753            block_on(client.kv_cas_as("notes", b"k", b"v", CasExpect::Absent, None, operation_id));
1754        let _ = block_on(client.kv_delete_as("notes", b"k", operation_id));
1755        let _ = block_on(client.delete_fork_as("f1", operation_id));
1756
1757        let requests = client.transport().requests.borrow();
1758        assert_eq!(
1759            requests[0].path,
1760            "/agdx/kv/notes/aw?expires_at_micros=10&operation_id=7"
1761        );
1762        assert_eq!(
1763            requests[1].path,
1764            "/agdx/kv/notes/aw/cas?expect_absent=true&operation_id=7"
1765        );
1766        assert_eq!(requests[2].path, "/agdx/kv/notes/aw?operation_id=7");
1767        assert_eq!(requests[3].path, "/agdx/forks/f1?operation_id=7");
1768    }
1769
1770    #[test]
1771    fn given_no_operation_id_when_writing_then_should_send_the_bare_path() {
1772        let client = HttpClient::new(RecordingTransport::default());
1773        block_on(client.drop_projection("reading.v1")).expect("drop");
1774        block_on(client.drop_schema(7, Some("telemetry"))).expect("drop schema");
1775        let requests = client.transport().requests.borrow();
1776        assert_eq!(requests[0].path, "/agdx/projections/reading.v1");
1777        assert_eq!(requests[1].path, "/agdx/schemas/7?stream=telemetry");
1778    }
1779
1780    #[test]
1781    fn given_a_plain_text_gateway_reply_when_read_then_should_keep_its_status() {
1782        for (status, text) in [
1783            (502, "bad gateway: upstream unreachable"),
1784            (504, "gateway timeout: the request may still complete"),
1785        ] {
1786            let client = HttpClient::new(CannedTransport {
1787                response: HttpResponse::new(status, text.as_bytes().to_vec()),
1788            });
1789            let error = block_on(client.list_sessions("agents", &SessionsQuery::default()))
1790                .expect_err("a gateway failure is an error");
1791            assert_eq!(error.code(), Some(ResultCode::Backend));
1792            assert_eq!(
1793                error.to_string(),
1794                format!("http status {status} (Backend): {text}")
1795            );
1796            let ClientError::Status { status: kept, body } = error else {
1797                panic!("a body that is not an ErrorBody is a status error: {error:?}");
1798            };
1799            assert_eq!(
1800                (kept, body),
1801                (status, ErrorBody::new(ResultCode::Backend, text))
1802            );
1803        }
1804    }
1805
1806    #[test]
1807    fn given_a_typed_backend_error_with_status_502_when_read_then_should_stay_an_api_error() {
1808        let typed = ErrorBody::new(ResultCode::Backend, "session backend error: store closed");
1809        let client = HttpClient::new(CannedTransport {
1810            response: HttpResponse::new(
1811                502,
1812                serde_json::to_vec(&typed).expect("error body encodes"),
1813            ),
1814        });
1815        let error = block_on(client.list_sessions("agents", &SessionsQuery::default()))
1816            .expect_err("a typed backend error is an error");
1817        assert_eq!(error.code(), Some(ResultCode::Backend));
1818        let ClientError::Api(body) = error else {
1819            panic!("a decodable ErrorBody is an api error: {error:?}");
1820        };
1821        assert_eq!(body, typed);
1822    }
1823
1824    #[test]
1825    fn given_a_missing_session_when_read_then_should_be_none_for_any_404_body() {
1826        let typed = ErrorBody::new(ResultCode::NotFound, "session not found");
1827        for body in [
1828            serde_json::to_vec(&typed).expect("error body encodes"),
1829            b"not found".to_vec(),
1830            Vec::new(),
1831        ] {
1832            let client = HttpClient::new(CannedTransport {
1833                response: HttpResponse::new(404, body),
1834            });
1835            let session = block_on(client.session("agents", ConversationId::from_u128(2)))
1836                .expect("a 404 maps to None");
1837            assert_eq!(session, None);
1838        }
1839    }
1840}