Skip to main content

laser_wire/
http_client.rs

1// A typed client for the `/agdx/*` HTTP surface (feature `http-client`). Every
2// browser UI and native tool otherwise re-implements the same glue: route
3// strings, base64url of binary KV keys, query-string composition, and unwrapping
4// the bare-`Ok`-or-`ErrorBody` reply contract. This owns all of it once, so a
5// consumer writes `client.kv_get(ns, key).await?` and gets a typed value or a
6// typed [`ClientError`] carrying the [`ResultCode`].
7//
8// The crate stays runtime-free: the caller injects the actual IO by
9// implementing [`Transport`] over `gloo-net` (wasm) or `reqwest` (native). This
10// is the crate's one async surface, and it is runtime-agnostic: an `async fn`
11// is just a `Future`, with no executor dependency, so it still compiles for
12// `wasm32-unknown-unknown`.
13
14use crate::agent_workflow::{AgentRunInfo, AgentSubmit};
15use crate::authz::{Role, WhoamiReply};
16use crate::browse::{ProjectionInfo, SchemaInfo};
17use crate::checkpoint::CheckpointReadConsistency;
18use crate::control::{Projection, ProjectionBinding, SchemaSource, SourceSelector};
19use crate::destination::{DestinationId, DestinationOperationId};
20use crate::fork::ForkInfo;
21use crate::graph::GraphQuery;
22use crate::http::{
23    self, AcceptedOperationView, Capabilities, CasCommittedView, CheckpointReadQuery,
24    ClientMetadataListView, ClientsQuery, DecodeRecordBody, DeletedManyView, DestinationIssueView,
25    DestinationListQuery, DestinationMutationBody, DestinationPageView, DestinationView, ErrorBody,
26    ForkCreateBody, ForkPutBody, GraphNeighborsQuery, GraphResultView, KvCasQuery, KvPageView,
27    KvPutQuery, KvScanQuery, ProjectionListQuery, PromotedView, QueryExecutionView, QueryPageBody,
28    QueryRouteListQuery, QueryRoutePageView, RemoveBindingBody, RunPageView, RunsQuery,
29    SchemaListQuery, SnapshotListQuery, SnapshotPageView, TableFileListQuery, TableFilePageView,
30    TableMetricsView, TableSchemaView, TableSnapshotView, TableView,
31};
32use crate::kv::{CasExpect, KvNamespaceInfo};
33use crate::query::{Query, QueryResult};
34use crate::result::ResultCode;
35use serde::Serialize;
36use serde::de::DeserializeOwned;
37
38/// The HTTP verb a [`Transport`] must perform.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr)]
40#[strum(serialize_all = "UPPERCASE")]
41pub enum Method {
42    Get,
43    Post,
44    Put,
45    Delete,
46}
47
48impl Method {
49    /// The uppercase method token (`"GET"`, ...).
50    pub fn as_str(self) -> &'static str {
51        self.into()
52    }
53}
54
55/// A request the [`Transport`] performs. `path` is already the full path plus
56/// query string (e.g. `/agdx/kv/sessions/dXNlcjox?expires_at_micros=10`). The
57/// transport prepends its own base URL. A JSON `body` is present only on
58/// `POST`/`PUT`, and when present the transport sends `Content-Type:
59/// application/json`.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct HttpRequest {
62    pub method: Method,
63    pub path: String,
64    pub body: Option<Vec<u8>>,
65}
66
67/// What the [`Transport`] returns: the numeric status, the response headers (for
68/// the routes that carry metadata out of band, like the KV get expiry), and the
69/// raw body. A transport that does not surface headers leaves `headers` empty,
70/// in which case header-carried metadata reads as absent.
71#[derive(Clone, Debug, PartialEq, Eq, Default)]
72pub struct HttpResponse {
73    pub status: u16,
74    pub headers: Vec<(String, String)>,
75    pub body: Vec<u8>,
76}
77
78impl HttpResponse {
79    /// A response with no headers.
80    pub fn new(status: u16, body: Vec<u8>) -> Self {
81        Self {
82            status,
83            headers: Vec::new(),
84            body,
85        }
86    }
87
88    /// Builder helper: attach a header (used by transports and tests).
89    #[must_use]
90    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
91        self.headers.push((name.into(), value.into()));
92        self
93    }
94
95    /// The first value of header `name`, matched case-insensitively (HTTP header
96    /// names are case-insensitive). `None` if absent.
97    pub fn header(&self, name: &str) -> Option<&str> {
98        self.headers
99            .iter()
100            .find(|(key, _)| key.eq_ignore_ascii_case(name))
101            .map(|(_, value)| value.as_str())
102    }
103}
104
105/// The result of a single-key KV read: the raw value bytes plus the optional
106/// absolute expiry (epoch microseconds). A `kv_get` builds it from the raw
107/// response body and the [`KV_EXPIRES_AT_MICROS_HEADER`](http::KV_EXPIRES_AT_MICROS_HEADER)
108/// header. A scan page carries the base64url [`KvEntryView`](http::KvEntryView)
109/// shape instead, since a JSON array cannot hold raw bytes.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct KvValue {
112    pub value: Vec<u8>,
113    pub expires_at_micros: Option<u64>,
114}
115
116/// The IO seam. A wasm consumer implements it over `gloo-net`, a native one over
117/// `reqwest`. The client never constructs URLs beyond the path: the transport
118/// owns the base URL, credentials, and headers.
119///
120/// Authentication and 401 refresh-retry live here, in the transport, not in the
121/// client. A transport attaches the credential to every request, and on a 401 it
122/// may refresh the token and retry once before returning the response. This is
123/// the injection point a real UI needs, and it keeps the client itself
124/// auth-agnostic.
125///
126/// The trait uses an `async fn` in a trait, so the returned future is not bound
127/// `Send`. A browser (single-threaded wasm) needs nothing more. A native caller
128/// that drives the client on a multi-threaded executor and needs a `Send` future
129/// (for example to `tokio::spawn` it) should run the client on a current-thread
130/// runtime or wrap the call in a `Send`-producing adapter.
131#[allow(async_fn_in_trait)]
132pub trait Transport {
133    /// The transport's own failure type (a network error, a timeout). Surfaced
134    /// verbatim through [`ClientError::Transport`].
135    type Error: core::fmt::Display;
136
137    /// Perform one request. A non-2xx status is **not** an error here: the
138    /// client inspects the status and decodes the [`ErrorBody`]. Return `Err`
139    /// only when the request never produced a response.
140    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
141}
142
143/// Why a typed call failed.
144#[derive(Debug)]
145pub enum ClientError<E> {
146    /// The transport never got a response (network down, timeout).
147    Transport(E),
148    /// A response arrived but its body did not decode as the expected type.
149    Decode(String),
150    /// The server returned a non-2xx status with a classified [`ErrorBody`].
151    Api(ErrorBody),
152}
153
154impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
155    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
156        match self {
157            ClientError::Transport(error) => write!(f, "transport error: {error}"),
158            ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
159            ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
160        }
161    }
162}
163
164impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
165
166impl<E> ClientError<E> {
167    /// The classified [`ResultCode`] for an [`ClientError::Api`], so a caller
168    /// branches on the kind (`NotFound`, `Conflict`, ...) without matching the
169    /// message text. `None` for a transport or decode failure.
170    pub fn code(&self) -> Option<ResultCode> {
171        match self {
172            ClientError::Api(body) => Some(body.code),
173            _ => None,
174        }
175    }
176}
177
178type ClientResult<T, E> = Result<T, ClientError<E>>;
179
180/// A typed `/agdx/*` client over an injected [`Transport`].
181///
182/// Binary KV keys are base64url-encoded into the path by the client, so raw user
183/// bytes never enter a path segment. A `namespace`, `fork id`, or `projection
184/// id` argument, by contrast, is placed in the path verbatim, so the caller must
185/// pass a path-safe identifier (no `/`, `?`, or `#`). Fork ids are already
186/// constrained by [`validate_fork_id`](crate::fork::validate_fork_id).
187#[derive(Clone, Debug)]
188pub struct HttpClient<T> {
189    transport: T,
190}
191
192impl<T: Transport> HttpClient<T> {
193    /// Wrap a transport. The transport owns the base URL and auth.
194    pub fn new(transport: T) -> Self {
195        Self { transport }
196    }
197
198    /// The underlying transport, for a caller that needs an escape hatch.
199    pub fn transport(&self) -> &T {
200        &self.transport
201    }
202
203    /// `GET /agdx/capabilities`: feature-detect before showing a view.
204    pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
205        self.get(http::CAPABILITIES_PATH.to_owned()).await
206    }
207
208    /// `POST /agdx/query`: submit a query and return its current execution view.
209    pub async fn query(&self, query: &Query) -> ClientResult<QueryExecutionView, T::Error> {
210        self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
211            .await
212    }
213
214    pub async fn query_page(
215        &self,
216        execution_id: crate::query::QueryExecutionId,
217        body: &QueryPageBody,
218    ) -> ClientResult<QueryResult, T::Error> {
219        self.send_json(Method::Post, http::query_page_path(execution_id), body)
220            .await
221    }
222
223    pub async fn query_status(
224        &self,
225        execution_id: crate::query::QueryExecutionId,
226    ) -> ClientResult<QueryExecutionView, T::Error> {
227        self.get(http::query_execution_path(execution_id)).await
228    }
229
230    pub async fn cancel_query(
231        &self,
232        execution_id: crate::query::QueryExecutionId,
233    ) -> ClientResult<crate::query::QueryExecutionStatus, T::Error> {
234        self.send_empty(Method::Post, http::query_cancel_path(execution_id))
235            .await
236    }
237
238    pub async fn list_destinations(
239        &self,
240        query: &DestinationListQuery,
241    ) -> ClientResult<DestinationPageView, T::Error> {
242        self.get(with_query(http::DESTINATIONS_PATH, query)?).await
243    }
244
245    pub async fn destination(
246        &self,
247        id: DestinationId,
248        consistency: CheckpointReadConsistency,
249    ) -> ClientResult<Option<DestinationView>, T::Error> {
250        self.get_optional(with_query(
251            &http::destination_path(id),
252            &CheckpointReadQuery::new(consistency),
253        )?)
254        .await
255    }
256
257    pub async fn create_destination(
258        &self,
259        body: &DestinationMutationBody,
260    ) -> ClientResult<AcceptedOperationView, T::Error> {
261        self.send_json(Method::Post, http::DESTINATIONS_PATH.to_owned(), body)
262            .await
263    }
264
265    pub async fn enable_destination(
266        &self,
267        id: DestinationId,
268        body: &DestinationMutationBody,
269    ) -> ClientResult<AcceptedOperationView, T::Error> {
270        self.send_json(Method::Post, http::destination_enable_path(id), body)
271            .await
272    }
273
274    pub async fn disable_destination(
275        &self,
276        id: DestinationId,
277        body: &DestinationMutationBody,
278    ) -> ClientResult<AcceptedOperationView, T::Error> {
279        self.send_json(Method::Post, http::destination_disable_path(id), body)
280            .await
281    }
282
283    pub async fn destination_operation(
284        &self,
285        id: DestinationOperationId,
286    ) -> ClientResult<AcceptedOperationView, T::Error> {
287        self.get(http::destination_operation_path(id)).await
288    }
289
290    pub async fn destination_status(
291        &self,
292        id: DestinationId,
293        consistency: CheckpointReadConsistency,
294    ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
295        self.get(with_query(
296            &http::destination_status_path(id),
297            &CheckpointReadQuery::new(consistency),
298        )?)
299        .await
300    }
301
302    pub async fn destination_checkpoint(
303        &self,
304        id: DestinationId,
305        consistency: CheckpointReadConsistency,
306    ) -> ClientResult<crate::checkpoint::DestinationCheckpointStatus, T::Error> {
307        self.get(with_query(
308            &http::destination_checkpoint_path(id),
309            &CheckpointReadQuery::new(consistency),
310        )?)
311        .await
312    }
313
314    pub async fn destination_retention_gap(
315        &self,
316        id: DestinationId,
317        consistency: CheckpointReadConsistency,
318    ) -> ClientResult<DestinationIssueView, T::Error> {
319        self.get(with_query(
320            &http::destination_retention_gap_path(id),
321            &CheckpointReadQuery::new(consistency),
322        )?)
323        .await
324    }
325
326    pub async fn destination_prepared_attempt(
327        &self,
328        id: DestinationId,
329        consistency: CheckpointReadConsistency,
330    ) -> ClientResult<DestinationIssueView, T::Error> {
331        self.get(with_query(
332            &http::destination_prepared_attempt_path(id),
333            &CheckpointReadQuery::new(consistency),
334        )?)
335        .await
336    }
337
338    pub async fn query_routes(
339        &self,
340        query: &QueryRouteListQuery,
341    ) -> ClientResult<QueryRoutePageView, T::Error> {
342        self.get(with_query(http::QUERY_ROUTES_PATH, query)?).await
343    }
344
345    pub async fn destination_table(&self, id: DestinationId) -> ClientResult<TableView, T::Error> {
346        self.get(http::destination_table_path(id)).await
347    }
348
349    pub async fn destination_table_schema(
350        &self,
351        id: DestinationId,
352    ) -> ClientResult<TableSchemaView, T::Error> {
353        self.get(http::destination_table_schema_path(id)).await
354    }
355
356    pub async fn destination_current_snapshot(
357        &self,
358        id: DestinationId,
359    ) -> ClientResult<TableSnapshotView, T::Error> {
360        self.get(http::destination_current_snapshot_path(id)).await
361    }
362
363    pub async fn destination_snapshots(
364        &self,
365        id: DestinationId,
366        query: &SnapshotListQuery,
367    ) -> ClientResult<SnapshotPageView, T::Error> {
368        self.get(with_query(&http::destination_snapshots_path(id), query)?)
369            .await
370    }
371
372    pub async fn destination_snapshot(
373        &self,
374        id: DestinationId,
375        snapshot_id: i64,
376    ) -> ClientResult<TableSnapshotView, T::Error> {
377        self.get(http::destination_snapshot_path(id, snapshot_id))
378            .await
379    }
380
381    pub async fn destination_files(
382        &self,
383        id: DestinationId,
384        query: &TableFileListQuery,
385    ) -> ClientResult<TableFilePageView, T::Error> {
386        self.get(with_query(&http::destination_files_path(id), query)?)
387            .await
388    }
389
390    pub async fn destination_metrics(
391        &self,
392        id: DestinationId,
393    ) -> ClientResult<TableMetricsView, T::Error> {
394        self.get(http::destination_metrics_path(id)).await
395    }
396
397    /// `GET /agdx/projections`: list projections and their bindings, optionally
398    /// filtered.
399    pub async fn list_projections(
400        &self,
401        filter: &ProjectionListQuery,
402    ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
403        self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
404    }
405
406    /// `GET /agdx/schemas`: list registered schemas, optionally filtered.
407    pub async fn list_schemas(
408        &self,
409        filter: &SchemaListQuery,
410    ) -> ClientResult<Vec<SchemaInfo>, T::Error> {
411        self.get(with_query(http::SCHEMAS_PATH, filter)?).await
412    }
413
414    /// `POST /agdx/schemas`: register a schema, get its allocated id.
415    pub async fn register_schema(
416        &self,
417        source: SchemaSource,
418        name: Option<String>,
419        version: Option<u32>,
420    ) -> ClientResult<u32, T::Error> {
421        let body = http::RegisterSchemaBody {
422            source,
423            name,
424            version,
425        };
426        self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
427            .await
428    }
429
430    /// `GET /agdx/kv/{namespace}/{key}`: fetch one entry, or `None` on a 404.
431    /// The value rides the raw response body (no base64 inflation, symmetric with
432    /// `kv_set`), and the optional expiry rides the
433    /// [`KV_EXPIRES_AT_MICROS_HEADER`](http::KV_EXPIRES_AT_MICROS_HEADER) response
434    /// header.
435    pub async fn kv_get(
436        &self,
437        namespace: &str,
438        key: &[u8],
439    ) -> ClientResult<Option<KvValue>, T::Error> {
440        let path = http::kv_entry_path(namespace, &base64url_encode(key));
441        let response = self.dispatch(Method::Get, path, None).await?;
442        if response.status == 404 {
443            return Ok(None);
444        }
445        if !(200..300).contains(&response.status) {
446            return Err(api_error(&response));
447        }
448        let expires_at_micros = response
449            .header(http::KV_EXPIRES_AT_MICROS_HEADER)
450            .and_then(|value| value.parse::<u64>().ok());
451        Ok(Some(KvValue {
452            value: response.body,
453            expires_at_micros,
454        }))
455    }
456
457    /// `PUT /agdx/kv/{namespace}/{key}`: set a value, with an optional absolute
458    /// expiry (epoch microseconds).
459    pub async fn kv_set(
460        &self,
461        namespace: &str,
462        key: &[u8],
463        value: &[u8],
464        expires_at_micros: Option<u64>,
465    ) -> ClientResult<(), T::Error> {
466        let path = with_query(
467            &http::kv_entry_path(namespace, &base64url_encode(key)),
468            &KvPutQuery { expires_at_micros },
469        )?;
470        // The key is base64url in the path, but the value rides the raw request
471        // body, so a large value carries no base64 inflation.
472        self.expect_ok(Method::Put, path, Some(value.to_vec()))
473            .await
474    }
475
476    /// `PUT /agdx/kv/{namespace}/{key}/cas`: a conditional write
477    /// (compare-and-swap). Applies `value` only if `expect` holds, returning the
478    /// new version on commit. A precondition miss surfaces as
479    /// `ClientError::Api` with `ResultCode::Conflict` (the response's
480    /// `ErrorBody.detail` carries the current version). Backend-gated by the
481    /// `kv_cas` capability: a deployment that does not serve it answers
482    /// unsupported.
483    pub async fn kv_cas(
484        &self,
485        namespace: &str,
486        key: &[u8],
487        value: &[u8],
488        expect: CasExpect,
489        expires_at_micros: Option<u64>,
490    ) -> ClientResult<u64, T::Error> {
491        let (expect_version, expect_absent) = match expect {
492            CasExpect::Match(version) => (Some(version), None),
493            CasExpect::Absent => (None, Some(true)),
494        };
495        let path = with_query(
496            &http::kv_cas_path(namespace, &base64url_encode(key)),
497            &KvCasQuery {
498                expect_version,
499                expect_absent,
500                expires_at_micros,
501            },
502        )?;
503        // The value rides the raw body like the plain PUT.
504        let response = self
505            .dispatch(Method::Put, path, Some(value.to_vec()))
506            .await?;
507        let view: CasCommittedView = decode_ok(&response)?;
508        Ok(view.version)
509    }
510
511    /// `DELETE /agdx/kv/{namespace}/{key}`: delete one entry, returning `true` if it existed.
512    pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
513        let path = http::kv_entry_path(namespace, &base64url_encode(key));
514        self.send_empty(Method::Delete, path).await
515    }
516
517    /// `GET /agdx/kv/{namespace}`: scan a page of entries.
518    pub async fn kv_scan(
519        &self,
520        namespace: &str,
521        filter: &KvScanQuery,
522    ) -> ClientResult<KvPageView, T::Error> {
523        self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
524            .await
525    }
526
527    /// `POST /agdx/forks`: create a fork.
528    pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
529        self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
530            .await
531    }
532
533    /// `GET /agdx/forks`: list the caller's forks.
534    pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
535        self.get(http::FORKS_PATH.to_owned()).await
536    }
537
538    /// `GET /agdx/clients`: one page of live connections with their advertised
539    /// metadata, filtered and paginated per `query`. Follow `next_cursor` as the
540    /// next `after` to page.
541    pub async fn clients(
542        &self,
543        query: &ClientsQuery,
544    ) -> ClientResult<ClientMetadataListView, T::Error> {
545        self.get(with_query(http::CLIENTS_PATH, query)?).await
546    }
547
548    /// `POST /agdx/runs`: submit a task to an agent or workflow, returning the
549    /// minted (or converged) run.
550    pub async fn submit_run(&self, body: &AgentSubmit) -> ClientResult<AgentRunInfo, T::Error> {
551        self.send_json(Method::Post, http::RUNS_PATH.to_owned(), body)
552            .await
553    }
554
555    /// `GET /agdx/runs/{id}`: read one run's status, or `None` on a 404.
556    pub async fn run_status(&self, id: &str) -> ClientResult<Option<AgentRunInfo>, T::Error> {
557        self.get_optional(http::run_path(id)).await
558    }
559
560    /// `GET /agdx/runs`: one page of runs, filtered and paged per `query`.
561    /// Follow `cursor` as the next request's cursor to page.
562    pub async fn list_runs(&self, query: &RunsQuery) -> ClientResult<RunPageView, T::Error> {
563        self.get(with_query(http::RUNS_PATH, query)?).await
564    }
565
566    /// `POST /agdx/runs/{id}/cancel`: record the cancel intent, returning the
567    /// run (the engine observes the intent at its next step boundary).
568    pub async fn cancel_run(&self, id: &str) -> ClientResult<AgentRunInfo, T::Error> {
569        self.send_empty(Method::Post, http::run_cancel_path(id))
570            .await
571    }
572
573    /// `GET /agdx/projections/{id}`: read one projection and its bindings, or
574    /// `None` on a 404.
575    pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
576        self.get_optional(http::projection_path(id)).await
577    }
578
579    /// `POST /agdx/projections`: register or replace a projection (a control
580    /// command, durable on the control topic).
581    pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
582        self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
583            .await
584    }
585
586    /// `DELETE /agdx/projections/{id}`: drop a projection.
587    pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
588        self.expect_ok(Method::Delete, http::projection_path(id), None)
589            .await
590    }
591
592    /// `POST /agdx/bindings`: apply (add or update) a binding.
593    pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
594        self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
595            .await
596    }
597
598    /// `DELETE /agdx/bindings`: remove a binding for a source, or one projection
599    /// from it when `projection_ref` is set.
600    pub async fn remove_binding(
601        &self,
602        source: &SourceSelector,
603        projection_ref: Option<String>,
604    ) -> ClientResult<(), T::Error> {
605        let body = RemoveBindingBody {
606            stream: source.stream.clone(),
607            topic: source.topic.clone(),
608            projection_ref,
609        };
610        self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
611            .await
612    }
613
614    /// `GET /agdx/schemas/{id}`: read one writer schema, or `None` on a 404.
615    pub async fn get_schema(&self, id: u32) -> ClientResult<Option<SchemaInfo>, T::Error> {
616        self.get_optional(http::schema_path(id)).await
617    }
618
619    /// `DELETE /agdx/schemas/{id}`: drop (tombstone) a schema.
620    pub async fn drop_schema(&self, id: u32) -> ClientResult<(), T::Error> {
621        self.expect_ok(Method::Delete, http::schema_path(id), None)
622            .await
623    }
624
625    /// `POST /agdx/schemas/{id}/decode`: decode a record body under the schema,
626    /// returning its JSON form, or `None` when the body does not decode under it.
627    pub async fn decode_record(
628        &self,
629        id: u32,
630        payload: &[u8],
631    ) -> ClientResult<Option<serde_json::Value>, T::Error> {
632        let body = DecodeRecordBody {
633            payload: base64url_encode(payload),
634        };
635        self.send_json(Method::Post, http::schema_decode_path(id), &body)
636            .await
637    }
638
639    /// `GET /agdx/kv`: list the caller's namespaces and their entry counts.
640    pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
641        self.get(http::KV_PATH.to_owned()).await
642    }
643
644    /// `DELETE /agdx/kv/{namespace}`: bulk-delete entries matching the bounds
645    /// (no bounds clears the namespace). Returns the number removed.
646    pub async fn kv_delete_many(
647        &self,
648        namespace: &str,
649        filter: &KvScanQuery,
650    ) -> ClientResult<usize, T::Error> {
651        let path = with_query(&http::kv_namespace_path(namespace), filter)?;
652        let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
653        Ok(view.deleted)
654    }
655
656    /// `POST /agdx/forks/{id}/promote`: promote a fork's rows onto the trunk.
657    /// Returns the number of rows applied.
658    pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
659        let view: PromotedView = self
660            .send_empty(Method::Post, http::fork_promote_path(id))
661            .await?;
662        Ok(view.rows)
663    }
664
665    /// `DELETE /agdx/forks/{id}`: squash (discard) a fork.
666    pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
667        self.expect_ok(Method::Delete, http::fork_path(id), None)
668            .await
669    }
670
671    /// `PUT /agdx/forks/{id}/rows`: write one speculative row into a fork.
672    pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
673        self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
674            .await
675    }
676
677    /// `POST /agdx/graph/{name}/query`: run a traversal over a named graph, get
678    /// back the reachable nodes and traversed edges. Backend-gated by the `graph`
679    /// capability: a deployment without a graph backend answers unsupported.
680    pub async fn graph_query(
681        &self,
682        name: &str,
683        query: &GraphQuery,
684    ) -> ClientResult<GraphResultView, T::Error> {
685        self.send_json(Method::Post, http::graph_query_path(name), query)
686            .await
687    }
688
689    /// `GET /agdx/graph/{name}/neighbors/{node}`: the neighbor read, the cheap
690    /// common traversal. `node` is the Crockford-base32 node id. `query` carries
691    /// the direction, an optional edge-type filter, the hop depth, and a limit (a
692    /// default `query` reads one hop outward with no filter).
693    pub async fn graph_neighbors(
694        &self,
695        name: &str,
696        node: &str,
697        query: &GraphNeighborsQuery,
698    ) -> ClientResult<GraphResultView, T::Error> {
699        self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
700            .await
701    }
702
703    /// `GET /agdx/graphs`: list the registered graph projections, the discovery
704    /// surface a graph explorer reads to offer the available graphs. Reuses the
705    /// projection-list filter, narrowed to graph-kind projections server-side.
706    pub async fn list_graphs(
707        &self,
708        filter: &ProjectionListQuery,
709    ) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
710        self.get(with_query(http::GRAPHS_PATH, filter)?).await
711    }
712
713    /// `POST /agdx/graphs`: register a graph projection (a [`Projection`] with
714    /// `kind = Graph` and an entity schema). Applied asynchronously, like every
715    /// control command.
716    pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
717        self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
718            .await
719    }
720
721    /// `GET /agdx/graphs/{id}`: read one graph projection by id, or `None` when no
722    /// graph projection has it.
723    pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
724        self.get_optional(http::graph_path(id)).await
725    }
726
727    /// `DELETE /agdx/graphs/{id}`: drop the graph projection registered under
728    /// `id`. The materialized nodes and edges are left untouched.
729    pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
730        self.expect_ok(Method::Delete, http::graph_path(id), None)
731            .await
732    }
733
734    /// `GET /agdx/authz/whoami`: the caller's own bound roles and effective
735    /// grants. Backend-gated by the `authz` capability: a deployment that does
736    /// not serve the authorization band answers unsupported.
737    pub async fn authz_whoami(&self) -> ClientResult<WhoamiReply, T::Error> {
738        self.get(http::AUTHZ_WHOAMI_PATH.to_owned()).await
739    }
740
741    /// `GET /agdx/authz/roles`: every defined role with its full grant set.
742    pub async fn list_roles(&self) -> ClientResult<Vec<Role>, T::Error> {
743        self.get(http::AUTHZ_ROLES_PATH.to_owned()).await
744    }
745
746    /// `GET /agdx/authz/roles/{name}`: one role by name, or `None` on a 404.
747    pub async fn get_role(&self, name: &str) -> ClientResult<Option<Role>, T::Error> {
748        self.get_optional(http::authz_role_path(name)).await
749    }
750
751    /// `PUT /agdx/authz/roles/{name}`: define or replace a role. The path name is
752    /// authoritative, so `role.name` must match it.
753    pub async fn define_role(&self, role: &Role) -> ClientResult<(), T::Error> {
754        self.send_json_ok(Method::Put, http::authz_role_path(&role.name), role)
755            .await
756    }
757
758    /// `DELETE /agdx/authz/roles/{name}`: delete a role.
759    pub async fn delete_role(&self, name: &str) -> ClientResult<(), T::Error> {
760        self.expect_ok(Method::Delete, http::authz_role_path(name), None)
761            .await
762    }
763
764    /// `GET /agdx/authz/users/{id}/roles`: one user's bound role names.
765    pub async fn user_roles(&self, user_id: u32) -> ClientResult<Vec<String>, T::Error> {
766        self.get(http::authz_user_roles_path(user_id)).await
767    }
768
769    /// `PUT /agdx/authz/users/{id}/roles`: replace the user's whole role set.
770    pub async fn bind_user_roles(
771        &self,
772        user_id: u32,
773        roles: &[String],
774    ) -> ClientResult<(), T::Error> {
775        self.send_json_ok(Method::Put, http::authz_user_roles_path(user_id), &roles)
776            .await
777    }
778
779    async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
780        let response = self.dispatch(Method::Get, path, None).await?;
781        decode_ok(&response)
782    }
783
784    /// A `GET` whose 404 means "absent" rather than an error.
785    async fn get_optional<R: DeserializeOwned>(
786        &self,
787        path: String,
788    ) -> ClientResult<Option<R>, T::Error> {
789        let response = self.dispatch(Method::Get, path, None).await?;
790        if response.status == 404 {
791            return Ok(None);
792        }
793        decode_ok(&response).map(Some)
794    }
795
796    async fn send_json<B: Serialize, R: DeserializeOwned>(
797        &self,
798        method: Method,
799        path: String,
800        body: &B,
801    ) -> ClientResult<R, T::Error> {
802        let payload = serde_json::to_vec(body)
803            .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
804        let response = self.dispatch(method, path, Some(payload)).await?;
805        decode_ok(&response)
806    }
807
808    async fn send_empty<R: DeserializeOwned>(
809        &self,
810        method: Method,
811        path: String,
812    ) -> ClientResult<R, T::Error> {
813        let response = self.dispatch(method, path, None).await?;
814        decode_ok(&response)
815    }
816
817    /// Send a JSON body and only check the status, for a control route whose 2xx
818    /// body is empty.
819    async fn send_json_ok<B: Serialize>(
820        &self,
821        method: Method,
822        path: String,
823        body: &B,
824    ) -> ClientResult<(), T::Error> {
825        let payload = serde_json::to_vec(body)
826            .map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
827        let response = self.dispatch(method, path, Some(payload)).await?;
828        check_status(&response)
829    }
830
831    async fn expect_ok(
832        &self,
833        method: Method,
834        path: String,
835        body: Option<Vec<u8>>,
836    ) -> ClientResult<(), T::Error> {
837        let response = self.dispatch(method, path, body).await?;
838        check_status(&response)
839    }
840
841    async fn dispatch(
842        &self,
843        method: Method,
844        path: String,
845        body: Option<Vec<u8>>,
846    ) -> ClientResult<HttpResponse, T::Error> {
847        self.transport
848            .send(HttpRequest { method, path, body })
849            .await
850            .map_err(ClientError::Transport)
851    }
852}
853
854/// Append `?<urlencoded>` to a path when `params` serializes to a non-empty
855/// query string, else return the path unchanged.
856fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
857    let query = serde_urlencoded::to_string(params)
858        .map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
859    if query.is_empty() {
860        Ok(path.to_owned())
861    } else {
862        Ok(format!("{path}?{query}"))
863    }
864}
865
866/// On a 2xx, decode the bare `Ok` payload, otherwise turn the body into a typed
867/// [`ClientError::Api`] (falling back to a synthetic body if the error body
868/// itself does not decode, so a malformed 500 still classifies).
869fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
870    if (200..300).contains(&response.status) {
871        serde_json::from_slice(&response.body)
872            .map_err(|error| ClientError::Decode(format!("response body: {error}")))
873    } else {
874        Err(api_error(response))
875    }
876}
877
878/// Like [`decode_ok`] but for a route whose 2xx body is empty.
879fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
880    if (200..300).contains(&response.status) {
881        Ok(())
882    } else {
883        Err(api_error(response))
884    }
885}
886
887fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
888    let body = serde_json::from_slice::<ErrorBody>(&response.body).unwrap_or_else(|_| {
889        ErrorBody::new(
890            code_for_status(response.status),
891            String::from_utf8_lossy(&response.body).into_owned(),
892        )
893    });
894    ClientError::Api(body)
895}
896
897/// A best-effort classification for an `ErrorBody`-less failure response,
898/// inferred from the HTTP status alone. Used only as the fallback when the body
899/// did not carry a [`ResultCode`].
900fn code_for_status(status: u16) -> ResultCode {
901    match status {
902        404 => ResultCode::NotFound,
903        400 => ResultCode::InvalidArgument,
904        401 => ResultCode::Unauthenticated,
905        403 => ResultCode::Forbidden,
906        409 => ResultCode::Conflict,
907        413 => ResultCode::TooLarge,
908        501 => ResultCode::Unsupported,
909        503 => ResultCode::Stale,
910        _ => ResultCode::Backend,
911    }
912}
913
914// base64url (unpadded, RFC 4648 section 5), the encoding this surface uses for
915// every binary value. Hand-rolled to keep the portable graph dependency-free.
916
917const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
918
919/// Encode bytes as URL-safe unpadded base64 (RFC 4648 ยง5, no `=`).
920pub fn base64url_encode(input: &[u8]) -> String {
921    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
922    for chunk in input.chunks(3) {
923        let b0 = chunk[0] as usize;
924        out.push(B64URL[b0 >> 2] as char);
925        match chunk.len() {
926            1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
927            2 => {
928                let b1 = chunk[1] as usize;
929                out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
930                out.push(B64URL[(b1 & 0b1111) << 2] as char);
931            }
932            _ => {
933                let b1 = chunk[1] as usize;
934                let b2 = chunk[2] as usize;
935                out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
936                out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
937                out.push(B64URL[b2 & 0b111111] as char);
938            }
939        }
940    }
941    out
942}
943
944/// Decode URL-safe unpadded base64. `None` on any non-alphabet byte or an
945/// impossible length (a single trailing char carries no whole byte).
946pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
947    fn val(byte: u8) -> Option<u8> {
948        match byte {
949            b'A'..=b'Z' => Some(byte - b'A'),
950            b'a'..=b'z' => Some(byte - b'a' + 26),
951            b'0'..=b'9' => Some(byte - b'0' + 52),
952            b'-' => Some(62),
953            b'_' => Some(63),
954            _ => None,
955        }
956    }
957    let bytes = input.as_bytes();
958    if bytes.len() % 4 == 1 {
959        return None;
960    }
961    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
962    for chunk in bytes.chunks(4) {
963        let mut acc = 0u32;
964        for &byte in chunk {
965            acc = (acc << 6) | u32::from(val(byte)?);
966        }
967        // Left-align the accumulated bits for a short final chunk.
968        acc <<= 6 * (4 - chunk.len());
969        match chunk.len() {
970            2 => out.push((acc >> 16) as u8),
971            3 => {
972                out.push((acc >> 16) as u8);
973                out.push((acc >> 8) as u8);
974            }
975            _ => {
976                out.push((acc >> 16) as u8);
977                out.push((acc >> 8) as u8);
978                out.push(acc as u8);
979            }
980        }
981    }
982    Some(out)
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    #[test]
990    fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
991        for case in [
992            &b""[..],
993            &b"f"[..],
994            &b"fo"[..],
995            &b"foo"[..],
996            &b"foob"[..],
997            &b"fooba"[..],
998            &b"foobar"[..],
999            &[0x00, 0xff, 0x10, 0x80][..],
1000        ] {
1001            let encoded = base64url_encode(case);
1002            assert!(
1003                !encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
1004                "url-safe unpadded: {encoded}"
1005            );
1006            assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
1007        }
1008    }
1009
1010    #[test]
1011    fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
1012        assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1013        assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
1014    }
1015
1016    #[test]
1017    fn given_checkpoint_reads_when_paths_are_built_then_should_require_the_selected_consistency() {
1018        let single = with_query::<(), _>(
1019            &http::destination_status_path(DestinationId::from_u128(1)),
1020            &CheckpointReadQuery::new(CheckpointReadConsistency::Linearizable),
1021        )
1022        .expect("single checkpoint read query encodes");
1023        assert!(single.ends_with("?consistency=linearizable"));
1024
1025        let list = with_query::<(), _>(
1026            http::DESTINATIONS_PATH,
1027            &DestinationListQuery {
1028                source_stream: None,
1029                source_topic: None,
1030                name_contains: None,
1031                after: None,
1032                limit: None,
1033                consistency: CheckpointReadConsistency::PotentiallyStale,
1034            },
1035        )
1036        .expect("checkpoint list query encodes");
1037        assert!(list.ends_with("?consistency=potentially_stale"));
1038    }
1039
1040    #[test]
1041    fn given_a_bad_base64_string_when_decoded_then_should_reject() {
1042        assert!(
1043            base64url_decode("====").is_none(),
1044            "padding is not alphabet"
1045        );
1046        assert!(
1047            base64url_decode("A").is_none(),
1048            "a lone char carries no byte"
1049        );
1050        assert!(base64url_decode("a b").is_none(), "space is not alphabet");
1051    }
1052
1053    // A transport that replays a canned response, so the typed methods are
1054    // testable without any IO.
1055    struct CannedTransport {
1056        response: HttpResponse,
1057    }
1058
1059    impl Transport for CannedTransport {
1060        type Error = std::convert::Infallible;
1061        async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
1062            Ok(self.response.clone())
1063        }
1064    }
1065
1066    fn block_on<F: core::future::Future>(future: F) -> F::Output {
1067        // A minimal executor: these futures never yield (the canned transport
1068        // is ready immediately), so a busy poll with the no-op waker resolves
1069        // them. No `unsafe`, so the crate's `forbid(unsafe_code)` holds.
1070        use core::task::{Context, Poll, Waker};
1071        let mut context = Context::from_waker(Waker::noop());
1072        let mut future = core::pin::pin!(future);
1073        loop {
1074            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
1075                return output;
1076            }
1077        }
1078    }
1079
1080    #[test]
1081    fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
1082        let body = serde_json::to_vec(&Capabilities::new(
1083            true,
1084            crate::hello::OpVersions::new(1, 1, 1, 1),
1085        ))
1086        .unwrap();
1087        let client = HttpClient::new(CannedTransport {
1088            response: HttpResponse::new(200, body),
1089        });
1090        let caps = block_on(client.capabilities()).expect("decodes");
1091        assert!(caps.managed && !caps.kv.cas);
1092    }
1093
1094    #[test]
1095    fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
1096        let body =
1097            serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
1098        let client = HttpClient::new(CannedTransport {
1099            response: HttpResponse::new(404, body),
1100        });
1101        let error = block_on(client.list_forks()).expect_err("a 404 is an error");
1102        assert_eq!(error.code(), Some(ResultCode::NotFound));
1103    }
1104
1105    #[test]
1106    fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
1107        let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1108        let client = HttpClient::new(CannedTransport {
1109            response: HttpResponse::new(404, body),
1110        });
1111        let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
1112        assert!(entry.is_none());
1113    }
1114
1115    #[test]
1116    fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
1117        let response = HttpResponse::new(200, b"world".to_vec())
1118            .with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
1119        let client = HttpClient::new(CannedTransport { response });
1120        let entry = block_on(client.kv_get("sessions", b"user:1"))
1121            .expect("decodes")
1122            .expect("present");
1123        assert_eq!(entry.value, b"world");
1124        assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
1125    }
1126
1127    #[test]
1128    fn given_a_missing_projection_when_fetched_then_should_be_none() {
1129        let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1130        let client = HttpClient::new(CannedTransport {
1131            response: HttpResponse::new(404, body),
1132        });
1133        let info = block_on(client.get_projection("order.v1")).expect("404 maps to None");
1134        assert!(info.is_none());
1135    }
1136
1137    #[test]
1138    fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
1139        let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
1140        let client = HttpClient::new(CannedTransport {
1141            response: HttpResponse::new(200, body),
1142        });
1143        let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
1144            .expect("decodes the count");
1145        assert_eq!(removed, 7);
1146    }
1147
1148    #[test]
1149    fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
1150        let client = HttpClient::new(CannedTransport {
1151            response: HttpResponse::new(204, Vec::new()),
1152        });
1153        block_on(client.drop_projection("order.v1")).expect("a 204 is a success");
1154    }
1155
1156    #[test]
1157    fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
1158        let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
1159        let client = HttpClient::new(CannedTransport {
1160            response: HttpResponse::new(200, body),
1161        });
1162        let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
1163            .expect("a commit returns the new version");
1164        assert_eq!(version, 4);
1165    }
1166
1167    #[test]
1168    fn given_an_ok_whoami_response_when_fetched_then_should_decode_roles_and_grants() {
1169        let reply = WhoamiReply {
1170            v: 1,
1171            roles: vec!["admin".to_owned()],
1172            grants: vec![crate::authz::Grant {
1173                effect: crate::authz::Effect::Allow,
1174                feature: crate::authz::Feature::Authz,
1175                action: crate::authz::Action::Admin,
1176                resource: crate::authz::ResourcePattern::all(),
1177            }],
1178        };
1179        let body = serde_json::to_vec(&reply).unwrap();
1180        let client = HttpClient::new(CannedTransport {
1181            response: HttpResponse::new(200, body),
1182        });
1183        let whoami = block_on(client.authz_whoami()).expect("decodes");
1184        assert_eq!(whoami.roles, vec!["admin".to_owned()]);
1185        assert_eq!(whoami.grants.len(), 1);
1186    }
1187
1188    #[test]
1189    fn given_a_missing_role_when_fetched_then_should_be_none() {
1190        let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
1191        let client = HttpClient::new(CannedTransport {
1192            response: HttpResponse::new(404, body),
1193        });
1194        let role = block_on(client.get_role("ghost")).expect("404 maps to None");
1195        assert!(role.is_none());
1196    }
1197
1198    #[test]
1199    fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
1200        let body = serde_json::to_vec(
1201            &ErrorBody::new(ResultCode::Conflict, "version conflict")
1202                .with_detail(serde_json::json!({ "current": 3 })),
1203        )
1204        .unwrap();
1205        let client = HttpClient::new(CannedTransport {
1206            response: HttpResponse::new(409, body),
1207        });
1208        let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
1209            .expect_err("a precondition miss is an error");
1210        assert_eq!(error.code(), Some(ResultCode::Conflict));
1211    }
1212}