use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const EXPECTED_BOOLEAN: &str = "one of: true, false, 1, 0, yes, no, on, off";
const EXPECTED_SQLSTATES: &str = "true, false, or a comma-separated list of SQLSTATEs \
(P0001) and prefixes ending in * (PT*)";
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[error("invalid value for {variable}: {value:?}; expected {expected}")]
pub struct ConfigError {
variable: &'static str,
value: String,
expected: &'static str,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AppConfig {
#[serde(default = "default_db_uri")]
pub db_uri: String,
#[serde(default = "default_db_schemas")]
pub db_schemas: Vec<String>,
pub db_anon_role: Option<String>,
#[serde(default = "default_pool_size")]
pub db_pool_size: u32,
#[serde(default = "default_pool_timeout")]
pub db_pool_timeout: u64,
#[serde(default = "default_tx_isolation")]
pub db_tx_isolation: IsolationLevel,
#[serde(default = "default_true")]
pub db_prepared_statements: bool,
#[serde(default = "default_extra_search_path")]
pub db_extra_search_path: Vec<String>,
#[serde(default = "default_db_channel")]
pub db_channel: String,
#[serde(default)]
pub db_channel_enabled: bool,
pub db_pre_request: Option<String>,
pub db_max_rows: Option<i64>,
#[serde(default)]
pub db_aggregates_enabled: bool,
#[serde(default = "default_host")]
pub server_host: String,
#[serde(default = "default_port")]
pub server_port: u16,
#[serde(default)]
pub server_cors_origins: Vec<String>,
#[serde(default = "default_max_body_size")]
pub max_body_size: usize,
pub server_unix_socket: Option<String>,
pub admin_server_port: Option<u16>,
pub jwt_secret: Option<String>,
#[serde(default)]
pub jwt_secret_is_base64: bool,
pub jwt_aud: Option<String>,
pub jwt_iss: Option<String>,
#[serde(default = "default_jwt_role_claim")]
pub jwt_role_claim_key: String,
#[serde(default = "default_true")]
pub jwt_cache_enabled: bool,
#[serde(default = "default_jwt_cache_max")]
pub jwt_cache_max_lifetime: u64,
pub jwt_jwks_url: Option<String>,
#[serde(default = "default_jwt_jwks_refresh")]
pub jwt_jwks_refresh: u64,
pub hasura_admin_secret: Option<String>,
pub hasura_unauthorized_role: Option<String>,
pub openapi_server_proxy_uri: Option<String>,
#[serde(default = "default_openapi_mode")]
pub openapi_mode: OpenApiMode,
#[serde(default = "default_log_level")]
pub log_level: LogLevel,
#[serde(default)]
pub log_format: LogFormat,
#[serde(default)]
pub otel_db_statement: bool,
#[serde(default)]
pub role_settings: HashMap<String, RoleSettings>,
#[serde(default)]
pub app_settings: HashMap<String, String>,
#[serde(default)]
pub graphql_federation: bool,
#[serde(default)]
pub graphql_type_prefix: Option<String>,
#[serde(default)]
pub compat_mode: bool,
#[serde(default, deserialize_with = "deserialize_sqlstate_patterns")]
pub db_errors_verbatim: Vec<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
db_uri: default_db_uri(),
db_schemas: default_db_schemas(),
db_anon_role: None,
db_pool_size: default_pool_size(),
db_pool_timeout: default_pool_timeout(),
db_tx_isolation: default_tx_isolation(),
db_prepared_statements: true,
db_extra_search_path: default_extra_search_path(),
db_channel: default_db_channel(),
db_channel_enabled: false,
db_pre_request: None,
db_max_rows: None,
db_aggregates_enabled: false,
server_host: default_host(),
server_port: default_port(),
server_cors_origins: Vec::new(),
max_body_size: default_max_body_size(),
server_unix_socket: None,
admin_server_port: None,
jwt_secret: None,
jwt_secret_is_base64: false,
jwt_aud: None,
jwt_iss: None,
jwt_role_claim_key: default_jwt_role_claim(),
jwt_cache_enabled: true,
jwt_cache_max_lifetime: default_jwt_cache_max(),
jwt_jwks_url: None,
jwt_jwks_refresh: default_jwt_jwks_refresh(),
hasura_admin_secret: None,
hasura_unauthorized_role: None,
openapi_server_proxy_uri: None,
openapi_mode: OpenApiMode::FollowPrivileges,
log_level: default_log_level(),
log_format: LogFormat::Text,
otel_db_statement: false,
role_settings: HashMap::new(),
app_settings: HashMap::new(),
graphql_federation: false,
graphql_type_prefix: None,
compat_mode: false,
db_errors_verbatim: Vec::new(),
}
}
}
fn warn_ignored(var: &str, value: &str, expected: &str) {
tracing::warn!("Ignoring {}={:?}: expected {}", var, value, expected);
}
fn parse_bool(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" => Some(false),
_ => None,
}
}
fn invalid(variable: &'static str, value: &str, expected: &'static str) -> ConfigError {
let error = ConfigError {
variable,
value: value.to_string(),
expected,
};
tracing::warn!("{error}");
error
}
fn env_bool(variable: &'static str, value: &str) -> Result<bool, ConfigError> {
parse_bool(value).ok_or_else(|| invalid(variable, value, EXPECTED_BOOLEAN))
}
fn parse_sqlstate_patterns(value: &str) -> Option<Vec<String>> {
match parse_bool(value) {
Some(true) => return Some(vec!["*".to_string()]),
Some(false) => return Some(Vec::new()),
None => {}
}
let sqlstate_chars = |s: &str| s.chars().all(|c| c.is_ascii_alphanumeric());
value
.split(',')
.map(|s| s.trim().to_ascii_uppercase())
.filter(|s| !s.is_empty())
.map(|s| {
let valid = match s.strip_suffix('*') {
Some(prefix) => prefix.len() < 5 && sqlstate_chars(prefix),
None => s.len() == 5 && sqlstate_chars(&s),
};
valid.then_some(s)
})
.collect()
}
fn deserialize_sqlstate_patterns<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<String>, D::Error> {
let raw = Vec::<String>::deserialize(deserializer)?.join(",");
parse_sqlstate_patterns(&raw).ok_or_else(|| {
serde::de::Error::custom(format!(
"invalid db_errors_verbatim {raw:?}: expected {EXPECTED_SQLSTATES}"
))
})
}
fn sqlstate_matches(pattern: &str, code: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => code.starts_with(prefix),
None => pattern == code,
}
}
fn parse_graphql_type_prefix(value: &str) -> Result<Option<String>, &'static str> {
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
let mut chars = value.chars();
let valid_first = chars
.next()
.is_some_and(|c| c == '_' || c.is_ascii_alphabetic());
if !valid_first || !chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) {
return Err("a GraphQL identifier beginning with a letter or underscore");
}
Ok(Some(value.to_string()))
}
fn env_name(name: String) -> String {
if cfg!(windows) {
upper_env_name(name)
} else {
name
}
}
fn upper_env_name(mut name: String) -> String {
name.make_ascii_uppercase();
name
}
impl AppConfig {
pub fn from_env() -> Self {
Self::try_from_env().unwrap_or_else(|error| panic!("{error}"))
}
pub fn try_from_env() -> Result<Self, ConfigError> {
Self::try_from_vars(
std::env::vars_os().filter_map(|(k, v)| {
Some((env_name(k.into_string().ok()?), v.into_string().ok()?))
}),
)
}
pub fn try_from_vars<I, K, V>(vars: I) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let pairs: Vec<(String, String)> = vars
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect();
let vars: HashMap<&str, &str> = pairs
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let get = |key: &str| vars.get(key).map(|v| v.to_string()).ok_or(());
let mut config = Self::default();
if let Ok(uri) = get("PGRST_DB_URI") {
config.db_uri = uri;
}
if let Ok(uri) = get("DATABASE_URL") {
config.db_uri = uri;
}
if let Ok(schemas) = get("PGRST_DB_SCHEMAS") {
config.db_schemas = schemas.split(',').map(|s| s.trim().to_string()).collect();
}
if let Ok(role) = get("PGRST_DB_ANON_ROLE") {
config.db_anon_role = Some(role);
}
for var in ["PGRST_DB_POOL", "PGRST_DB_POOL_SIZE"] {
if let Ok(size) = get(var) {
match size.parse::<u32>() {
Ok(n) if n > 0 => config.db_pool_size = n,
_ => warn_ignored(var, &size, "a positive integer"),
}
}
}
if let Ok(secs) = get("PGRST_DB_POOL_TIMEOUT") {
match secs.parse::<u64>() {
Ok(n) if n > 0 => config.db_pool_timeout = n,
_ => warn_ignored("PGRST_DB_POOL_TIMEOUT", &secs, "a positive integer"),
}
}
if let Ok(level) = get("PGRST_DB_TX_ISOLATION") {
match IsolationLevel::parse_config(&level) {
Some(l) => config.db_tx_isolation = l,
None => warn_ignored(
"PGRST_DB_TX_ISOLATION",
&level,
"one of: read committed, repeatable read, serializable",
),
}
}
if let Ok(v) = get("PGRST_DB_EXTRA_SEARCH_PATH") {
config.db_extra_search_path = v
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Ok(v) = get("PGRST_DB_AGGREGATES_ENABLED") {
config.db_aggregates_enabled = env_bool("PGRST_DB_AGGREGATES_ENABLED", &v)?;
}
if let Ok(v) = get("PGRST_DB_PREPARED_STATEMENTS") {
config.db_prepared_statements = env_bool("PGRST_DB_PREPARED_STATEMENTS", &v)?;
}
if let Ok(v) = get("PGRST_DB_PRE_REQUEST") {
if !v.trim().is_empty() {
config.db_pre_request = Some(v.trim().to_string());
}
}
if let Ok(v) = get("PGRST_DB_CHANNEL") {
if !v.trim().is_empty() {
config.db_channel = v.trim().to_string();
}
}
if let Ok(v) = get("PGRST_DB_CHANNEL_ENABLED") {
config.db_channel_enabled = env_bool("PGRST_DB_CHANNEL_ENABLED", &v)?;
}
for var in ["PGRST_DB_MAX_ROWS", "PGRST_MAX_ROWS"] {
if let Ok(max_rows) = get(var) {
match max_rows.parse::<i64>() {
Ok(n) if n >= 0 => config.db_max_rows = Some(n),
_ => warn_ignored(var, &max_rows, "a non-negative integer"),
}
}
}
if let Ok(secret) = get("PGRST_JWT_SECRET") {
config.jwt_secret = Some(secret);
}
if let Ok(aud) = get("PGRST_JWT_AUD") {
config.jwt_aud = Some(aud);
}
if let Ok(iss) = get("PGRST_JWT_ISS") {
if !iss.trim().is_empty() {
config.jwt_iss = Some(iss);
}
}
if let Ok(v) = get("PGRST_JWT_SECRET_IS_BASE64") {
config.jwt_secret_is_base64 = env_bool("PGRST_JWT_SECRET_IS_BASE64", &v)?;
}
if let Ok(key) = get("PGRST_JWT_ROLE_CLAIM_KEY") {
if !key.trim().is_empty() {
config.jwt_role_claim_key = key;
}
}
if let Ok(v) = get("PGRST_JWT_CACHE_ENABLED") {
config.jwt_cache_enabled = env_bool("PGRST_JWT_CACHE_ENABLED", &v)?;
}
if let Ok(v) = get("PGRST_JWT_CACHE_MAX_LIFETIME") {
match v.parse::<u64>() {
Ok(n) => config.jwt_cache_max_lifetime = n,
Err(_) => warn_ignored(
"PGRST_JWT_CACHE_MAX_LIFETIME",
&v,
"a non-negative integer number of seconds",
),
}
}
if let Ok(url) = get("PGRST_JWT_JWKS_URL") {
if !url.trim().is_empty() {
config.jwt_jwks_url = Some(url.trim().to_string());
}
}
if let Ok(secs) = get("PGRST_JWT_JWKS_REFRESH") {
match secs.parse::<u64>() {
Ok(n) if n > 0 => config.jwt_jwks_refresh = n,
_ => warn_ignored(
"PGRST_JWT_JWKS_REFRESH",
&secs,
"a positive integer number of seconds",
),
}
}
for var in ["HASURA_GRAPHQL_ADMIN_SECRET", "PGRST_HASURA_ADMIN_SECRET"] {
if let Ok(secret) = get(var) {
if !secret.is_empty() {
config.hasura_admin_secret = Some(secret);
}
}
}
for var in [
"HASURA_GRAPHQL_UNAUTHORIZED_ROLE",
"PGRST_HASURA_UNAUTHORIZED_ROLE",
] {
if let Ok(role) = get(var) {
if !role.is_empty() {
config.hasura_unauthorized_role = Some(role);
}
}
}
if let Ok(host) = get("PGRST_SERVER_HOST") {
config.server_host = host;
}
if let Ok(port) = get("PGRST_SERVER_PORT") {
if let Ok(p) = port.parse() {
config.server_port = p;
}
}
if let Ok(port) = get("PORT") {
if let Ok(p) = port.parse() {
config.server_port = p;
}
}
if let Ok(origins) = get("PGRST_SERVER_CORS_ORIGINS") {
config.server_cors_origins = if origins.trim() == "*" {
Vec::new()
} else {
origins
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
}
if let Ok(bytes) = get("PGRST_MAX_BODY_SIZE") {
match bytes.parse::<usize>() {
Ok(n) if n > 0 => config.max_body_size = n,
_ => warn_ignored("PGRST_MAX_BODY_SIZE", &bytes, "a positive integer"),
}
}
if let Ok(level) = get("PGRST_LOG_LEVEL") {
match LogLevel::parse_config(&level) {
Some(l) => config.log_level = l,
None => warn_ignored(
"PGRST_LOG_LEVEL",
&level,
"one of: crit, error, warn, info, debug",
),
}
}
if let Ok(format) = get("PGRST_LOG_FORMAT") {
match LogFormat::parse_config(&format) {
Some(f) => config.log_format = f,
None => warn_ignored("PGRST_LOG_FORMAT", &format, "one of: text, json"),
}
}
if let Ok(v) = get("PGRST_OTEL_DB_STATEMENT") {
config.otel_db_statement = env_bool("PGRST_OTEL_DB_STATEMENT", &v)?;
}
if let Ok(path) = get("PGRST_SERVER_UNIX_SOCKET") {
if !path.trim().is_empty() {
config.server_unix_socket = Some(path.trim().to_string());
}
}
if let Ok(port) = get("PGRST_ADMIN_SERVER_PORT") {
match port.parse::<u16>() {
Ok(p) if p > 0 => config.admin_server_port = Some(p),
_ => warn_ignored("PGRST_ADMIN_SERVER_PORT", &port, "a port number"),
}
}
if let Ok(uri) = get("PGRST_OPENAPI_SERVER_PROXY_URI") {
if !uri.trim().is_empty() {
config.openapi_server_proxy_uri = Some(uri.trim().to_string());
}
}
if let Ok(mode) = get("PGRST_OPENAPI_MODE") {
match OpenApiMode::parse_config(&mode) {
Some(m) => config.openapi_mode = m,
None => warn_ignored(
"PGRST_OPENAPI_MODE",
&mode,
"one of: disabled, follow-privileges, ignore-privileges",
),
}
}
if let Ok(raw) = get("PGRST_ROLE_SETTINGS") {
match serde_json::from_str::<HashMap<String, RoleSettings>>(&raw) {
Ok(settings) => config.role_settings = settings,
Err(e) => warn_ignored(
"PGRST_ROLE_SETTINGS",
&raw,
&format!("a JSON object of role to settings ({e})"),
),
}
}
if let Ok(value) = get("PGRST_GRAPHQL_FEDERATION") {
config.graphql_federation = env_bool("PGRST_GRAPHQL_FEDERATION", &value)?;
}
if let Ok(value) = get("PGRST_GRAPHQL_TYPE_PREFIX") {
match parse_graphql_type_prefix(&value) {
Ok(prefix) => config.graphql_type_prefix = prefix,
Err(expected) => warn_ignored("PGRST_GRAPHQL_TYPE_PREFIX", &value, expected),
}
}
for (key, value) in &pairs {
if let Some(name) = key.strip_prefix("PGRST_APP_SETTINGS_") {
if !name.is_empty() {
config
.app_settings
.insert(name.to_ascii_lowercase(), value.clone());
}
}
}
for var in ["PGRST_COMPAT_MODE", "POSTRUST_COMPAT_MODE"] {
if let Ok(v) = get(var) {
config.compat_mode = env_bool(var, &v)?;
}
}
if let Ok(v) = get("PGRST_DB_ERRORS_VERBATIM") {
config.db_errors_verbatim = parse_sqlstate_patterns(&v)
.ok_or_else(|| invalid("PGRST_DB_ERRORS_VERBATIM", &v, EXPECTED_SQLSTATES))?;
}
Ok(config)
}
pub fn verbatim_db_error(&self, error: &crate::Error) -> bool {
match error {
crate::Error::Database(db) => {
self.compat_mode
|| self
.db_errors_verbatim
.iter()
.any(|pattern| sqlstate_matches(pattern, &db.code))
}
_ => false,
}
}
pub fn any_verbatim_db_errors(&self) -> bool {
self.compat_mode || !self.db_errors_verbatim.is_empty()
}
pub fn default_schema(&self) -> &str {
self.db_schemas
.first()
.map(|s| s.as_str())
.unwrap_or("public")
}
}
impl Serialize for IsolationLevel {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_config_str())
}
}
impl<'de> Deserialize<'de> for IsolationLevel {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::parse_config(&raw).ok_or_else(|| {
serde::de::Error::custom(format!(
"unknown isolation level {raw:?}: expected one of \
read committed, repeatable read, serializable"
))
})
}
}
impl Serialize for OpenApiMode {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_config_str())
}
}
impl<'de> Deserialize<'de> for OpenApiMode {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::parse_config(&raw).ok_or_else(|| {
serde::de::Error::custom(format!(
"unknown OpenAPI mode {raw:?}: expected one of \
disabled, follow-privileges, ignore-privileges"
))
})
}
}
impl Serialize for LogLevel {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_config_str())
}
}
impl<'de> Deserialize<'de> for LogLevel {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::parse_config(&raw).ok_or_else(|| {
serde::de::Error::custom(format!(
"unknown log level {raw:?}: expected one of \
crit, error, warn, info, debug"
))
})
}
}
impl Serialize for LogFormat {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_config_str())
}
}
impl<'de> Deserialize<'de> for LogFormat {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::parse_config(&raw).ok_or_else(|| {
serde::de::Error::custom(format!(
"unknown log format {raw:?}: expected one of text, json"
))
})
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RoleSettings {
pub isolation_level: Option<IsolationLevel>,
pub statement_timeout: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IsolationLevel {
ReadCommitted,
RepeatableRead,
Serializable,
}
impl IsolationLevel {
pub fn to_sql(&self) -> &'static str {
match self {
Self::ReadCommitted => "READ COMMITTED",
Self::RepeatableRead => "REPEATABLE READ",
Self::Serializable => "SERIALIZABLE",
}
}
pub fn parse_config(s: &str) -> Option<Self> {
match s
.trim()
.to_ascii_lowercase()
.replace(['-', '_'], " ")
.as_str()
{
"read committed" => Some(Self::ReadCommitted),
"repeatable read" => Some(Self::RepeatableRead),
"serializable" => Some(Self::Serializable),
_ => None,
}
}
pub fn as_config_str(&self) -> &'static str {
match self {
Self::ReadCommitted => "read committed",
Self::RepeatableRead => "repeatable read",
Self::Serializable => "serializable",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OpenApiMode {
Disabled,
FollowPrivileges,
IgnorePrivileges,
}
impl OpenApiMode {
pub fn parse_config(s: &str) -> Option<Self> {
match s
.trim()
.to_ascii_lowercase()
.replace(['-', '_'], " ")
.as_str()
{
"disabled" => Some(Self::Disabled),
"follow privileges" => Some(Self::FollowPrivileges),
"ignore privileges" => Some(Self::IgnorePrivileges),
_ => None,
}
}
pub fn as_config_str(&self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::FollowPrivileges => "follow-privileges",
Self::IgnorePrivileges => "ignore-privileges",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LogLevel {
Crit,
Error,
Warn,
Info,
Debug,
}
impl LogLevel {
pub fn to_tracing(&self) -> tracing::Level {
match self {
Self::Crit | Self::Error => tracing::Level::ERROR,
Self::Warn => tracing::Level::WARN,
Self::Info => tracing::Level::INFO,
Self::Debug => tracing::Level::DEBUG,
}
}
pub fn parse_config(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"crit" | "critical" => Some(Self::Crit),
"error" => Some(Self::Error),
"warn" | "warning" => Some(Self::Warn),
"info" => Some(Self::Info),
"debug" => Some(Self::Debug),
_ => None,
}
}
pub fn as_config_str(&self) -> &'static str {
match self {
Self::Crit => "crit",
Self::Error => "error",
Self::Warn => "warn",
Self::Info => "info",
Self::Debug => "debug",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LogFormat {
#[default]
Text,
Json,
}
impl LogFormat {
pub fn parse_config(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"text" => Some(Self::Text),
"json" => Some(Self::Json),
_ => None,
}
}
pub fn as_config_str(&self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "json",
}
}
}
fn default_db_uri() -> String {
"postgresql://localhost/postgres".to_string()
}
fn default_db_schemas() -> Vec<String> {
vec!["public".to_string()]
}
fn default_pool_size() -> u32 {
10
}
fn default_pool_timeout() -> u64 {
10
}
fn default_tx_isolation() -> IsolationLevel {
IsolationLevel::ReadCommitted
}
fn default_max_body_size() -> usize {
10 * 1024 * 1024
}
fn default_extra_search_path() -> Vec<String> {
vec!["public".to_string()]
}
fn default_db_channel() -> String {
"pgrst".to_string()
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
3000
}
fn default_jwt_role_claim() -> String {
"role".to_string()
}
fn default_jwt_cache_max() -> u64 {
3600
}
fn default_jwt_jwks_refresh() -> u64 {
300
}
fn default_openapi_mode() -> OpenApiMode {
OpenApiMode::FollowPrivileges
}
fn default_log_level() -> LogLevel {
LogLevel::Info
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = AppConfig::default();
assert_eq!(config.server_port, 3000);
assert_eq!(config.db_pool_size, 10);
assert!(config.db_prepared_statements);
assert!(!config.graphql_federation);
assert_eq!(config.graphql_type_prefix, None);
}
#[test]
fn db_errors_verbatim_takes_sqlstates_prefixes_and_booleans() {
let parse = |v: &str| {
AppConfig::try_from_vars([("PGRST_DB_ERRORS_VERBATIM", v)])
.map(|c| c.db_errors_verbatim)
};
assert_eq!(parse("P0001, pt*").unwrap(), ["P0001", "PT*"]);
assert_eq!(parse("true").unwrap(), ["*"]);
assert_eq!(parse("off").unwrap(), Vec::<String>::new());
assert_eq!(parse("").unwrap(), Vec::<String>::new());
for bad in ["P001", "P00011", "P0*01", "P0001*", "4250!"] {
assert!(parse(bad).is_err(), "{bad:?}");
}
let from_json = |v: &str| {
serde_json::from_str::<AppConfig>(&format!(r#"{{"db_errors_verbatim": {v}}}"#))
.map(|c| c.db_errors_verbatim)
};
assert_eq!(from_json(r#"["p0001", "PT*"]"#).unwrap(), ["P0001", "PT*"]);
assert_eq!(from_json(r#"["true"]"#).unwrap(), ["*"]);
assert!(from_json(r#"["P001"]"#).is_err());
}
#[test]
fn verbatim_db_error_matches_only_listed_database_errors() {
let db = |code: &str| {
crate::Error::Database(crate::error::DatabaseError {
code: code.to_string(),
message: String::new(),
details: None,
hint: None,
constraint: None,
table: None,
column: None,
})
};
let mut config = AppConfig::default();
assert!(!config.verbatim_db_error(&db("P0001")));
assert!(!config.any_verbatim_db_errors());
config.db_errors_verbatim = vec!["P0001".to_string(), "PT*".to_string()];
assert!(config.any_verbatim_db_errors());
assert!(config.verbatim_db_error(&db("P0001")));
assert!(config.verbatim_db_error(&db("PT402")));
assert!(!config.verbatim_db_error(&db("23505")));
assert!(!config.verbatim_db_error(&crate::Error::NotFound("t".to_string())));
config.db_errors_verbatim = vec!["*".to_string()];
assert!(config.verbatim_db_error(&db("42P01")));
config.db_errors_verbatim.clear();
config.compat_mode = true;
assert!(config.verbatim_db_error(&db("23505")));
}
#[test]
fn test_default_schema() {
let mut config = AppConfig::default();
assert_eq!(config.default_schema(), "public");
config.db_schemas = vec!["api".to_string(), "public".to_string()];
assert_eq!(config.default_schema(), "api");
}
#[test]
fn test_isolation_level_sql() {
assert_eq!(IsolationLevel::ReadCommitted.to_sql(), "READ COMMITTED");
assert_eq!(IsolationLevel::Serializable.to_sql(), "SERIALIZABLE");
}
#[test]
fn isolation_level_accepts_the_documented_spellings() {
for s in [
"read committed",
"READ COMMITTED",
"read-committed",
" Read_Committed ",
] {
assert_eq!(
IsolationLevel::parse_config(s),
Some(IsolationLevel::ReadCommitted),
"{s:?}"
);
}
assert_eq!(
IsolationLevel::parse_config("serializable"),
Some(IsolationLevel::Serializable)
);
assert_eq!(
IsolationLevel::parse_config("repeatable read"),
Some(IsolationLevel::RepeatableRead)
);
assert_eq!(IsolationLevel::parse_config("dirty read"), None);
assert_eq!(IsolationLevel::parse_config(""), None);
}
#[test]
fn log_level_accepts_the_documented_spellings() {
assert_eq!(LogLevel::parse_config("INFO"), Some(LogLevel::Info));
assert_eq!(LogLevel::parse_config("warning"), Some(LogLevel::Warn));
assert_eq!(LogLevel::parse_config("crit"), Some(LogLevel::Crit));
assert_eq!(LogLevel::parse_config("trace"), None);
}
#[test]
fn default_log_level_matches_the_documented_one() {
assert_eq!(AppConfig::default().log_level, LogLevel::Info);
}
#[test]
fn parse_bool_accepts_only_the_supported_spellings() {
for s in ["true", "TRUE", "1", "yes", "on", " On "] {
assert_eq!(parse_bool(s), Some(true), "{s:?}");
}
for s in ["false", "FALSE", "0", "no", "off", " Off "] {
assert_eq!(parse_bool(s), Some(false), "{s:?}");
}
for s in ["", "maybe", "enabled", "2"] {
assert_eq!(parse_bool(s), None, "{s:?}");
}
}
#[test]
fn graphql_federation_configuration_is_read() {
let config = with_env(&[
("PGRST_GRAPHQL_FEDERATION", "true"),
("PGRST_GRAPHQL_TYPE_PREFIX", " test "),
]);
assert!(config.graphql_federation);
assert_eq!(config.graphql_type_prefix.as_deref(), Some("test"));
}
#[test]
fn graphql_federation_accepts_an_explicit_false() {
assert!(!with_env(&[("PGRST_GRAPHQL_FEDERATION", "false")]).graphql_federation);
}
#[test]
fn malformed_graphql_federation_configuration_is_rejected() {
let error = with_env_result(&[("PGRST_GRAPHQL_FEDERATION", "sometimes")])
.expect_err("an invalid boolean must reject the configuration");
assert_eq!(
error.to_string(),
"invalid value for PGRST_GRAPHQL_FEDERATION: \"sometimes\"; expected one of: true, false, 1, 0, yes, no, on, off"
);
}
#[test]
fn an_empty_graphql_prefix_means_unset() {
let config = with_env(&[("PGRST_GRAPHQL_TYPE_PREFIX", " ")]);
assert_eq!(config.graphql_type_prefix, None);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env(vars: &[(&str, &str)]) -> AppConfig {
with_env_result(vars).expect("test configuration should be valid")
}
fn with_env_result(vars: &[(&str, &str)]) -> Result<AppConfig, ConfigError> {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
for (k, v) in vars {
std::env::set_var(k, v);
}
let config = AppConfig::try_from_env();
for (k, _) in vars {
std::env::remove_var(k);
}
config
}
#[test]
fn documented_variables_are_read() {
let config = with_env(&[
("PGRST_DB_POOL_SIZE", "42"),
("PGRST_DB_POOL_TIMEOUT", "7"),
("PGRST_DB_TX_ISOLATION", "serializable"),
("PGRST_JWT_SECRET_IS_BASE64", "true"),
("PGRST_JWT_ROLE_CLAIM_KEY", "user.role"),
("PGRST_LOG_LEVEL", "warn"),
("PGRST_MAX_BODY_SIZE", "1024"),
(
"PGRST_SERVER_CORS_ORIGINS",
"https://a.example, https://b.example",
),
]);
assert_eq!(config.db_pool_size, 42);
assert_eq!(config.db_pool_timeout, 7);
assert_eq!(config.db_tx_isolation, IsolationLevel::Serializable);
assert!(config.jwt_secret_is_base64);
assert_eq!(config.jwt_role_claim_key, "user.role");
assert_eq!(config.log_level, LogLevel::Warn);
assert_eq!(config.max_body_size, 1024);
assert_eq!(
config.server_cors_origins,
vec![
"https://a.example".to_string(),
"https://b.example".to_string()
]
);
}
#[test]
fn the_jwt_issuer_is_read_and_an_empty_one_is_unset() {
let read = |value: &str| {
AppConfig::try_from_vars([("PGRST_JWT_ISS", value)])
.unwrap()
.jwt_iss
};
assert_eq!(
read("https://issuer.example").as_deref(),
Some("https://issuer.example")
);
assert_eq!(read(" "), None);
assert_eq!(
AppConfig::try_from_vars(Vec::<(String, String)>::new())
.unwrap()
.jwt_iss,
None
);
}
#[test]
fn the_jwks_url_and_its_refresh_interval_are_read() {
let read = |vars: &[(&str, &str)]| AppConfig::try_from_vars(vars.to_vec()).unwrap();
let config = read(&[
("PGRST_JWT_JWKS_URL", " https://idp.example/jwks "),
("PGRST_JWT_JWKS_REFRESH", "60"),
]);
assert_eq!(
config.jwt_jwks_url.as_deref(),
Some("https://idp.example/jwks")
);
assert_eq!(config.jwt_jwks_refresh, 60);
let defaults = read(&[("PGRST_JWT_JWKS_URL", ""), ("PGRST_JWT_JWKS_REFRESH", "0")]);
assert_eq!(defaults.jwt_jwks_url, None);
assert_eq!(defaults.jwt_jwks_refresh, 300);
assert_eq!(
read(&[("PGRST_JWT_JWKS_REFRESH", "soon")]).jwt_jwks_refresh,
300
);
}
#[test]
fn variables_can_come_from_somewhere_other_than_the_environment() {
let config = AppConfig::try_from_vars([
("PGRST_DB_SCHEMAS", "api, extra"),
("PGRST_DB_ANON_ROLE", "web_anon"),
("PGRST_JWT_SECRET", "a-secret"),
("PGRST_DB_MAX_ROWS", "100"),
("PGRST_APP_SETTINGS_Tenant", "acme"),
])
.unwrap();
assert_eq!(config.db_schemas, ["api", "extra"]);
assert_eq!(config.db_anon_role.as_deref(), Some("web_anon"));
assert_eq!(config.jwt_secret.as_deref(), Some("a-secret"));
assert_eq!(config.db_max_rows, Some(100));
assert_eq!(
config.app_settings.get("tenant").map(String::as_str),
Some("acme")
);
let refused = AppConfig::try_from_vars([("PGRST_DB_AGGREGATES_ENABLED", "maybe")]);
assert!(refused.is_err());
}
#[test]
fn app_settings_differing_only_in_case_take_the_later_value() {
for _ in 0..16 {
let config = AppConfig::try_from_vars([
("PGRST_APP_SETTINGS_Tenant", "first"),
("PGRST_APP_SETTINGS_TENANT", "second"),
("PGRST_APP_SETTINGS_tenant", "third"),
])
.unwrap();
assert_eq!(
config.app_settings.get("tenant").map(String::as_str),
Some("third")
);
let config = AppConfig::try_from_vars([
("PGRST_APP_SETTINGS_tenant", "third"),
("PGRST_APP_SETTINGS_TENANT", "second"),
])
.unwrap();
assert_eq!(
config.app_settings.get("tenant").map(String::as_str),
Some("second")
);
}
}
#[test]
fn windows_environment_names_are_matched_whatever_their_case() {
let config = AppConfig::try_from_vars([
(upper_env_name("pgrst_db_schemas".into()), "api"),
(upper_env_name("Pgrst_App_Settings_Tenant".into()), "acme"),
])
.unwrap();
assert_eq!(config.db_schemas, ["api"]);
assert_eq!(
config.app_settings.get("tenant").map(String::as_str),
Some("acme")
);
assert_eq!(env_name("PGRST_DB_URI".into()), "PGRST_DB_URI");
assert_eq!(
env_name("pgrst_db_uri".into()) == "PGRST_DB_URI",
cfg!(windows)
);
}
#[test]
fn pool_size_accepts_postgrests_own_name() {
assert_eq!(with_env(&[("PGRST_DB_POOL", "5")]).db_pool_size, 5);
}
#[test]
fn a_star_origin_means_any_origin() {
assert!(with_env(&[("PGRST_SERVER_CORS_ORIGINS", "*")])
.server_cors_origins
.is_empty());
assert!(with_env(&[("PGRST_SERVER_CORS_ORIGINS", " , ")])
.server_cors_origins
.is_empty());
}
#[test]
fn an_unusable_value_leaves_the_default() {
let config = with_env(&[
("PGRST_DB_POOL_SIZE", "lots"),
("PGRST_DB_POOL_TIMEOUT", "0"),
("PGRST_MAX_BODY_SIZE", "-1"),
("PGRST_DB_TX_ISOLATION", "dirty read"),
("PGRST_LOG_LEVEL", "shout"),
]);
let d = AppConfig::default();
assert_eq!(config.db_pool_size, d.db_pool_size);
assert_eq!(config.db_pool_timeout, d.db_pool_timeout);
assert_eq!(config.max_body_size, d.max_body_size);
assert_eq!(config.db_tx_isolation, d.db_tx_isolation);
assert_eq!(config.log_level, d.log_level);
}
#[test]
fn the_formerly_dead_fields_are_read() {
let config = with_env(&[
("PGRST_DB_PREPARED_STATEMENTS", "false"),
("PGRST_DB_PRE_REQUEST", "auth.check"),
("PGRST_DB_CHANNEL", "my_channel"),
("PGRST_DB_CHANNEL_ENABLED", "yes"),
("PGRST_JWT_CACHE_ENABLED", "false"),
("PGRST_JWT_CACHE_MAX_LIFETIME", "60"),
("PGRST_SERVER_UNIX_SOCKET", "/tmp/postrust.sock"),
("PGRST_ADMIN_SERVER_PORT", "3001"),
("PGRST_OPENAPI_SERVER_PROXY_URI", "https://api.example.com"),
("PGRST_OPENAPI_MODE", "ignore-privileges"),
]);
assert!(!config.db_prepared_statements);
assert_eq!(config.db_pre_request.as_deref(), Some("auth.check"));
assert_eq!(config.db_channel, "my_channel");
assert!(config.db_channel_enabled);
assert!(!config.jwt_cache_enabled);
assert_eq!(config.jwt_cache_max_lifetime, 60);
assert_eq!(
config.server_unix_socket.as_deref(),
Some("/tmp/postrust.sock")
);
assert_eq!(config.admin_server_port, Some(3001));
assert_eq!(
config.openapi_server_proxy_uri.as_deref(),
Some("https://api.example.com")
);
assert_eq!(config.openapi_mode, OpenApiMode::IgnorePrivileges);
}
#[test]
fn tracing_settings_are_read() {
let d = AppConfig::default();
assert_eq!(d.log_format, LogFormat::Text);
assert!(!d.otel_db_statement);
let config = with_env(&[
("PGRST_LOG_FORMAT", "JSON"),
("PGRST_OTEL_DB_STATEMENT", "on"),
]);
assert_eq!(config.log_format, LogFormat::Json);
assert!(config.otel_db_statement);
assert_eq!(
with_env(&[("PGRST_LOG_FORMAT", "yaml")]).log_format,
LogFormat::Text
);
assert!(with_env_result(&[("PGRST_OTEL_DB_STATEMENT", "maybe")]).is_err());
}
#[test]
fn a_zero_jwt_cache_lifetime_is_kept_because_it_turns_the_cache_off() {
assert_eq!(
with_env(&[("PGRST_JWT_CACHE_MAX_LIFETIME", "0")]).jwt_cache_max_lifetime,
0
);
}
#[test]
fn app_settings_come_from_a_prefix() {
let config = with_env(&[
("PGRST_APP_SETTINGS_JWT_LIFETIME", "3600"),
("PGRST_APP_SETTINGS_TenantName", "acme"),
]);
assert_eq!(
config.app_settings.get("jwt_lifetime").map(String::as_str),
Some("3600")
);
assert_eq!(
config.app_settings.get("tenantname").map(String::as_str),
Some("acme")
);
}
#[test]
fn role_settings_parse_from_json_using_the_documented_spellings() {
let config = with_env(&[(
"PGRST_ROLE_SETTINGS",
r#"{"web_user":{"isolation_level":"serializable","statement_timeout":5000}}"#,
)]);
let web_user = config.role_settings.get("web_user").expect("role present");
assert_eq!(web_user.isolation_level, Some(IsolationLevel::Serializable));
assert_eq!(web_user.statement_timeout, Some(5000));
}
#[test]
fn unparseable_role_settings_leave_the_map_empty() {
assert!(with_env(&[("PGRST_ROLE_SETTINGS", "{not json")])
.role_settings
.is_empty());
assert!(with_env(&[(
"PGRST_ROLE_SETTINGS",
r#"{"r":{"isolation_level":"dirty"}}"#
)])
.role_settings
.is_empty());
}
#[test]
fn openapi_mode_accepts_the_documented_spellings() {
assert_eq!(
OpenApiMode::parse_config("follow-privileges"),
Some(OpenApiMode::FollowPrivileges)
);
assert_eq!(
OpenApiMode::parse_config("IGNORE_PRIVILEGES"),
Some(OpenApiMode::IgnorePrivileges)
);
assert_eq!(
OpenApiMode::parse_config("disabled"),
Some(OpenApiMode::Disabled)
);
assert_eq!(OpenApiMode::parse_config("security-definer"), None);
}
#[test]
fn the_config_enums_round_trip_through_json() {
for level in [
IsolationLevel::ReadCommitted,
IsolationLevel::RepeatableRead,
IsolationLevel::Serializable,
] {
let json = serde_json::to_string(&level).unwrap();
let back: IsolationLevel = serde_json::from_str(&json).unwrap();
assert_eq!(back, level, "{json}");
}
for mode in [
OpenApiMode::Disabled,
OpenApiMode::FollowPrivileges,
OpenApiMode::IgnorePrivileges,
] {
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(serde_json::from_str::<OpenApiMode>(&json).unwrap(), mode);
}
for level in [
LogLevel::Crit,
LogLevel::Error,
LogLevel::Warn,
LogLevel::Info,
LogLevel::Debug,
] {
let json = serde_json::to_string(&level).unwrap();
assert_eq!(serde_json::from_str::<LogLevel>(&json).unwrap(), level);
}
}
#[test]
fn a_whole_config_round_trips_through_json() {
let mut config = AppConfig::default();
config.role_settings.insert(
"web_user".to_string(),
RoleSettings {
isolation_level: Some(IsolationLevel::RepeatableRead),
statement_timeout: Some(5000),
},
);
let json = serde_json::to_string(&config).unwrap();
let back: AppConfig = serde_json::from_str(&json).expect("config should round trip");
assert_eq!(back.db_tx_isolation, config.db_tx_isolation);
assert_eq!(back.log_level, config.log_level);
assert_eq!(back.openapi_mode, config.openapi_mode);
assert_eq!(
back.role_settings["web_user"].isolation_level,
Some(IsolationLevel::RepeatableRead)
);
}
#[test]
fn the_serialized_spelling_is_the_configuration_spelling() {
assert_eq!(
serde_json::to_string(&IsolationLevel::ReadCommitted).unwrap(),
"\"read committed\""
);
assert_eq!(
serde_json::to_string(&OpenApiMode::FollowPrivileges).unwrap(),
"\"follow-privileges\""
);
assert_eq!(serde_json::to_string(&LogLevel::Warn).unwrap(), "\"warn\"");
}
#[test]
fn an_empty_role_claim_key_is_not_accepted() {
assert_eq!(
with_env(&[("PGRST_JWT_ROLE_CLAIM_KEY", " ")]).jwt_role_claim_key,
AppConfig::default().jwt_role_claim_key
);
}
}