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