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