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