Skip to main content

postrust_core/
config.rs

1//! Configuration for Postrust.
2//!
3//! Mirrors PostgREST's configuration options.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8const EXPECTED_BOOLEAN: &str = "one of: true, false, 1, 0, yes, no, on, off";
9
10/// An environment variable contains a value that cannot be used safely.
11#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
12#[error("invalid value for {variable}: {value:?}; expected {expected}")]
13pub struct ConfigError {
14    variable: &'static str,
15    value: String,
16    expected: &'static str,
17}
18
19/// Main application configuration.
20#[derive(Clone, Debug, Serialize, Deserialize)]
21#[non_exhaustive]
22pub struct AppConfig {
23    // ========================================================================
24    // Database Settings
25    // ========================================================================
26    /// PostgreSQL connection URI
27    #[serde(default = "default_db_uri")]
28    pub db_uri: String,
29
30    /// Schemas to expose via the API
31    #[serde(default = "default_db_schemas")]
32    pub db_schemas: Vec<String>,
33
34    /// Role for unauthenticated requests
35    pub db_anon_role: Option<String>,
36
37    /// Connection pool size
38    #[serde(default = "default_pool_size")]
39    pub db_pool_size: u32,
40
41    /// Pool acquisition timeout in seconds
42    #[serde(default = "default_pool_timeout")]
43    pub db_pool_timeout: u64,
44
45    /// Isolation level for the transaction every request runs in.
46    ///
47    /// `READ COMMITTED` unless set, which is PostgreSQL's own default; a
48    /// stricter level is issued as `SET TRANSACTION ISOLATION LEVEL` on the
49    /// request's transaction.
50    #[serde(default = "default_tx_isolation")]
51    pub db_tx_isolation: IsolationLevel,
52
53    /// Use prepared statements
54    #[serde(default = "default_true")]
55    pub db_prepared_statements: bool,
56
57    /// Schemas added to the `search_path` of every request, after the
58    /// request's own schema.
59    ///
60    /// These are not exposed as API endpoints; they are where a function an
61    /// exposed one calls, or a type it references, is allowed to live. An
62    /// aggregate returning a domain declared elsewhere cannot resolve that
63    /// domain without this. `public` by default, as in PostgREST.
64    #[serde(default = "default_extra_search_path")]
65    pub db_extra_search_path: Vec<String>,
66
67    /// LISTEN/NOTIFY channel for schema reload
68    #[serde(default = "default_db_channel")]
69    pub db_channel: String,
70
71    /// Enable NOTIFY-based schema cache reload
72    #[serde(default)]
73    pub db_channel_enabled: bool,
74
75    /// Function called on the request's transaction before its own query,
76    /// after every setting is applied. Anything it raises aborts the request.
77    pub db_pre_request: Option<String>,
78
79    /// Maximum rows allowed in a response
80    pub db_max_rows: Option<i64>,
81
82    /// Enable aggregate functions in `select`.
83    ///
84    /// Off by default, as in PostgREST: an aggregate over a large table costs
85    /// far more than the request looks like it asks for.
86    #[serde(default)]
87    pub db_aggregates_enabled: bool,
88
89    // ========================================================================
90    // Server Settings
91    // ========================================================================
92    /// Server host to bind
93    #[serde(default = "default_host")]
94    pub server_host: String,
95
96    /// Server port
97    #[serde(default = "default_port")]
98    pub server_port: u16,
99
100    /// Origins allowed by CORS.
101    ///
102    /// Empty means every origin is allowed, which is the default and what
103    /// `PGRST_SERVER_CORS_ORIGINS="*"` selects explicitly. A non-empty list
104    /// restricts the `Access-Control-Allow-Origin` response to exactly those
105    /// origins; anything else gets no CORS headers and is refused by the
106    /// browser.
107    #[serde(default)]
108    pub server_cors_origins: Vec<String>,
109
110    /// Maximum accepted request body, in bytes.
111    ///
112    /// The REST handler applies this when it reads the body; the routes that
113    /// take a body extractor get it through axum's `DefaultBodyLimit`. Before
114    /// this was read, the limit in force was a 10 MiB constant in the request
115    /// handler, whatever the variable said.
116    #[serde(default = "default_max_body_size")]
117    pub max_body_size: usize,
118
119    /// Listen on a Unix domain socket instead of host and port.
120    pub server_unix_socket: Option<String>,
121
122    /// Serve liveness and readiness on a second port of their own.
123    pub admin_server_port: Option<u16>,
124
125    // ========================================================================
126    // JWT Settings
127    // ========================================================================
128    /// JWT secret key (or JWKS URL)
129    pub jwt_secret: Option<String>,
130
131    /// JWT secret as base64
132    #[serde(default)]
133    pub jwt_secret_is_base64: bool,
134
135    /// JWT audience claim to validate
136    pub jwt_aud: Option<String>,
137
138    /// JWT claim that contains the role
139    #[serde(default = "default_jwt_role_claim")]
140    pub jwt_role_claim_key: String,
141
142    /// Cache validated tokens, so a repeated token is not verified again.
143    #[serde(default = "default_true")]
144    pub jwt_cache_enabled: bool,
145
146    /// How long a cached validation may be reused, in seconds.
147    ///
148    /// A token's own `exp` bounds this; the cache never answers with a token
149    /// past its expiry. `0` turns the cache off, as in PostgREST.
150    #[serde(default = "default_jwt_cache_max")]
151    pub jwt_cache_max_lifetime: u64,
152
153    // ========================================================================
154    // Hasura Authentication
155    // ========================================================================
156    /// The shared secret that authenticates an administrator, and the switch
157    /// that makes `x-hasura-*` headers mean anything at all.
158    ///
159    /// Unset, this server ignores those headers and reads session variables
160    /// only from a verified token -- because honouring a header nothing
161    /// authenticated would let any caller name its own identity, and a policy
162    /// reading a value the caller chose is not a policy. Hasura instead treats
163    /// an unsecured deployment as wholly administrative; the divergence is
164    /// deliberate and is recorded in `postrust_auth::hasura`.
165    pub hasura_admin_secret: Option<String>,
166
167    /// The role a request falls to when nothing authenticated it. Hasura's
168    /// `HASURA_GRAPHQL_UNAUTHORIZED_ROLE`; unset means such a request is
169    /// refused rather than answered as a stranger.
170    pub hasura_unauthorized_role: Option<String>,
171
172    // ========================================================================
173    // OpenAPI Settings
174    // ========================================================================
175    /// Address to advertise in the specification's `servers`, for when the
176    /// server sits behind a reverse proxy and its own address is not the one
177    /// clients use.
178    pub openapi_server_proxy_uri: Option<String>,
179
180    /// OpenAPI mode: disabled, follow-privileges, ignore-privileges
181    #[serde(default = "default_openapi_mode")]
182    pub openapi_mode: OpenApiMode,
183
184    // ========================================================================
185    // Logging Settings
186    // ========================================================================
187    /// Log level: crit, error, warn, info, debug
188    #[serde(default = "default_log_level")]
189    pub log_level: LogLevel,
190
191    // ========================================================================
192    // Role Settings
193    // ========================================================================
194    /// Per-role settings (isolation level, timeout)
195    #[serde(default)]
196    pub role_settings: HashMap<String, RoleSettings>,
197
198    /// App-level settings to expose via GUC
199    #[serde(default)]
200    pub app_settings: HashMap<String, String>,
201
202    // ========================================================================
203    // GraphQL Settings
204    // ========================================================================
205    /// Expose the GraphQL API as an Apollo Federation subgraph.
206    #[serde(default)]
207    pub graphql_federation: bool,
208
209    /// Namespace applied to generated GraphQL table types and root fields.
210    #[serde(default)]
211    pub graphql_type_prefix: Option<String>,
212
213    // ========================================================================
214    // Compatibility Settings
215    // ========================================================================
216    /// PostgREST compatibility mode.
217    ///
218    /// When enabled, the REST surface is also served at the root (so canonical
219    /// PostgREST paths like `/rpc/<name>` and `/<table>` work in addition to
220    /// the `/api`-prefixed paths), and RPC responses are un-wrapped to match
221    /// PostgREST's shape (bare object/scalar for non-set-returning functions,
222    /// a top-level array for set-returning ones) instead of the array-wrapped,
223    /// function-name-keyed default.
224    #[serde(default)]
225    pub compat_mode: bool,
226}
227
228impl Default for AppConfig {
229    fn default() -> Self {
230        Self {
231            db_uri: default_db_uri(),
232            db_schemas: default_db_schemas(),
233            db_anon_role: None,
234            db_pool_size: default_pool_size(),
235            db_pool_timeout: default_pool_timeout(),
236            db_tx_isolation: default_tx_isolation(),
237            db_prepared_statements: true,
238            db_extra_search_path: default_extra_search_path(),
239            db_channel: default_db_channel(),
240            db_channel_enabled: false,
241            db_pre_request: None,
242            db_max_rows: None,
243            db_aggregates_enabled: false,
244            server_host: default_host(),
245            server_port: default_port(),
246            server_cors_origins: Vec::new(),
247            max_body_size: default_max_body_size(),
248            server_unix_socket: None,
249            admin_server_port: None,
250            jwt_secret: None,
251            jwt_secret_is_base64: false,
252            jwt_aud: None,
253            jwt_role_claim_key: default_jwt_role_claim(),
254            jwt_cache_enabled: true,
255            jwt_cache_max_lifetime: default_jwt_cache_max(),
256            hasura_admin_secret: None,
257            hasura_unauthorized_role: None,
258            openapi_server_proxy_uri: None,
259            openapi_mode: OpenApiMode::FollowPrivileges,
260            log_level: default_log_level(),
261            role_settings: HashMap::new(),
262            app_settings: HashMap::new(),
263            graphql_federation: false,
264            graphql_type_prefix: None,
265            compat_mode: false,
266        }
267    }
268}
269
270/// Say that a variable was set to something unusable, rather than silently
271/// applying the default. A rejected value and an unset one are different
272/// mistakes and the operator can only fix the one they are told about.
273fn warn_ignored(var: &str, value: &str, expected: &str) {
274    tracing::warn!("Ignoring {}={:?}: expected {}", var, value, expected);
275}
276
277/// Parse the explicit spellings accepted for boolean environment variables.
278fn parse_bool(value: &str) -> Option<bool> {
279    match value.trim().to_ascii_lowercase().as_str() {
280        "true" | "1" | "yes" | "on" => Some(true),
281        "false" | "0" | "no" | "off" => Some(false),
282        _ => None,
283    }
284}
285
286fn env_bool(variable: &'static str, value: &str) -> Result<bool, ConfigError> {
287    parse_bool(value).ok_or_else(|| {
288        let error = ConfigError {
289            variable,
290            value: value.to_string(),
291            expected: EXPECTED_BOOLEAN,
292        };
293        tracing::warn!("{error}");
294        error
295    })
296}
297
298/// Validate a namespace before it becomes part of generated GraphQL names.
299fn parse_graphql_type_prefix(value: &str) -> Result<Option<String>, &'static str> {
300    let value = value.trim();
301    if value.is_empty() {
302        return Ok(None);
303    }
304
305    let mut chars = value.chars();
306    let valid_first = chars
307        .next()
308        .is_some_and(|c| c == '_' || c.is_ascii_alphabetic());
309    if !valid_first || !chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) {
310        return Err("a GraphQL identifier beginning with a letter or underscore");
311    }
312
313    Ok(Some(value.to_string()))
314}
315
316/// The name an environment variable is looked up by. Windows environment
317/// names are case-insensitive, and `std::env::var("PGRST_DB_URI")` finds
318/// `pgrst_db_uri` there; the names collected for [`AppConfig::try_from_vars`]
319/// are matched exactly, so on Windows they are upper-cased to keep that.
320/// App settings lose nothing by it: their names are lower-cased anyway.
321fn env_name(name: String) -> String {
322    if cfg!(windows) {
323        upper_env_name(name)
324    } else {
325        name
326    }
327}
328
329fn upper_env_name(mut name: String) -> String {
330    name.make_ascii_uppercase();
331    name
332}
333
334impl AppConfig {
335    /// Load configuration from environment variables.
336    ///
337    /// # Panics
338    ///
339    /// Panics if a boolean environment variable has an unsupported value. Use
340    /// [`Self::try_from_env`] when the caller can propagate a startup error.
341    pub fn from_env() -> Self {
342        Self::try_from_env().unwrap_or_else(|error| panic!("{error}"))
343    }
344
345    /// Load configuration from environment variables, rejecting invalid
346    /// boolean values.
347    pub fn try_from_env() -> Result<Self, ConfigError> {
348        // A variable whose name or value is not UTF-8 is skipped, as
349        // `std::env::var` would have refused it.
350        Self::try_from_vars(
351            std::env::vars_os().filter_map(|(k, v)| {
352                Some((env_name(k.into_string().ok()?), v.into_string().ok()?))
353            }),
354        )
355    }
356
357    /// Load configuration from name/value pairs spelled as the environment
358    /// variables are, for a host with no process environment -- a Cloudflare
359    /// Worker reads its bindings instead. Where a name appears twice, the last
360    /// one wins. Names are matched exactly, case included.
361    pub fn try_from_vars<I, K, V>(vars: I) -> Result<Self, ConfigError>
362    where
363        I: IntoIterator<Item = (K, V)>,
364        K: Into<String>,
365        V: Into<String>,
366    {
367        // Kept in the order given as well as looked up by name: the app
368        // settings are found by walking the pairs, and two of them that differ
369        // only in case name the same setting, so which one wins has to depend
370        // on that order and not on a hash.
371        let pairs: Vec<(String, String)> = vars
372            .into_iter()
373            .map(|(k, v)| (k.into(), v.into()))
374            .collect();
375        let vars: HashMap<&str, &str> = pairs
376            .iter()
377            .map(|(k, v)| (k.as_str(), v.as_str()))
378            .collect();
379        let get = |key: &str| vars.get(key).map(|v| v.to_string()).ok_or(());
380        let mut config = Self::default();
381
382        if let Ok(uri) = get("PGRST_DB_URI") {
383            config.db_uri = uri;
384        }
385        if let Ok(uri) = get("DATABASE_URL") {
386            config.db_uri = uri;
387        }
388        if let Ok(schemas) = get("PGRST_DB_SCHEMAS") {
389            config.db_schemas = schemas.split(',').map(|s| s.trim().to_string()).collect();
390        }
391        if let Ok(role) = get("PGRST_DB_ANON_ROLE") {
392            config.db_anon_role = Some(role);
393        }
394        // `PGRST_DB_POOL` is PostgREST's own name; `PGRST_DB_POOL_SIZE` is the
395        // one this project's documentation has always given, including the
396        // production checklist in docs/deployment.md. Only the former was read,
397        // so anybody who followed our own instructions silently kept the
398        // default pool.
399        for var in ["PGRST_DB_POOL", "PGRST_DB_POOL_SIZE"] {
400            if let Ok(size) = get(var) {
401                match size.parse::<u32>() {
402                    Ok(n) if n > 0 => config.db_pool_size = n,
403                    _ => warn_ignored(var, &size, "a positive integer"),
404                }
405            }
406        }
407        if let Ok(secs) = get("PGRST_DB_POOL_TIMEOUT") {
408            match secs.parse::<u64>() {
409                Ok(n) if n > 0 => config.db_pool_timeout = n,
410                _ => warn_ignored("PGRST_DB_POOL_TIMEOUT", &secs, "a positive integer"),
411            }
412        }
413        if let Ok(level) = get("PGRST_DB_TX_ISOLATION") {
414            match IsolationLevel::parse_config(&level) {
415                Some(l) => config.db_tx_isolation = l,
416                None => warn_ignored(
417                    "PGRST_DB_TX_ISOLATION",
418                    &level,
419                    "one of: read committed, repeatable read, serializable",
420                ),
421            }
422        }
423        if let Ok(v) = get("PGRST_DB_EXTRA_SEARCH_PATH") {
424            config.db_extra_search_path = v
425                .split(',')
426                .map(|s| s.trim().to_string())
427                .filter(|s| !s.is_empty())
428                .collect();
429        }
430
431        if let Ok(v) = get("PGRST_DB_AGGREGATES_ENABLED") {
432            config.db_aggregates_enabled = env_bool("PGRST_DB_AGGREGATES_ENABLED", &v)?;
433        }
434        if let Ok(v) = get("PGRST_DB_PREPARED_STATEMENTS") {
435            config.db_prepared_statements = env_bool("PGRST_DB_PREPARED_STATEMENTS", &v)?;
436        }
437        if let Ok(v) = get("PGRST_DB_PRE_REQUEST") {
438            if !v.trim().is_empty() {
439                config.db_pre_request = Some(v.trim().to_string());
440            }
441        }
442        if let Ok(v) = get("PGRST_DB_CHANNEL") {
443            if !v.trim().is_empty() {
444                config.db_channel = v.trim().to_string();
445            }
446        }
447        if let Ok(v) = get("PGRST_DB_CHANNEL_ENABLED") {
448            config.db_channel_enabled = env_bool("PGRST_DB_CHANNEL_ENABLED", &v)?;
449        }
450
451        // `PGRST_DB_MAX_ROWS` mirrors PostgREST's `db-max-rows`. Accept both.
452        for var in ["PGRST_DB_MAX_ROWS", "PGRST_MAX_ROWS"] {
453            if let Ok(max_rows) = get(var) {
454                match max_rows.parse::<i64>() {
455                    Ok(n) if n >= 0 => config.db_max_rows = Some(n),
456                    _ => warn_ignored(var, &max_rows, "a non-negative integer"),
457                }
458            }
459        }
460        if let Ok(secret) = get("PGRST_JWT_SECRET") {
461            config.jwt_secret = Some(secret);
462        }
463        if let Ok(aud) = get("PGRST_JWT_AUD") {
464            config.jwt_aud = Some(aud);
465        }
466        // Both of these reach the JWT layer already -- `JwtConfig` is built
467        // from them in every binary. Only the environment read was missing, so
468        // a base64 secret was verified against its own text and a role claim
469        // outside `role` was never found.
470        if let Ok(v) = get("PGRST_JWT_SECRET_IS_BASE64") {
471            config.jwt_secret_is_base64 = env_bool("PGRST_JWT_SECRET_IS_BASE64", &v)?;
472        }
473        if let Ok(key) = get("PGRST_JWT_ROLE_CLAIM_KEY") {
474            if !key.trim().is_empty() {
475                config.jwt_role_claim_key = key;
476            }
477        }
478        if let Ok(v) = get("PGRST_JWT_CACHE_ENABLED") {
479            config.jwt_cache_enabled = env_bool("PGRST_JWT_CACHE_ENABLED", &v)?;
480        }
481        // Zero is meaningful here -- it turns the cache off, as in PostgREST --
482        // so unlike the other durations it is not rejected.
483        if let Ok(v) = get("PGRST_JWT_CACHE_MAX_LIFETIME") {
484            match v.parse::<u64>() {
485                Ok(n) => config.jwt_cache_max_lifetime = n,
486                Err(_) => warn_ignored(
487                    "PGRST_JWT_CACHE_MAX_LIFETIME",
488                    &v,
489                    "a non-negative integer number of seconds",
490                ),
491            }
492        }
493        // Hasura's own spelling is accepted beside ours, because a deployment
494        // migrating from it has these in a compose file already and the point
495        // of the mode is that the client does not have to be rewritten. The
496        // `PGRST_` name wins where both are set, on the grounds that it is the
497        // more specific instruction to this server.
498        for var in ["HASURA_GRAPHQL_ADMIN_SECRET", "PGRST_HASURA_ADMIN_SECRET"] {
499            if let Ok(secret) = get(var) {
500                if !secret.is_empty() {
501                    config.hasura_admin_secret = Some(secret);
502                }
503            }
504        }
505        for var in [
506            "HASURA_GRAPHQL_UNAUTHORIZED_ROLE",
507            "PGRST_HASURA_UNAUTHORIZED_ROLE",
508        ] {
509            if let Ok(role) = get(var) {
510                if !role.is_empty() {
511                    config.hasura_unauthorized_role = Some(role);
512                }
513            }
514        }
515        if let Ok(host) = get("PGRST_SERVER_HOST") {
516            config.server_host = host;
517        }
518        if let Ok(port) = get("PGRST_SERVER_PORT") {
519            if let Ok(p) = port.parse() {
520                config.server_port = p;
521            }
522        }
523        if let Ok(port) = get("PORT") {
524            if let Ok(p) = port.parse() {
525                config.server_port = p;
526            }
527        }
528        // A bare `*` is the documented way to spell "any origin", and is the
529        // default. An empty list means the same thing, so a value of only
530        // separators does not accidentally lock everybody out.
531        if let Ok(origins) = get("PGRST_SERVER_CORS_ORIGINS") {
532            config.server_cors_origins = if origins.trim() == "*" {
533                Vec::new()
534            } else {
535                origins
536                    .split(',')
537                    .map(|s| s.trim().to_string())
538                    .filter(|s| !s.is_empty())
539                    .collect()
540            };
541        }
542        if let Ok(bytes) = get("PGRST_MAX_BODY_SIZE") {
543            match bytes.parse::<usize>() {
544                Ok(n) if n > 0 => config.max_body_size = n,
545                _ => warn_ignored("PGRST_MAX_BODY_SIZE", &bytes, "a positive integer"),
546            }
547        }
548        if let Ok(level) = get("PGRST_LOG_LEVEL") {
549            match LogLevel::parse_config(&level) {
550                Some(l) => config.log_level = l,
551                None => warn_ignored(
552                    "PGRST_LOG_LEVEL",
553                    &level,
554                    "one of: crit, error, warn, info, debug",
555                ),
556            }
557        }
558        if let Ok(path) = get("PGRST_SERVER_UNIX_SOCKET") {
559            if !path.trim().is_empty() {
560                config.server_unix_socket = Some(path.trim().to_string());
561            }
562        }
563        if let Ok(port) = get("PGRST_ADMIN_SERVER_PORT") {
564            match port.parse::<u16>() {
565                Ok(p) if p > 0 => config.admin_server_port = Some(p),
566                _ => warn_ignored("PGRST_ADMIN_SERVER_PORT", &port, "a port number"),
567            }
568        }
569        if let Ok(uri) = get("PGRST_OPENAPI_SERVER_PROXY_URI") {
570            if !uri.trim().is_empty() {
571                config.openapi_server_proxy_uri = Some(uri.trim().to_string());
572            }
573        }
574        if let Ok(mode) = get("PGRST_OPENAPI_MODE") {
575            match OpenApiMode::parse_config(&mode) {
576                Some(m) => config.openapi_mode = m,
577                None => warn_ignored(
578                    "PGRST_OPENAPI_MODE",
579                    &mode,
580                    "one of: disabled, follow-privileges, ignore-privileges",
581                ),
582            }
583        }
584
585        // `PGRST_ROLE_SETTINGS` is JSON rather than a per-role variable,
586        // because a PostgreSQL role name may hold characters an environment
587        // variable name cannot.
588        if let Ok(raw) = get("PGRST_ROLE_SETTINGS") {
589            match serde_json::from_str::<HashMap<String, RoleSettings>>(&raw) {
590                Ok(settings) => config.role_settings = settings,
591                Err(e) => warn_ignored(
592                    "PGRST_ROLE_SETTINGS",
593                    &raw,
594                    &format!("a JSON object of role to settings ({e})"),
595                ),
596            }
597        }
598
599        if let Ok(value) = get("PGRST_GRAPHQL_FEDERATION") {
600            config.graphql_federation = env_bool("PGRST_GRAPHQL_FEDERATION", &value)?;
601        }
602        if let Ok(value) = get("PGRST_GRAPHQL_TYPE_PREFIX") {
603            match parse_graphql_type_prefix(&value) {
604                Ok(prefix) => config.graphql_type_prefix = prefix,
605                Err(expected) => warn_ignored("PGRST_GRAPHQL_TYPE_PREFIX", &value, expected),
606            }
607        }
608
609        // `PGRST_APP_SETTINGS_<NAME>` becomes `app.settings.<name>`, which is
610        // where PostgREST puts it and where a policy expects to read it back.
611        // Names that differ only in case (`..._Tenant`, `..._TENANT`) are the
612        // same setting; the one that comes later in the pairs wins, as the
613        // later of two equal names does.
614        for (key, value) in &pairs {
615            if let Some(name) = key.strip_prefix("PGRST_APP_SETTINGS_") {
616                if !name.is_empty() {
617                    config
618                        .app_settings
619                        .insert(name.to_ascii_lowercase(), value.clone());
620                }
621            }
622        }
623        // Accept either the PGRST_-prefixed name (for parity with other options)
624        // or a POSTRUST_-prefixed alias.
625        for var in ["PGRST_COMPAT_MODE", "POSTRUST_COMPAT_MODE"] {
626            if let Ok(v) = get(var) {
627                config.compat_mode = env_bool(var, &v)?;
628            }
629        }
630
631        Ok(config)
632    }
633
634    /// Get the default schema (first in the list).
635    pub fn default_schema(&self) -> &str {
636        self.db_schemas
637            .first()
638            .map(|s| s.as_str())
639            .unwrap_or("public")
640    }
641}
642
643// Serialized as the spelling configuration uses, not as the Rust variant name,
644// so that what this writes is what `parse_config` reads back. Deriving
645// `Serialize` here instead emits `"ReadCommitted"`, which then fails to
646// deserialize -- and `Serializable` happens to survive the round trip, which
647// makes the breakage look intermittent rather than total.
648impl Serialize for IsolationLevel {
649    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
650        serializer.serialize_str(self.as_config_str())
651    }
652}
653
654impl<'de> Deserialize<'de> for IsolationLevel {
655    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
656        let raw = String::deserialize(deserializer)?;
657        Self::parse_config(&raw).ok_or_else(|| {
658            serde::de::Error::custom(format!(
659                "unknown isolation level {raw:?}: expected one of \
660                 read committed, repeatable read, serializable"
661            ))
662        })
663    }
664}
665
666impl Serialize for OpenApiMode {
667    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
668        serializer.serialize_str(self.as_config_str())
669    }
670}
671
672impl<'de> Deserialize<'de> for OpenApiMode {
673    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
674        let raw = String::deserialize(deserializer)?;
675        Self::parse_config(&raw).ok_or_else(|| {
676            serde::de::Error::custom(format!(
677                "unknown OpenAPI mode {raw:?}: expected one of \
678                 disabled, follow-privileges, ignore-privileges"
679            ))
680        })
681    }
682}
683
684impl Serialize for LogLevel {
685    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
686        serializer.serialize_str(self.as_config_str())
687    }
688}
689
690impl<'de> Deserialize<'de> for LogLevel {
691    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
692        let raw = String::deserialize(deserializer)?;
693        Self::parse_config(&raw).ok_or_else(|| {
694            serde::de::Error::custom(format!(
695                "unknown log level {raw:?}: expected one of \
696                 crit, error, warn, info, debug"
697            ))
698        })
699    }
700}
701
702/// Per-role settings.
703#[derive(Clone, Debug, Default, Serialize, Deserialize)]
704#[non_exhaustive]
705pub struct RoleSettings {
706    /// Isolation level for this role
707    pub isolation_level: Option<IsolationLevel>,
708    /// Statement timeout in milliseconds
709    pub statement_timeout: Option<u64>,
710}
711
712/// Transaction isolation levels.
713///
714/// Deserialized from the spelling the documentation uses (`"serializable"`,
715/// `"read committed"`), not from the Rust variant name, so a `role_settings`
716/// entry reads the same as the environment variable.
717#[derive(Clone, Debug, PartialEq, Eq)]
718pub enum IsolationLevel {
719    /// PostgreSQL's default. A statement sees rows committed before it began.
720    ReadCommitted,
721    /// Every statement in the transaction sees the same snapshot.
722    RepeatableRead,
723    /// As `RepeatableRead`, and concurrent transactions are additionally
724    /// guaranteed to be equivalent to some serial order. May abort with a
725    /// serialization failure, which the client is expected to retry.
726    Serializable,
727}
728
729impl IsolationLevel {
730    /// The spelling PostgreSQL's `SET TRANSACTION ISOLATION LEVEL` expects.
731    pub fn to_sql(&self) -> &'static str {
732        match self {
733            Self::ReadCommitted => "READ COMMITTED",
734            Self::RepeatableRead => "REPEATABLE READ",
735            Self::Serializable => "SERIALIZABLE",
736        }
737    }
738
739    /// Parse the spelling used in configuration.
740    ///
741    /// Case-insensitive, and a hyphen, underscore or space separates the words
742    /// equally -- the documentation writes `read committed` and an environment
743    /// file is as likely to carry `read-committed`.
744    pub fn parse_config(s: &str) -> Option<Self> {
745        match s
746            .trim()
747            .to_ascii_lowercase()
748            .replace(['-', '_'], " ")
749            .as_str()
750        {
751            "read committed" => Some(Self::ReadCommitted),
752            "repeatable read" => Some(Self::RepeatableRead),
753            "serializable" => Some(Self::Serializable),
754            _ => None,
755        }
756    }
757
758    /// The inverse of [`parse_config`](Self::parse_config).
759    pub fn as_config_str(&self) -> &'static str {
760        match self {
761            Self::ReadCommitted => "read committed",
762            Self::RepeatableRead => "repeatable read",
763            Self::Serializable => "serializable",
764        }
765    }
766}
767
768/// OpenAPI generation mode.
769///
770/// PostgREST also has a `security-definer` mode, where the specification comes
771/// from a user-supplied `SECURITY DEFINER` function. There is no configuration
772/// here for naming that function, so the variant is absent rather than present
773/// and behaving as one of the others.
774#[derive(Clone, Debug, PartialEq, Eq)]
775pub enum OpenApiMode {
776    /// No OpenAPI document is served.
777    Disabled,
778    /// The document describes only what the requesting role may reach, so two
779    /// roles asking are answered differently.
780    FollowPrivileges,
781    /// The document describes the whole exposed schema, whoever is asking.
782    IgnorePrivileges,
783}
784
785impl OpenApiMode {
786    /// Parse the spelling used in configuration. Case-insensitive, and a
787    /// hyphen or an underscore separates the words equally.
788    pub fn parse_config(s: &str) -> Option<Self> {
789        match s
790            .trim()
791            .to_ascii_lowercase()
792            .replace(['-', '_'], " ")
793            .as_str()
794        {
795            "disabled" => Some(Self::Disabled),
796            "follow privileges" => Some(Self::FollowPrivileges),
797            "ignore privileges" => Some(Self::IgnorePrivileges),
798            _ => None,
799        }
800    }
801
802    /// The inverse of [`parse_config`](Self::parse_config).
803    pub fn as_config_str(&self) -> &'static str {
804        match self {
805            Self::Disabled => "disabled",
806            Self::FollowPrivileges => "follow-privileges",
807            Self::IgnorePrivileges => "ignore-privileges",
808        }
809    }
810}
811
812/// Log levels.
813#[derive(Clone, Debug, PartialEq, Eq)]
814pub enum LogLevel {
815    /// Only failures that stop the server. Mapped onto `ERROR`, which is the
816    /// least severe level `tracing` offers.
817    Crit,
818    /// Requests that failed, and faults that did not stop the server.
819    Error,
820    /// Configuration that was ignored, and conditions worth noticing.
821    Warn,
822    /// Start-up, schema reloads, and one line per request.
823    Info,
824    /// The generated SQL and the decisions behind it.
825    Debug,
826}
827
828impl LogLevel {
829    /// The `tracing` level this maps onto.
830    ///
831    /// `Crit` and `Error` both become `ERROR`: PostgREST distinguishes them
832    /// and `tracing` has nothing more severe to map `Crit` onto.
833    pub fn to_tracing(&self) -> tracing::Level {
834        match self {
835            Self::Crit | Self::Error => tracing::Level::ERROR,
836            Self::Warn => tracing::Level::WARN,
837            Self::Info => tracing::Level::INFO,
838            Self::Debug => tracing::Level::DEBUG,
839        }
840    }
841
842    /// Parse the spelling used in configuration. Case-insensitive.
843    pub fn parse_config(s: &str) -> Option<Self> {
844        match s.trim().to_ascii_lowercase().as_str() {
845            "crit" | "critical" => Some(Self::Crit),
846            "error" => Some(Self::Error),
847            "warn" | "warning" => Some(Self::Warn),
848            "info" => Some(Self::Info),
849            "debug" => Some(Self::Debug),
850            _ => None,
851        }
852    }
853
854    /// The inverse of [`parse_config`](Self::parse_config).
855    pub fn as_config_str(&self) -> &'static str {
856        match self {
857            Self::Crit => "crit",
858            Self::Error => "error",
859            Self::Warn => "warn",
860            Self::Info => "info",
861            Self::Debug => "debug",
862        }
863    }
864}
865
866// Default value functions
867fn default_db_uri() -> String {
868    "postgresql://localhost/postgres".to_string()
869}
870
871fn default_db_schemas() -> Vec<String> {
872    vec!["public".to_string()]
873}
874
875fn default_pool_size() -> u32 {
876    10
877}
878
879fn default_pool_timeout() -> u64 {
880    10
881}
882
883fn default_tx_isolation() -> IsolationLevel {
884    IsolationLevel::ReadCommitted
885}
886
887fn default_max_body_size() -> usize {
888    10 * 1024 * 1024
889}
890
891fn default_extra_search_path() -> Vec<String> {
892    vec!["public".to_string()]
893}
894
895fn default_db_channel() -> String {
896    "pgrst".to_string()
897}
898
899fn default_host() -> String {
900    "127.0.0.1".to_string()
901}
902
903fn default_port() -> u16 {
904    3000
905}
906
907fn default_jwt_role_claim() -> String {
908    "role".to_string()
909}
910
911fn default_jwt_cache_max() -> u64 {
912    3600
913}
914
915fn default_openapi_mode() -> OpenApiMode {
916    OpenApiMode::FollowPrivileges
917}
918
919// `info`, not `error`. The documentation has always said `info`, and the
920// server's own fallback filter is `postrust=info`, so `error` was a third
921// answer that agreed with neither -- harmless only for as long as nothing
922// read the field.
923fn default_log_level() -> LogLevel {
924    LogLevel::Info
925}
926
927fn default_true() -> bool {
928    true
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934
935    #[test]
936    fn test_default_config() {
937        let config = AppConfig::default();
938        assert_eq!(config.server_port, 3000);
939        assert_eq!(config.db_pool_size, 10);
940        assert!(config.db_prepared_statements);
941        assert!(!config.graphql_federation);
942        assert_eq!(config.graphql_type_prefix, None);
943    }
944
945    #[test]
946    fn test_default_schema() {
947        let mut config = AppConfig::default();
948        assert_eq!(config.default_schema(), "public");
949
950        config.db_schemas = vec!["api".to_string(), "public".to_string()];
951        assert_eq!(config.default_schema(), "api");
952    }
953
954    #[test]
955    fn test_isolation_level_sql() {
956        assert_eq!(IsolationLevel::ReadCommitted.to_sql(), "READ COMMITTED");
957        assert_eq!(IsolationLevel::Serializable.to_sql(), "SERIALIZABLE");
958    }
959
960    #[test]
961    fn isolation_level_accepts_the_documented_spellings() {
962        // The documentation writes `read committed`; an environment file is as
963        // likely to carry either separator, or capitals.
964        for s in [
965            "read committed",
966            "READ COMMITTED",
967            "read-committed",
968            " Read_Committed ",
969        ] {
970            assert_eq!(
971                IsolationLevel::parse_config(s),
972                Some(IsolationLevel::ReadCommitted),
973                "{s:?}"
974            );
975        }
976        assert_eq!(
977            IsolationLevel::parse_config("serializable"),
978            Some(IsolationLevel::Serializable)
979        );
980        assert_eq!(
981            IsolationLevel::parse_config("repeatable read"),
982            Some(IsolationLevel::RepeatableRead)
983        );
984        // Not a level. Rejected rather than resolved to a default, so the
985        // caller can say so.
986        assert_eq!(IsolationLevel::parse_config("dirty read"), None);
987        assert_eq!(IsolationLevel::parse_config(""), None);
988    }
989
990    #[test]
991    fn log_level_accepts_the_documented_spellings() {
992        assert_eq!(LogLevel::parse_config("INFO"), Some(LogLevel::Info));
993        assert_eq!(LogLevel::parse_config("warning"), Some(LogLevel::Warn));
994        assert_eq!(LogLevel::parse_config("crit"), Some(LogLevel::Crit));
995        // `trace` was listed in the documentation and is not a level this
996        // server has. It is rejected rather than silently read as `debug`.
997        assert_eq!(LogLevel::parse_config("trace"), None);
998    }
999
1000    #[test]
1001    fn default_log_level_matches_the_documented_one() {
1002        // The struct default disagreed with both the documentation and the
1003        // server's own fallback filter for as long as nothing read it.
1004        assert_eq!(AppConfig::default().log_level, LogLevel::Info);
1005    }
1006
1007    #[test]
1008    fn parse_bool_accepts_only_the_supported_spellings() {
1009        for s in ["true", "TRUE", "1", "yes", "on", " On "] {
1010            assert_eq!(parse_bool(s), Some(true), "{s:?}");
1011        }
1012        for s in ["false", "FALSE", "0", "no", "off", " Off "] {
1013            assert_eq!(parse_bool(s), Some(false), "{s:?}");
1014        }
1015        for s in ["", "maybe", "enabled", "2"] {
1016            assert_eq!(parse_bool(s), None, "{s:?}");
1017        }
1018    }
1019
1020    #[test]
1021    fn graphql_federation_configuration_is_read() {
1022        let config = with_env(&[
1023            ("PGRST_GRAPHQL_FEDERATION", "true"),
1024            ("PGRST_GRAPHQL_TYPE_PREFIX", " test "),
1025        ]);
1026
1027        assert!(config.graphql_federation);
1028        assert_eq!(config.graphql_type_prefix.as_deref(), Some("test"));
1029    }
1030
1031    #[test]
1032    fn graphql_federation_accepts_an_explicit_false() {
1033        assert!(!with_env(&[("PGRST_GRAPHQL_FEDERATION", "false")]).graphql_federation);
1034    }
1035
1036    #[test]
1037    fn malformed_graphql_federation_configuration_is_rejected() {
1038        let error = with_env_result(&[("PGRST_GRAPHQL_FEDERATION", "sometimes")])
1039            .expect_err("an invalid boolean must reject the configuration");
1040
1041        assert_eq!(
1042            error.to_string(),
1043            "invalid value for PGRST_GRAPHQL_FEDERATION: \"sometimes\"; expected one of: true, false, 1, 0, yes, no, on, off"
1044        );
1045    }
1046
1047    #[test]
1048    fn an_empty_graphql_prefix_means_unset() {
1049        let config = with_env(&[("PGRST_GRAPHQL_TYPE_PREFIX", "   ")]);
1050
1051        assert_eq!(config.graphql_type_prefix, None);
1052    }
1053
1054    // `from_env` reads process-global state, so these run one at a time.
1055    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1056
1057    /// Set the given variables, load, then remove them again.
1058    fn with_env(vars: &[(&str, &str)]) -> AppConfig {
1059        with_env_result(vars).expect("test configuration should be valid")
1060    }
1061
1062    fn with_env_result(vars: &[(&str, &str)]) -> Result<AppConfig, ConfigError> {
1063        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1064        for (k, v) in vars {
1065            std::env::set_var(k, v);
1066        }
1067        let config = AppConfig::try_from_env();
1068        for (k, _) in vars {
1069            std::env::remove_var(k);
1070        }
1071        config
1072    }
1073
1074    #[test]
1075    fn documented_variables_are_read() {
1076        // Every one of these was documented and read by nothing. The features
1077        // behind them already worked -- only the environment read was missing.
1078        let config = with_env(&[
1079            ("PGRST_DB_POOL_SIZE", "42"),
1080            ("PGRST_DB_POOL_TIMEOUT", "7"),
1081            ("PGRST_DB_TX_ISOLATION", "serializable"),
1082            ("PGRST_JWT_SECRET_IS_BASE64", "true"),
1083            ("PGRST_JWT_ROLE_CLAIM_KEY", "user.role"),
1084            ("PGRST_LOG_LEVEL", "warn"),
1085            ("PGRST_MAX_BODY_SIZE", "1024"),
1086            (
1087                "PGRST_SERVER_CORS_ORIGINS",
1088                "https://a.example, https://b.example",
1089            ),
1090        ]);
1091
1092        assert_eq!(config.db_pool_size, 42);
1093        assert_eq!(config.db_pool_timeout, 7);
1094        assert_eq!(config.db_tx_isolation, IsolationLevel::Serializable);
1095        assert!(config.jwt_secret_is_base64);
1096        assert_eq!(config.jwt_role_claim_key, "user.role");
1097        assert_eq!(config.log_level, LogLevel::Warn);
1098        assert_eq!(config.max_body_size, 1024);
1099        assert_eq!(
1100            config.server_cors_origins,
1101            vec![
1102                "https://a.example".to_string(),
1103                "https://b.example".to_string()
1104            ]
1105        );
1106    }
1107
1108    #[test]
1109    fn variables_can_come_from_somewhere_other_than_the_environment() {
1110        // What a Cloudflare Worker does with its bindings: nothing here is in
1111        // the process environment, and the app settings, which are found by
1112        // prefix rather than by name, arrive all the same.
1113        let config = AppConfig::try_from_vars([
1114            ("PGRST_DB_SCHEMAS", "api, extra"),
1115            ("PGRST_DB_ANON_ROLE", "web_anon"),
1116            ("PGRST_JWT_SECRET", "a-secret"),
1117            ("PGRST_DB_MAX_ROWS", "100"),
1118            ("PGRST_APP_SETTINGS_Tenant", "acme"),
1119        ])
1120        .unwrap();
1121
1122        assert_eq!(config.db_schemas, ["api", "extra"]);
1123        assert_eq!(config.db_anon_role.as_deref(), Some("web_anon"));
1124        assert_eq!(config.jwt_secret.as_deref(), Some("a-secret"));
1125        assert_eq!(config.db_max_rows, Some(100));
1126        assert_eq!(
1127            config.app_settings.get("tenant").map(String::as_str),
1128            Some("acme")
1129        );
1130
1131        let refused = AppConfig::try_from_vars([("PGRST_DB_AGGREGATES_ENABLED", "maybe")]);
1132        assert!(refused.is_err());
1133    }
1134
1135    #[test]
1136    fn app_settings_differing_only_in_case_take_the_later_value() {
1137        // Both name `app.settings.tenant`. Run it a few times: with the pairs
1138        // in a hash map the winner changed from one run to the next.
1139        for _ in 0..16 {
1140            let config = AppConfig::try_from_vars([
1141                ("PGRST_APP_SETTINGS_Tenant", "first"),
1142                ("PGRST_APP_SETTINGS_TENANT", "second"),
1143                ("PGRST_APP_SETTINGS_tenant", "third"),
1144            ])
1145            .unwrap();
1146            assert_eq!(
1147                config.app_settings.get("tenant").map(String::as_str),
1148                Some("third")
1149            );
1150
1151            let config = AppConfig::try_from_vars([
1152                ("PGRST_APP_SETTINGS_tenant", "third"),
1153                ("PGRST_APP_SETTINGS_TENANT", "second"),
1154            ])
1155            .unwrap();
1156            assert_eq!(
1157                config.app_settings.get("tenant").map(String::as_str),
1158                Some("second")
1159            );
1160        }
1161    }
1162
1163    #[test]
1164    fn windows_environment_names_are_matched_whatever_their_case() {
1165        // What `env_name` does on Windows, where `std::env::var` ignored case.
1166        let config = AppConfig::try_from_vars([
1167            (upper_env_name("pgrst_db_schemas".into()), "api"),
1168            (upper_env_name("Pgrst_App_Settings_Tenant".into()), "acme"),
1169        ])
1170        .unwrap();
1171        assert_eq!(config.db_schemas, ["api"]);
1172        assert_eq!(
1173            config.app_settings.get("tenant").map(String::as_str),
1174            Some("acme")
1175        );
1176        assert_eq!(env_name("PGRST_DB_URI".into()), "PGRST_DB_URI");
1177        assert_eq!(
1178            env_name("pgrst_db_uri".into()) == "PGRST_DB_URI",
1179            cfg!(windows)
1180        );
1181    }
1182
1183    #[test]
1184    fn pool_size_accepts_postgrests_own_name() {
1185        assert_eq!(with_env(&[("PGRST_DB_POOL", "5")]).db_pool_size, 5);
1186    }
1187
1188    #[test]
1189    fn a_star_origin_means_any_origin() {
1190        // Empty is how "any" is carried, so `*` must not become a literal
1191        // origin that matches nothing.
1192        assert!(with_env(&[("PGRST_SERVER_CORS_ORIGINS", "*")])
1193            .server_cors_origins
1194            .is_empty());
1195        assert!(with_env(&[("PGRST_SERVER_CORS_ORIGINS", " , ")])
1196            .server_cors_origins
1197            .is_empty());
1198    }
1199
1200    #[test]
1201    fn an_unusable_value_leaves_the_default() {
1202        let config = with_env(&[
1203            ("PGRST_DB_POOL_SIZE", "lots"),
1204            ("PGRST_DB_POOL_TIMEOUT", "0"),
1205            ("PGRST_MAX_BODY_SIZE", "-1"),
1206            ("PGRST_DB_TX_ISOLATION", "dirty read"),
1207            ("PGRST_LOG_LEVEL", "shout"),
1208        ]);
1209
1210        let d = AppConfig::default();
1211        assert_eq!(config.db_pool_size, d.db_pool_size);
1212        assert_eq!(config.db_pool_timeout, d.db_pool_timeout);
1213        assert_eq!(config.max_body_size, d.max_body_size);
1214        assert_eq!(config.db_tx_isolation, d.db_tx_isolation);
1215        assert_eq!(config.log_level, d.log_level);
1216    }
1217
1218    #[test]
1219    fn the_formerly_dead_fields_are_read() {
1220        // Every one of these was a public field that nothing set and nothing
1221        // consumed.
1222        let config = with_env(&[
1223            ("PGRST_DB_PREPARED_STATEMENTS", "false"),
1224            ("PGRST_DB_PRE_REQUEST", "auth.check"),
1225            ("PGRST_DB_CHANNEL", "my_channel"),
1226            ("PGRST_DB_CHANNEL_ENABLED", "yes"),
1227            ("PGRST_JWT_CACHE_ENABLED", "false"),
1228            ("PGRST_JWT_CACHE_MAX_LIFETIME", "60"),
1229            ("PGRST_SERVER_UNIX_SOCKET", "/tmp/postrust.sock"),
1230            ("PGRST_ADMIN_SERVER_PORT", "3001"),
1231            ("PGRST_OPENAPI_SERVER_PROXY_URI", "https://api.example.com"),
1232            ("PGRST_OPENAPI_MODE", "ignore-privileges"),
1233        ]);
1234
1235        assert!(!config.db_prepared_statements);
1236        assert_eq!(config.db_pre_request.as_deref(), Some("auth.check"));
1237        assert_eq!(config.db_channel, "my_channel");
1238        assert!(config.db_channel_enabled);
1239        assert!(!config.jwt_cache_enabled);
1240        assert_eq!(config.jwt_cache_max_lifetime, 60);
1241        assert_eq!(
1242            config.server_unix_socket.as_deref(),
1243            Some("/tmp/postrust.sock")
1244        );
1245        assert_eq!(config.admin_server_port, Some(3001));
1246        assert_eq!(
1247            config.openapi_server_proxy_uri.as_deref(),
1248            Some("https://api.example.com")
1249        );
1250        assert_eq!(config.openapi_mode, OpenApiMode::IgnorePrivileges);
1251    }
1252
1253    #[test]
1254    fn a_zero_jwt_cache_lifetime_is_kept_because_it_turns_the_cache_off() {
1255        // Unlike the other durations, 0 is a meaningful value here and must
1256        // not be rejected as unusable.
1257        assert_eq!(
1258            with_env(&[("PGRST_JWT_CACHE_MAX_LIFETIME", "0")]).jwt_cache_max_lifetime,
1259            0
1260        );
1261    }
1262
1263    #[test]
1264    fn app_settings_come_from_a_prefix() {
1265        let config = with_env(&[
1266            ("PGRST_APP_SETTINGS_JWT_LIFETIME", "3600"),
1267            ("PGRST_APP_SETTINGS_TenantName", "acme"),
1268        ]);
1269        // Lower-cased, because that is the name a policy reads back with
1270        // `current_setting('app.settings.<name>')`.
1271        assert_eq!(
1272            config.app_settings.get("jwt_lifetime").map(String::as_str),
1273            Some("3600")
1274        );
1275        assert_eq!(
1276            config.app_settings.get("tenantname").map(String::as_str),
1277            Some("acme")
1278        );
1279    }
1280
1281    #[test]
1282    fn role_settings_parse_from_json_using_the_documented_spellings() {
1283        let config = with_env(&[(
1284            "PGRST_ROLE_SETTINGS",
1285            r#"{"web_user":{"isolation_level":"serializable","statement_timeout":5000}}"#,
1286        )]);
1287        let web_user = config.role_settings.get("web_user").expect("role present");
1288        assert_eq!(web_user.isolation_level, Some(IsolationLevel::Serializable));
1289        assert_eq!(web_user.statement_timeout, Some(5000));
1290    }
1291
1292    #[test]
1293    fn unparseable_role_settings_leave_the_map_empty() {
1294        assert!(with_env(&[("PGRST_ROLE_SETTINGS", "{not json")])
1295            .role_settings
1296            .is_empty());
1297        // A valid JSON object naming a level that does not exist is refused
1298        // as a whole rather than half-applied.
1299        assert!(with_env(&[(
1300            "PGRST_ROLE_SETTINGS",
1301            r#"{"r":{"isolation_level":"dirty"}}"#
1302        )])
1303        .role_settings
1304        .is_empty());
1305    }
1306
1307    #[test]
1308    fn openapi_mode_accepts_the_documented_spellings() {
1309        assert_eq!(
1310            OpenApiMode::parse_config("follow-privileges"),
1311            Some(OpenApiMode::FollowPrivileges)
1312        );
1313        assert_eq!(
1314            OpenApiMode::parse_config("IGNORE_PRIVILEGES"),
1315            Some(OpenApiMode::IgnorePrivileges)
1316        );
1317        assert_eq!(
1318            OpenApiMode::parse_config("disabled"),
1319            Some(OpenApiMode::Disabled)
1320        );
1321        // PostgREST's fourth mode needs a function this server has no
1322        // configuration for, so it is not accepted rather than aliased.
1323        assert_eq!(OpenApiMode::parse_config("security-definer"), None);
1324    }
1325
1326    #[test]
1327    fn the_config_enums_round_trip_through_json() {
1328        // Deriving `Serialize` emits the Rust variant name while the
1329        // hand-written `Deserialize` reads the configuration spelling, so a
1330        // value this type writes could not be read back. `Serializable`
1331        // survived by coincidence -- its variant name lowercases to the
1332        // accepted spelling -- which made the breakage look intermittent.
1333        for level in [
1334            IsolationLevel::ReadCommitted,
1335            IsolationLevel::RepeatableRead,
1336            IsolationLevel::Serializable,
1337        ] {
1338            let json = serde_json::to_string(&level).unwrap();
1339            let back: IsolationLevel = serde_json::from_str(&json).unwrap();
1340            assert_eq!(back, level, "{json}");
1341        }
1342
1343        for mode in [
1344            OpenApiMode::Disabled,
1345            OpenApiMode::FollowPrivileges,
1346            OpenApiMode::IgnorePrivileges,
1347        ] {
1348            let json = serde_json::to_string(&mode).unwrap();
1349            assert_eq!(serde_json::from_str::<OpenApiMode>(&json).unwrap(), mode);
1350        }
1351
1352        for level in [
1353            LogLevel::Crit,
1354            LogLevel::Error,
1355            LogLevel::Warn,
1356            LogLevel::Info,
1357            LogLevel::Debug,
1358        ] {
1359            let json = serde_json::to_string(&level).unwrap();
1360            assert_eq!(serde_json::from_str::<LogLevel>(&json).unwrap(), level);
1361        }
1362    }
1363
1364    #[test]
1365    fn a_whole_config_round_trips_through_json() {
1366        // The struct derives both, which invites persisting it.
1367        let mut config = AppConfig::default();
1368        config.role_settings.insert(
1369            "web_user".to_string(),
1370            RoleSettings {
1371                isolation_level: Some(IsolationLevel::RepeatableRead),
1372                statement_timeout: Some(5000),
1373            },
1374        );
1375
1376        let json = serde_json::to_string(&config).unwrap();
1377        let back: AppConfig = serde_json::from_str(&json).expect("config should round trip");
1378
1379        assert_eq!(back.db_tx_isolation, config.db_tx_isolation);
1380        assert_eq!(back.log_level, config.log_level);
1381        assert_eq!(back.openapi_mode, config.openapi_mode);
1382        assert_eq!(
1383            back.role_settings["web_user"].isolation_level,
1384            Some(IsolationLevel::RepeatableRead)
1385        );
1386    }
1387
1388    #[test]
1389    fn the_serialized_spelling_is_the_configuration_spelling() {
1390        // Not the Rust variant name: what is written should be what an
1391        // operator could paste into the environment.
1392        assert_eq!(
1393            serde_json::to_string(&IsolationLevel::ReadCommitted).unwrap(),
1394            "\"read committed\""
1395        );
1396        assert_eq!(
1397            serde_json::to_string(&OpenApiMode::FollowPrivileges).unwrap(),
1398            "\"follow-privileges\""
1399        );
1400        assert_eq!(serde_json::to_string(&LogLevel::Warn).unwrap(), "\"warn\"");
1401    }
1402
1403    #[test]
1404    fn an_empty_role_claim_key_is_not_accepted() {
1405        // An empty key would look up nothing and every request would fall to
1406        // the anonymous role.
1407        assert_eq!(
1408            with_env(&[("PGRST_JWT_ROLE_CLAIM_KEY", "  ")]).jwt_role_claim_key,
1409            AppConfig::default().jwt_role_claim_key
1410        );
1411    }
1412}