Skip to main content

cratestack_client_rust/client/
core.rs

1use std::sync::Arc;
2
3use cratestack_codec_cbor::CborCodec;
4
5use crate::auth::RequestAuthorizer;
6use crate::client::http::HttpClient;
7use crate::client::sealing::Sealing;
8use crate::codec::HttpClientCodec;
9use crate::config::ClientConfig;
10use crate::error::ClientError;
11use crate::idempotency::RequestIdempotency;
12use crate::state::{ClientStateStore, InMemoryStateStore, PersistedClientState};
13
14/// Installs a `ring`-backed `rustls::crypto::CryptoProvider` if the process
15/// doesn't already have one (#440).
16///
17/// `reqwest`'s `rustls-no-provider` feature — deliberately chosen over
18/// `rustls` so this crate stops forcing `aws-lc-rs` on every consumer of
19/// `cratestack-pg` (see the workspace `Cargo.toml`'s `reqwest` entry) — ships
20/// no crypto provider at all: `reqwest::Client::new()`/`ClientBuilder::build()`
21/// PANIC at construction time if `rustls::crypto::CryptoProvider::get_default()`
22/// finds nothing installed. Unlike the old `rustls` feature's silent
23/// `aws-lc-rs` install, that's a worse zero-config default than what this
24/// crate had before, not merely a neutral one.
25///
26/// `install_default()` only ever takes effect the FIRST time it succeeds
27/// process-wide — it's a courtesy fallback, not an override. A consumer that
28/// installs its own provider (any backend, including `aws-lc-rs`) before
29/// constructing its first `CratestackClient` keeps that choice; this only
30/// fires when nobody has chosen anything yet, which is exactly the gap
31/// `rustls-no-provider` otherwise turns into a panic. The `Err` it returns
32/// on a race with another caller installing first (or a no-op call to this
33/// same function from a second `CratestackClient::new`) is expected and
34/// intentionally ignored.
35///
36/// Public since cratestack#926: [`CratestackClient::with_http_client`]
37/// and `with_middleware_client` both take an already-built
38/// `reqwest::Client`, so the panic happens in the *caller's* code,
39/// before this crate gets a chance to install anything. Callers on
40/// those paths need a way to say "install the fallback now", and this
41/// is it — idempotent, safe to call from anywhere, and a no-op once any
42/// provider is installed.
43///
44/// On `wasm32` this does nothing (cratestack#1104): reqwest goes through
45/// the browser's `fetch` there, which does TLS itself, so there is no
46/// `rustls` provider to install. It still exists so a caller's code
47/// compiles unchanged for both targets.
48pub fn ensure_crypto_provider() {
49    #[cfg(not(target_arch = "wasm32"))]
50    let _ = rustls::crypto::ring::default_provider().install_default();
51}
52
53#[derive(Clone)]
54pub struct CratestackClient<C = CborCodec> {
55    pub(crate) http: HttpClient,
56    pub(crate) config: ClientConfig,
57    pub(crate) codec: C,
58    pub(crate) state_store: Arc<dyn ClientStateStore>,
59    pub(crate) request_authorizer: Option<Arc<dyn RequestAuthorizer>>,
60    /// The generating schema's `SCHEMA_SHA256` (issue #178) — `None` for a
61    /// hand-constructed client that isn't wrapped by generated `Client`
62    /// code (e.g. a bare `CratestackClient` in a test). Set automatically
63    /// by the schema-generated `Client::new` wrapper, never by the schema
64    /// author directly. Sent as `x-cratestack-schema-sha` on every request
65    /// when present; the server-side counterpart only ever warns on
66    /// mismatch, never rejects.
67    pub(crate) schema_sha: Option<&'static str>,
68    /// Per-call idempotency override (cratestack#926) set by
69    /// [`Self::with_idempotency`]. `None` — the default — means "derive
70    /// it from the HTTP method", which is the right answer for every
71    /// REST CRUD route and the wrong one for RPC (all `POST`) and for
72    /// `@query` procedures. See [`RequestIdempotency`].
73    pub(crate) idempotency: Option<RequestIdempotency>,
74    /// The signed transport (cratestack#1007): the envelope, the schema
75    /// digest it binds and the route of the call in flight. Empty, and
76    /// inert, unless the `cose` feature is on and `with_envelope` was called.
77    #[cfg_attr(not(feature = "cose"), allow(dead_code))]
78    pub(crate) sealing: Sealing,
79    /// Compact wire keys (`client/compact.rs`); `None` — the default — is
80    /// today's wire, byte for byte.
81    pub(crate) compact: Option<Arc<crate::client::compact::CompactClient>>,
82    /// The route template of the call in flight, set by [`Self::at`].
83    pub(crate) compact_route: Option<Arc<str>>,
84}
85
86impl CratestackClient<CborCodec> {
87    pub fn cbor(config: ClientConfig) -> Self {
88        Self::new(config, CborCodec)
89    }
90}
91
92impl<C> CratestackClient<C>
93where
94    C: HttpClientCodec,
95{
96    pub fn new(config: ClientConfig, codec: C) -> Self {
97        ensure_crypto_provider();
98        Self {
99            http: HttpClient::Plain(default_http_client()),
100            config,
101            codec,
102            state_store: Arc::new(InMemoryStateStore::default()),
103            request_authorizer: None,
104            schema_sha: None,
105            idempotency: None,
106            sealing: Sealing::default(),
107            compact: None,
108            compact_route: None,
109        }
110    }
111
112    /// Build a client on a caller-supplied `reqwest::Client` — a
113    /// custom timeout, proxy, or mTLS identity.
114    ///
115    /// Install a `rustls` crypto provider (e.g. via
116    /// [`ensure_crypto_provider`]) *before* constructing `http`:
117    /// unlike [`Self::new`], this constructor is handed an already-built
118    /// client, so the #440 panic fires in the caller, not here.
119    pub fn with_http_client(config: ClientConfig, codec: C, http: reqwest::Client) -> Self {
120        Self {
121            http: HttpClient::Plain(http),
122            config,
123            codec,
124            state_store: Arc::new(InMemoryStateStore::default()),
125            request_authorizer: None,
126            schema_sha: None,
127            idempotency: None,
128            sealing: Sealing::default(),
129            compact: None,
130            compact_route: None,
131        }
132    }
133
134    pub fn with_state_store(mut self, state_store: Arc<dyn ClientStateStore>) -> Self {
135        self.state_store = state_store;
136        self
137    }
138
139    pub fn with_optional_state_store(self, state_store: Option<Arc<dyn ClientStateStore>>) -> Self {
140        match state_store {
141            Some(state_store) => self.with_state_store(state_store),
142            None => self,
143        }
144    }
145
146    pub fn with_request_authorizer(
147        mut self,
148        request_authorizer: Arc<dyn RequestAuthorizer>,
149    ) -> Self {
150        self.request_authorizer = Some(request_authorizer);
151        self
152    }
153
154    /// Stamps the generating schema's `SCHEMA_SHA256` onto this client, so
155    /// every subsequent request carries `x-cratestack-schema-sha` (issue
156    /// #178). Called by the schema-generated `Client::new` wrapper, not
157    /// meant to be called directly by schema authors — public because the
158    /// generated code lives in a downstream crate.
159    pub fn with_schema_sha(mut self, schema_sha: &'static str) -> Self {
160        self.schema_sha = Some(schema_sha);
161        self
162    }
163
164    pub fn state(&self) -> Result<PersistedClientState, ClientError> {
165        // Not `.map_err(ClientError::from)`: `ClientError`'s only
166        // `From<CratestackError>` impl targets `ClientError::Codec` (for genuine
167        // wire-codec failures), which would misclassify a purely local
168        // state-store failure as a remote/codec error — see #475's review
169        // findings and `error.rs`'s `state_store_error_maps_to_client_error_state`.
170        self.state_store
171            .load()
172            .map_err(|error| ClientError::State(error.to_string()))
173    }
174}
175
176#[cfg(test)]
177pub(crate) mod tests;
178
179/// The client [`CratestackClient::new`] sends through. With `cose` it never
180/// follows a redirect: a sealed request is bound to one route, so a `303`
181/// that turned it into a plain authenticated `GET` elsewhere, or a `307`
182/// that re-sent the sealed bytes to another `Location`, is an attack and not
183/// a convenience (cratestack#1007). A caller-supplied client
184/// ([`CratestackClient::with_http_client`]) is checked after the fact instead.
185fn default_http_client() -> reqwest::Client {
186    #[cfg(all(feature = "cose", not(target_arch = "wasm32")))]
187    return reqwest::Client::builder()
188        .redirect(reqwest::redirect::Policy::none())
189        .build()
190        .unwrap_or_else(|_| reqwest::Client::new());
191    #[cfg(not(all(feature = "cose", not(target_arch = "wasm32"))))]
192    reqwest::Client::new()
193}