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