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