Skip to content

Advanced: hand-written from_env

The derive covers per-field rules and FromStr covers scalar parsing. Two things push a config past them: a field carrying a structured payload (a JSON blob in one variable) and a rule spanning the whole struct. This is the real IssuerConfig from features/oauth (signs the OAuth clients accepted by apps/auth) — it needs both:

crates/features/src/oauth/config.rs (abridged)
use nest_rs_authn::RegisteredClient;
use nest_rs_config::{config, Config, ConfigError, ConfigService};
use uuid::Uuid;
use validator::{Validate, ValidationError, ValidationErrors};
const DEFAULT_ORG: Uuid = Uuid::from_u128(0x0000_0000_0000_7000_8000_0000_0000_ac3e);
#[config(namespace = "issuer")]
#[derive(Clone, Default)]
pub struct IssuerConfig {
// The org id each client acts as is the framework client's generic payload.
pub clients: Vec<RegisteredClient<Uuid>>,
pub default_org_id: Uuid,
}
impl Validate for IssuerConfig {
fn validate(&self) -> Result<(), ValidationErrors> {
let mut errors = ValidationErrors::new();
if self.clients.is_empty() {
errors.add("clients", ValidationError::new("at_least_one_client"));
}
if errors.is_empty() { Ok(()) } else { Err(errors) }
}
}
impl Config for IssuerConfig {
fn from_env(env: &ConfigService) -> nest_rs_config::Result<Self> {
let clients = match env.get("CLIENTS") {
Some(raw) => serde_json::from_str(&raw)
.map_err(|e| ConfigError::parse(env.var_name("CLIENTS"), e.to_string()))?,
None => Vec::new(),
};
let default_org_id = env.parse("DEFAULT_ORG_ID")?.unwrap_or(DEFAULT_ORG);
Ok(Self { clients, default_org_id })
}
}
  • clients is a JSON array in a single variable. env.get("CLIENTS") returns the raw string and serde_json::from_str does the structured parse — mapping the failure through ConfigError::parse(env.var_name("CLIENTS"), …) so the boot error still names NESTRS_ISSUER__CLIENTS. The element type is the framework’s RegisteredClient<P> (from nest-rs-authn); the app fills the generic payload — here Uuid, the org each client acts as — so the framework owns the credential shape and the app owns only what it authorizes on.
  • “At least one client” is a struct-level rule, so impl Validate is written by hand, building the same ValidationErrors the derive would. It runs after the load and aborts boot exactly like a derived rule. Per-field rules stay on the derive, as in the lead example.

Wiring and consumption do not change — the same for_feature import, the same Arc<IssuerConfig> injection:

crates/features/src/oauth/module.rs
use nest_rs_config::ConfigModule;
use super::config::IssuerConfig;
use super::service::OAuthFlow;
#[module(
imports = [ConfigModule::for_feature::<IssuerConfig>(), UsersModule],
providers = [OAuthFlow],
)]
pub struct OAuthModule;
apps/auth/src/module.rs
use nest_rs_config::ConfigModule;
#[module(
imports = [
ConfigModule::for_root(),
OAuthHttpModule,
],
)]
pub struct AuthModule;
crates/features/src/oauth/service.rs
#[injectable]
pub struct OAuthFlow {
#[inject]
config: Arc<IssuerConfig>,
}
impl OAuthFlow {
pub fn issue(&self, client_id: &str) -> Result<AccessTokenDto, AuthError> {
let client = self.config.clients
.iter()
.find(|c| c.client_id == client_id)
.ok_or(AuthError::UnknownClient)?;
// ...
}
}
Terminal window
$ NESTRS_ISSUER__CLIENTS='[{"client_id":"web","client_secret":"...","payload":"00000000-0000-0000-0000-000000000000","scopes":["read"]}]' \
nestrs run dev auth
2026-06-03T10:42:11Z INFO nest_rs::config: loaded issuer (2 fields)
2026-06-03T10:42:11Z INFO nest_rs::http: bound 5 routes on 0.0.0.0:3000

A bad value fails the boot before any port opens:

Terminal window
$ NESTRS_ISSUER__DEFAULT_ORG_ID=not-a-uuid nestrs run dev auth
Error: configuration parse error
variable: NESTRS_ISSUER__DEFAULT_ORG_ID
reason : invalid character at 1: expected hyphen

A failed validation runs after the load:

Terminal window
$ nestrs run dev auth # no clients configured
Error: configuration validation failed for 'issuer'
- clients: at_least_one_client