Skip to main content

ocre/
config.rs

1//! Typed app configuration from Worker variables and secrets, and the environment (development or production).
2//!
3//! An Ocre app has no config files at run time: its settings are the
4//! plain-text variables of cloudflare.config.ts (`KEY: bindings.text("...")`)
5//! and its secrets (`ocre secrets push`), with `.dev.vars` overriding both
6//! in `ocre dev`. Declare them once as a struct and read it with
7//! [`Ctx::config`](crate::Ctx::config) (Loco's `settings`, Rails'
8//! `config.x` and credentials):
9//!
10//! ```no_run
11//! use axum::extract::State;
12//! use ocre::{Ctx, Result};
13//! use serde::Deserialize;
14//!
15//! /// Field `stripe_secret_key` reads `STRIPE_SECRET_KEY`.
16//! #[derive(Deserialize)]
17//! struct Settings {
18//!     stripe_secret_key: String,
19//!     #[serde(default = "default_page_size")]
20//!     page_size: u32,
21//!     support_email: Option<String>,
22//!     beta_features: Vec<String>, // "search,exports"
23//! }
24//!
25//! fn default_page_size() -> u32 {
26//!     25
27//! }
28//!
29//! async fn checkout(State(ctx): State<Ctx>) -> Result<String> {
30//!     let settings: Settings = ctx.config()?;
31//!     Ok(format!("{} per page", settings.page_size))
32//! }
33//! # let _ = checkout;
34//! ```
35//!
36//! Each field reads the variable or secret named like the field in upper
37//! case (`#[serde(rename = "...")]` names it exactly, before upper-casing).
38//! Values are text, converted to the field's type: numbers, `bool`
39//! (`true`/`false`/`1`/`0`), `char`, `String`, `Vec<_>` from a
40//! comma-separated list, and unit enums by name. `Option` fields and
41//! `#[serde(default)]` fields may be missing; a missing required field is
42//! [`Error::Internal`] naming the variable and the
43//! two places to set it.
44//!
45//! # Free plan
46//!
47//! Reading costs no request or binding operation: one environment lookup
48//! per field (microseconds of CPU). The free plan allows 64 variables and
49//! secrets per Worker, 5 KB each (September 2026).
50
51use serde::de::{self, DeserializeOwned, IntoDeserializer, Visitor};
52
53use crate::Error;
54
55/// Where the Worker runs, from its build profile (Rails' `Rails.env`, Loco's environment).
56///
57/// `ocre dev` and `cargo test` build with debug assertions
58/// ([`Development`](Self::Development)); `ocre deploy` builds in release mode
59/// ([`Production`](Self::Production)). Rails' `test` environment is
60/// `Development` here: tests run the debug build natively. Values that
61/// differ per environment go in `.dev.vars` (development) and
62/// cloudflare.config.ts or secrets (production), read with
63/// [`Ctx::config`](crate::Ctx::config).
64///
65/// # Examples
66///
67/// ```
68/// use ocre::config::Environment;
69///
70/// let env = Environment::current();
71/// assert_eq!(env.is_development(), cfg!(debug_assertions));
72/// assert_eq!(Environment::Production.as_str(), "production");
73/// ```
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Environment {
76    /// Debug build: `ocre dev`, `cargo test`, `ocre test`.
77    Development,
78    /// Release build: `ocre deploy`.
79    Production,
80}
81
82impl Environment {
83    /// The environment of this build.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// assert!(ocre::config::Environment::current().is_development() || cfg!(not(debug_assertions)));
89    /// ```
90    pub fn current() -> Self {
91        if cfg!(debug_assertions) { Self::Development } else { Self::Production }
92    }
93
94    /// `development` or `production`, as sent to error reporters.
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// assert_eq!(ocre::config::Environment::Development.as_str(), "development");
100    /// ```
101    pub fn as_str(self) -> &'static str {
102        match self {
103            Self::Development => "development",
104            Self::Production => "production",
105        }
106    }
107
108    /// Whether this is [`Development`](Self::Development).
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// assert!(!ocre::config::Environment::Production.is_development());
114    /// ```
115    pub fn is_development(self) -> bool {
116        self == Self::Development
117    }
118}
119
120/// Reads `T` from the given variables: what [`Ctx::config`](crate::Ctx::config) does with the Worker's environment.
121///
122/// Use it in unit tests of code that takes the settings struct.
123///
124/// # Errors
125///
126/// [`Error::Internal`] when a required variable is missing or does not
127/// convert to its field's type; the message names the variable and the fix.
128///
129/// # Examples
130///
131/// ```
132/// use serde::Deserialize;
133///
134/// #[derive(Debug, Deserialize, PartialEq)]
135/// struct Settings {
136///     page_size: u32,
137///     admins: Vec<String>,
138///     debug_toolbar: Option<bool>,
139/// }
140///
141/// let settings: Settings = ocre::config::from_vars([("PAGE_SIZE", "20"), ("ADMINS", "ada, grace")]).unwrap();
142/// assert_eq!(settings, Settings { page_size: 20, admins: vec!["ada".into(), "grace".into()], debug_toolbar: None });
143///
144/// let err = ocre::config::from_vars::<Settings>([("PAGE_SIZE", "many")]).unwrap_err();
145/// assert!(err.to_string().contains("`PAGE_SIZE`"));
146/// ```
147pub fn from_vars<'a, T: DeserializeOwned>(vars: impl IntoIterator<Item = (&'a str, &'a str)>) -> crate::Result<T> {
148    let vars: Vec<(&str, &str)> = vars.into_iter().collect();
149    from_lookup(&|name| vars.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned()))
150}
151
152/// Reads `T` with `lookup` (variable, then secret, by name); used by `Ctx::config`.
153pub(crate) fn from_lookup<T: DeserializeOwned>(lookup: &dyn Fn(&str) -> Option<String>) -> crate::Result<T> {
154    T::deserialize(Vars { lookup }).map_err(|err| Error::internal(err.0))
155}
156
157/// The text of a missing-variable error, naming both places to set it.
158fn missing(name: &str) -> String {
159    format!(
160        "Worker variable or secret `{name}` is missing. Fix: add `{name}: bindings.text(\"...\"),` to worker.env in cloudflare.config.ts, or for a secret run `ocre secrets push {name} --file .prod.vars`; in `ocre dev`, add `{name}=...` to .dev.vars"
161    )
162}
163
164/// A config error, turned into [`Error::Internal`].
165#[derive(Debug)]
166struct ConfigError(String);
167
168impl std::fmt::Display for ConfigError {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.write_str(&self.0)
171    }
172}
173
174impl std::error::Error for ConfigError {}
175
176impl de::Error for ConfigError {
177    fn custom<M: std::fmt::Display>(message: M) -> Self {
178        Self(message.to_string())
179    }
180
181    fn missing_field(field: &'static str) -> Self {
182        Self(missing(&field.to_ascii_uppercase()))
183    }
184}
185
186/// The whole environment: only structs can be read from it.
187struct Vars<'a> {
188    lookup: &'a dyn Fn(&str) -> Option<String>,
189}
190
191impl<'de> de::Deserializer<'de> for Vars<'_> {
192    type Error = ConfigError;
193
194    fn deserialize_any<V: Visitor<'de>>(self, _visitor: V) -> Result<V::Value, ConfigError> {
195        Err(ConfigError("ocre::config reads a struct with named fields, one per variable".to_owned()))
196    }
197
198    fn deserialize_struct<V: Visitor<'de>>(
199        self,
200        _name: &'static str,
201        fields: &'static [&'static str],
202        visitor: V,
203    ) -> Result<V::Value, ConfigError> {
204        let entries: Vec<(&'static str, String)> = fields
205            .iter()
206            .filter_map(|field| {
207                let name = field.to_ascii_uppercase();
208                (self.lookup)(&name).map(|value| (*field, value))
209            })
210            .collect();
211        visitor.visit_map(Fields { entries: entries.into_iter(), value: None })
212    }
213
214    serde::forward_to_deserialize_any! {
215        bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string
216        bytes byte_buf option unit unit_struct newtype_struct seq tuple
217        tuple_struct map enum identifier ignored_any
218    }
219}
220
221struct Fields {
222    entries: std::vec::IntoIter<(&'static str, String)>,
223    value: Option<(&'static str, String)>,
224}
225
226impl<'de> de::MapAccess<'de> for Fields {
227    type Error = ConfigError;
228
229    fn next_key_seed<K: de::DeserializeSeed<'de>>(&mut self, seed: K) -> Result<Option<K::Value>, ConfigError> {
230        let Some((field, value)) = self.entries.next() else { return Ok(None) };
231        self.value = Some((field, value));
232        seed.deserialize(field.into_deserializer()).map(Some)
233    }
234
235    fn next_value_seed<V: de::DeserializeSeed<'de>>(&mut self, seed: V) -> Result<V::Value, ConfigError> {
236        let (field, value) = self.value.take().expect("serde asks for a value after its key");
237        let name = field.to_ascii_uppercase();
238        seed.deserialize(Text { text: &value, name: &name })
239    }
240}
241
242/// One variable's text, converted to the type the field asks for.
243struct Text<'a> {
244    text: &'a str,
245    name: &'a str,
246}
247
248impl Text<'_> {
249    fn parse<T: std::str::FromStr>(&self, kind: &str) -> Result<T, ConfigError> {
250        self.text.trim().parse().map_err(|_| {
251            ConfigError(format!(
252                "Worker variable `{}` is `{}`, not {kind}. Fix: set it to {kind} in cloudflare.config.ts, the secret, or .dev.vars",
253                self.name, self.text
254            ))
255        })
256    }
257}
258
259macro_rules! parse_as {
260    ($($method:ident => $visit:ident, $kind:literal;)*) => {
261        $(fn $method<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, ConfigError> {
262            visitor.$visit(self.parse($kind)?)
263        })*
264    };
265}
266
267impl<'de> de::Deserializer<'de> for Text<'_> {
268    type Error = ConfigError;
269
270    fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, ConfigError> {
271        visitor.visit_string(self.text.to_owned())
272    }
273
274    parse_as! {
275        deserialize_i8 => visit_i8, "an integer";
276        deserialize_i16 => visit_i16, "an integer";
277        deserialize_i32 => visit_i32, "an integer";
278        deserialize_i64 => visit_i64, "an integer";
279        deserialize_u8 => visit_u8, "a positive integer";
280        deserialize_u16 => visit_u16, "a positive integer";
281        deserialize_u32 => visit_u32, "a positive integer";
282        deserialize_u64 => visit_u64, "a positive integer";
283        deserialize_f32 => visit_f32, "a number";
284        deserialize_f64 => visit_f64, "a number";
285        deserialize_char => visit_char, "a single character";
286    }
287
288    fn deserialize_bool<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, ConfigError> {
289        match self.text.trim().to_ascii_lowercase().as_str() {
290            "true" | "1" => visitor.visit_bool(true),
291            "false" | "0" => visitor.visit_bool(false),
292            _ => visitor.visit_bool(self.parse::<bool>("`true` or `false`")?),
293        }
294    }
295
296    fn deserialize_option<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, ConfigError> {
297        visitor.visit_some(self)
298    }
299
300    fn deserialize_newtype_struct<V: Visitor<'de>>(
301        self,
302        _name: &'static str,
303        visitor: V,
304    ) -> Result<V::Value, ConfigError> {
305        visitor.visit_newtype_struct(self)
306    }
307
308    fn deserialize_seq<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, ConfigError> {
309        let name = self.name;
310        let items = self.text.split(',').map(str::trim).filter(|item| !item.is_empty());
311        let items: Vec<Text<'_>> = items.map(|text| Text { text, name }).collect();
312        visitor.visit_seq(de::value::SeqDeserializer::new(items.into_iter()))
313    }
314
315    fn deserialize_enum<V: Visitor<'de>>(
316        self,
317        _name: &'static str,
318        _variants: &'static [&'static str],
319        visitor: V,
320    ) -> Result<V::Value, ConfigError> {
321        visitor.visit_enum(self.text.trim().into_deserializer())
322    }
323
324    serde::forward_to_deserialize_any! {
325        i128 u128 str string bytes byte_buf unit unit_struct tuple
326        tuple_struct map struct identifier ignored_any
327    }
328}
329
330impl<'de, 'a> IntoDeserializer<'de, ConfigError> for Text<'a> {
331    type Deserializer = Self;
332
333    fn into_deserializer(self) -> Self {
334        self
335    }
336}
337
338#[cfg(test)]
339#[path = "../tests/config.rs"]
340mod tests;