mirror of
https://github.com/OMGeeky/confique.git
synced 2026-01-07 04:01:26 +01:00
Merge pull request #22 from cyphersnake/10-lists-maps-from-env
Add parse_env attribute
This commit is contained in:
@@ -4,7 +4,9 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `parse_env` attribute for custom parsing of environment variables (allows you
|
||||
to load lists and other complex objects from env vars).
|
||||
|
||||
## [0.2.0] - 2022-10-21
|
||||
### Added
|
||||
|
||||
63
examples/parse_env.rs
Normal file
63
examples/parse_env.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use confique::Config;
|
||||
use std::{collections::HashSet, num::NonZeroU64, path::PathBuf, str::FromStr, convert::Infallible};
|
||||
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
struct Conf {
|
||||
#[config(env = "PATHS", parse_env = confique::env::parse::list_by_colon)]
|
||||
paths: HashSet<PathBuf>,
|
||||
|
||||
#[config(env = "PORTS", parse_env = confique::env::parse::list_by_comma)]
|
||||
ports: Vec<u16>,
|
||||
|
||||
#[config(env = "NAMES", parse_env = confique::env::parse::list_by_sep::<'|', _, _>)]
|
||||
names: Vec<String>,
|
||||
|
||||
#[config(env = "TIMEOUT", parse_env = NonZeroU64::from_str)]
|
||||
timeout_seconds: NonZeroU64,
|
||||
|
||||
#[config(env = "FORMATS", parse_env = parse_formats)]
|
||||
formats: Vec<Format>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
enum Format {
|
||||
Env,
|
||||
Toml,
|
||||
Json5,
|
||||
Yaml,
|
||||
}
|
||||
|
||||
/// Example custom parser.
|
||||
fn parse_formats(input: &str) -> Result<Vec<Format>, Infallible> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
if input.contains("toml") {
|
||||
result.push(Format::Toml);
|
||||
}
|
||||
if input.contains("env") {
|
||||
result.push(Format::Env);
|
||||
}
|
||||
if input.contains("yaml") {
|
||||
result.push(Format::Yaml);
|
||||
}
|
||||
if input.contains("json5") {
|
||||
result.push(Format::Json5);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::env::set_var("PATHS", "/bin/ls,/usr/local/bin,/usr/bin/ls");
|
||||
std::env::set_var("PORTS", "8080,8888,8000");
|
||||
std::env::set_var("NAMES", "Alex|Peter|Mary");
|
||||
std::env::set_var("TIMEOUT", "100");
|
||||
std::env::set_var("FORMATS", "json5,yaml;.env");
|
||||
|
||||
println!("{:#?}", Conf::builder().env().load()?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -149,14 +149,18 @@ fn gen_partial_mod(input: &ir::Input) -> TokenStream {
|
||||
|
||||
let from_env_fields = input.fields.iter().map(|f| {
|
||||
match &f.kind {
|
||||
FieldKind::Leaf { env: Some(key), deserialize_with, .. } => {
|
||||
FieldKind::Leaf { env: Some(key), deserialize_with, parse_env, .. } => {
|
||||
let field = format!("{}::{}", input.name, f.name);
|
||||
match deserialize_with {
|
||||
None => quote! {
|
||||
match (parse_env, deserialize_with) {
|
||||
(None, None) => quote! {
|
||||
confique::internal::from_env(#key, #field)?
|
||||
},
|
||||
Some(d) => quote! {
|
||||
confique::internal::from_env_with(#key, #field, #d)?
|
||||
(None, Some(deserialize_with)) => quote! {
|
||||
confique::internal::from_env_with_deserializer(
|
||||
#key, #field, #deserialize_with)?
|
||||
},
|
||||
(Some(parse_env), _) => quote! {
|
||||
confique::internal::from_env_with_parser(#key, #field, #parse_env)?
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub(crate) enum FieldKind {
|
||||
Leaf {
|
||||
env: Option<String>,
|
||||
deserialize_with: Option<syn::Path>,
|
||||
parse_env: Option<syn::Path>,
|
||||
kind: LeafKind,
|
||||
},
|
||||
|
||||
|
||||
@@ -65,32 +65,30 @@ impl Field {
|
||||
|
||||
FieldKind::Nested { ty: field.ty }
|
||||
} else {
|
||||
match unwrap_option(&field.ty) {
|
||||
None => FieldKind::Leaf {
|
||||
env: attrs.env,
|
||||
deserialize_with: attrs.deserialize_with,
|
||||
kind: LeafKind::Required {
|
||||
default: attrs.default,
|
||||
ty: field.ty,
|
||||
},
|
||||
},
|
||||
Some(inner) => {
|
||||
if attrs.default.is_some() {
|
||||
return Err(Error::new(
|
||||
field.ident.span(),
|
||||
"optional fields (type `Option<_>`) cannot have default \
|
||||
values (`#[config(default = ...)]`)",
|
||||
));
|
||||
}
|
||||
if attrs.env.is_none() && attrs.parse_env.is_some() {
|
||||
return Err(Error::new(
|
||||
field.ident.span(),
|
||||
"A `parse_env` attribute, cannot be provided without the `env` attribute",
|
||||
));
|
||||
}
|
||||
|
||||
FieldKind::Leaf {
|
||||
env: attrs.env,
|
||||
deserialize_with: attrs.deserialize_with,
|
||||
kind: LeafKind::Optional {
|
||||
inner_ty: inner.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
let kind = match unwrap_option(&field.ty) {
|
||||
Some(_) if attrs.default.is_some() => {
|
||||
return Err(Error::new(
|
||||
field.ident.span(),
|
||||
"optional fields (type `Option<_>`) cannot have default \
|
||||
values (`#[config(default = ...)]`)",
|
||||
));
|
||||
},
|
||||
Some(inner) => LeafKind::Optional { inner_ty: inner.clone() },
|
||||
None => LeafKind::Required { default: attrs.default, ty: field.ty },
|
||||
};
|
||||
|
||||
FieldKind::Leaf {
|
||||
env: attrs.env,
|
||||
deserialize_with: attrs.deserialize_with,
|
||||
parse_env: attrs.parse_env,
|
||||
kind,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,6 +240,10 @@ fn extract_internal_attrs(
|
||||
duplicate_if!(out.env.is_some());
|
||||
out.env = Some(key);
|
||||
}
|
||||
InternalAttr::ParseEnv(path) => {
|
||||
duplicate_if!(out.parse_env.is_some());
|
||||
out.parse_env = Some(path);
|
||||
}
|
||||
InternalAttr::DeserializeWith(path) => {
|
||||
duplicate_if!(out.deserialize_with.is_some());
|
||||
out.deserialize_with = Some(path);
|
||||
@@ -259,6 +261,7 @@ struct InternalAttrs {
|
||||
default: Option<Expr>,
|
||||
env: Option<String>,
|
||||
deserialize_with: Option<syn::Path>,
|
||||
parse_env: Option<syn::Path>,
|
||||
}
|
||||
|
||||
enum InternalAttr {
|
||||
@@ -266,6 +269,7 @@ enum InternalAttr {
|
||||
Default(Expr),
|
||||
Env(String),
|
||||
DeserializeWith(syn::Path),
|
||||
ParseEnv(syn::Path),
|
||||
}
|
||||
|
||||
impl InternalAttr {
|
||||
@@ -274,6 +278,7 @@ impl InternalAttr {
|
||||
Self::Nested => "nested",
|
||||
Self::Default(_) => "default",
|
||||
Self::Env(_) => "env",
|
||||
Self::ParseEnv(_) => "parse_env",
|
||||
Self::DeserializeWith(_) => "deserialize_with",
|
||||
}
|
||||
}
|
||||
@@ -310,6 +315,14 @@ impl Parse for InternalAttr {
|
||||
}
|
||||
}
|
||||
|
||||
"parse_env" => {
|
||||
let _: Token![=] = input.parse()?;
|
||||
let path: syn::Path = input.parse()?;
|
||||
assert_empty_or_comma(input)?;
|
||||
|
||||
Ok(Self::ParseEnv(path))
|
||||
}
|
||||
|
||||
"deserialize_with" => {
|
||||
let _: Token![=] = input.parse()?;
|
||||
let path: syn::Path = input.parse()?;
|
||||
|
||||
60
src/env.rs
60
src/env.rs
@@ -11,6 +11,7 @@ use serde::de::IntoDeserializer;
|
||||
/// module. Gets converted into `ErrorKind::EnvDeserialization` before reaching
|
||||
/// the real public API.
|
||||
#[derive(PartialEq, Eq)]
|
||||
#[doc(hidden)]
|
||||
pub struct DeError(pub(crate) String);
|
||||
|
||||
impl std::error::Error for DeError {}
|
||||
@@ -38,6 +39,7 @@ impl serde::de::Error for DeError {
|
||||
|
||||
|
||||
/// Deserializer type. Semantically private (see `DeError`).
|
||||
#[doc(hidden)]
|
||||
pub struct Deserializer {
|
||||
value: String,
|
||||
}
|
||||
@@ -166,3 +168,61 @@ mod tests {
|
||||
assert_eq!(de("-123.456"), Ok(-123.456f64));
|
||||
}
|
||||
}
|
||||
|
||||
/// Functions for the `#[config(parse_env = ...)]` attribute.
|
||||
pub mod parse {
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Splits the environment variable by separator `SEP`, parses each element
|
||||
/// with [`FromStr`] and collects everything via [`FromIterator`].
|
||||
///
|
||||
/// To avoid having to specify the separator via `::<>` syntax, see the
|
||||
/// other functions in this module.
|
||||
///
|
||||
/// [`FromStr`]: std::str::FromStr
|
||||
/// [`FromIterator`]: std::iter::FromIterator
|
||||
///
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use confique::Config;
|
||||
///
|
||||
/// #[derive(Debug, confique::Config)]
|
||||
/// struct Conf {
|
||||
/// #[config(env = "PORTS", parse_env = confique::env::parse::list_by_sep::<',', _, _>)]
|
||||
/// ports: Vec<u16>,
|
||||
/// }
|
||||
///
|
||||
/// std::env::set_var("PORTS", "8080,8000,8888");
|
||||
/// let conf = Conf::builder().env().load()?;
|
||||
/// assert_eq!(conf.ports, vec![8080, 8000, 8888]);
|
||||
/// # Ok::<_, confique::Error>(())
|
||||
/// ```
|
||||
pub fn list_by_sep<const SEP: char, T, C>(input: &str) -> Result<C, <T as FromStr>::Err>
|
||||
where
|
||||
T: FromStr,
|
||||
C: FromIterator<T>,
|
||||
{
|
||||
input.split(SEP).map(T::from_str).collect()
|
||||
}
|
||||
|
||||
|
||||
macro_rules! specify_fn_wrapper {
|
||||
($fn_name:ident, $sep:literal) => {
|
||||
#[doc = concat!("Like [`list_by_sep`] with `", $sep, "` separator.")]
|
||||
pub fn $fn_name<T, C>(input: &str) -> Result<C, <T as FromStr>::Err>
|
||||
where
|
||||
T: FromStr,
|
||||
C: FromIterator<T>,
|
||||
{
|
||||
list_by_sep::<$sep, _, _>(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
specify_fn_wrapper!(list_by_comma, ',');
|
||||
specify_fn_wrapper!(list_by_semicolon, ';');
|
||||
specify_fn_wrapper!(list_by_colon, ':');
|
||||
specify_fn_wrapper!(list_by_space, ' ');
|
||||
}
|
||||
|
||||
12
src/error.rs
12
src/error.rs
@@ -50,6 +50,13 @@ pub(crate) enum ErrorInner {
|
||||
msg: String,
|
||||
},
|
||||
|
||||
/// When a custom `parse_env` function fails.
|
||||
EnvParseError {
|
||||
field: String,
|
||||
key: String,
|
||||
err: Box<dyn std::error::Error + Send + Sync>,
|
||||
},
|
||||
|
||||
/// Returned by the [`Source`] impls for `Path` and `PathBuf` if the file
|
||||
/// extension is not supported by confique or if the corresponding Cargo
|
||||
/// feature of confique was not enabled.
|
||||
@@ -71,6 +78,7 @@ impl std::error::Error for Error {
|
||||
ErrorInner::MissingValue(_) => None,
|
||||
ErrorInner::EnvNotUnicode { .. } => None,
|
||||
ErrorInner::EnvDeserialization { .. } => None,
|
||||
ErrorInner::EnvParseError { err, .. } => Some(&**err),
|
||||
ErrorInner::UnsupportedFileFormat { .. } => None,
|
||||
ErrorInner::MissingFileExtension { .. } => None,
|
||||
ErrorInner::MissingRequiredFile { .. } => None,
|
||||
@@ -107,6 +115,10 @@ impl fmt::Display for Error {
|
||||
std::write!(f, "failed to deserialize value `{field}` from \
|
||||
environment variable `{key}`: {msg}")
|
||||
}
|
||||
ErrorInner::EnvParseError { field, key, err } => {
|
||||
std::write!(f, "failed to parse environment variable `{key}` into \
|
||||
field `{field}`: {err}")
|
||||
}
|
||||
ErrorInner::UnsupportedFileFormat { path } => {
|
||||
std::write!(f,
|
||||
"unknown configuration file format/extension: '{}'",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use crate::{error::ErrorInner, Error};
|
||||
|
||||
|
||||
pub fn deserialize_default<I, O>(src: I) -> Result<O, serde::de::value::Error>
|
||||
where
|
||||
I: for<'de> serde::de::IntoDeserializer<'de>,
|
||||
@@ -37,29 +36,53 @@ pub fn map_err_prefix_path<T>(res: Result<T, Error>, prefix: &str) -> Result<T,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
macro_rules! get_env_var {
|
||||
($key:expr, $field:expr) => {
|
||||
match std::env::var($key) {
|
||||
Err(std::env::VarError::NotPresent) => return Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
let err = ErrorInner::EnvNotUnicode {
|
||||
key: $key.into(),
|
||||
field: $field.into(),
|
||||
};
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(s) => s,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub fn from_env<'de, T: serde::Deserialize<'de>>(
|
||||
key: &str,
|
||||
field: &str,
|
||||
) -> Result<Option<T>, Error> {
|
||||
from_env_with(key, field, |de| T::deserialize(de))
|
||||
from_env_with_deserializer(key, field, |de| T::deserialize(de))
|
||||
}
|
||||
|
||||
pub fn from_env_with<T>(
|
||||
pub fn from_env_with_parser<T, E: std::error::Error + Send + Sync + 'static>(
|
||||
key: &str,
|
||||
field: &str,
|
||||
parse: fn(&str) -> Result<T, E>,
|
||||
) -> Result<Option<T>, Error> {
|
||||
let v = get_env_var!(key, field);
|
||||
parse(&v)
|
||||
.map(Some)
|
||||
.map_err(|err| {
|
||||
ErrorInner::EnvParseError {
|
||||
field: field.to_owned(),
|
||||
key: key.to_owned(),
|
||||
err: Box::new(err),
|
||||
}.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_env_with_deserializer<T>(
|
||||
key: &str,
|
||||
field: &str,
|
||||
deserialize: fn(crate::env::Deserializer) -> Result<T, crate::env::DeError>,
|
||||
) -> Result<Option<T>, Error> {
|
||||
let s = match std::env::var(key) {
|
||||
Err(std::env::VarError::NotPresent) => return Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
let err = ErrorInner::EnvNotUnicode {
|
||||
key: key.into(),
|
||||
field: field.into(),
|
||||
};
|
||||
return Err(err.into());
|
||||
}
|
||||
Ok(s) => s,
|
||||
};
|
||||
let s = get_env_var!(key, field);
|
||||
|
||||
match deserialize(crate::env::Deserializer::new(s)) {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
|
||||
10
src/lib.rs
10
src/lib.rs
@@ -175,7 +175,7 @@ use serde::Deserialize;
|
||||
pub mod internal;
|
||||
|
||||
mod builder;
|
||||
mod env;
|
||||
pub mod env;
|
||||
mod error;
|
||||
pub mod meta;
|
||||
|
||||
@@ -320,8 +320,14 @@ pub use crate::{
|
||||
/// - **`#[config(deserialize_with = path::to::function)]`**: like
|
||||
/// [serde's `deserialize_with` attribute][serde-deser].
|
||||
///
|
||||
/// [serde-deser]: https://serde.rs/field-attrs.html#deserialize_with
|
||||
/// - **`#[config(parse_env = path::to::function)]`**: function used to parse
|
||||
/// environment variables. Mostly useful if you need to parse lists or
|
||||
/// other complex objects from env vars. Function needs signature
|
||||
/// `fn(&str) -> Result<T, impl std::error::Error>` where `T` is the type of
|
||||
/// the field.. Can only be present if the `env` attribute is present. Also
|
||||
/// see [`env::parse`].
|
||||
///
|
||||
/// [serde-deser]: https://serde.rs/field-attrs.html#deserialize_with
|
||||
///
|
||||
/// ## Special types for leaf fields
|
||||
///
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::{collections::HashMap, net::IpAddr, path::PathBuf};
|
||||
use std::{collections::HashMap, net::IpAddr, path::PathBuf, convert::Infallible};
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
|
||||
use confique::{meta, Config, Partial};
|
||||
|
||||
@@ -110,6 +112,9 @@ mod full {
|
||||
|
||||
#[config(env = "ENV_TEST_FULL_3")]
|
||||
optional: Option<PathBuf>,
|
||||
|
||||
#[config(env = "ENV_TEST_FULL_4", parse_env = parse_dummy_collection)]
|
||||
env_collection: DummyCollection,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +252,14 @@ fn full() {
|
||||
kind: meta::LeafKind::Optional,
|
||||
},
|
||||
},
|
||||
meta::Field {
|
||||
name: "env_collection",
|
||||
doc: &[],
|
||||
kind: meta::FieldKind::Leaf {
|
||||
env: Some("ENV_TEST_FULL_4"),
|
||||
kind: meta::LeafKind::Required { default: None },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -266,6 +279,7 @@ fn full() {
|
||||
assert_eq!(def.env.required, None);
|
||||
assert_eq!(def.env.with_default, Some(8080));
|
||||
assert_eq!(def.env.optional, None);
|
||||
assert_eq!(def.env.env_collection, None);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -275,12 +289,19 @@ pub(crate) fn deserialize_dummy<'de, D>(deserializer: D) -> Result<Dummy, D::Err
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::Deserialize;
|
||||
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(Dummy(format!("dummy {s}")))
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
struct DummyCollection(Vec<String>);
|
||||
|
||||
pub(crate) fn parse_dummy_collection(input: &str) -> Result<DummyCollection, Infallible> {
|
||||
Ok(DummyCollection(
|
||||
input.split(',').map(ToString::to_string).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
// This only makes sure this compiles and doesn't result in any "cannot infer
|
||||
// type" problems.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user