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