diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b14ad1..1b37b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/examples/parse_env.rs b/examples/parse_env.rs new file mode 100644 index 0000000..11af4b0 --- /dev/null +++ b/examples/parse_env.rs @@ -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, + + #[config(env = "PORTS", parse_env = confique::env::parse::list_by_comma)] + ports: Vec, + + #[config(env = "NAMES", parse_env = confique::env::parse::list_by_sep::<'|', _, _>)] + names: Vec, + + #[config(env = "TIMEOUT", parse_env = NonZeroU64::from_str)] + timeout_seconds: NonZeroU64, + + #[config(env = "FORMATS", parse_env = parse_formats)] + formats: Vec, +} + +#[derive(Debug, serde::Deserialize)] +enum Format { + Env, + Toml, + Json5, + Yaml, +} + +/// Example custom parser. +fn parse_formats(input: &str) -> Result, 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> { + 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(()) +} diff --git a/macro/src/gen/mod.rs b/macro/src/gen/mod.rs index 55303d2..93f5b33 100644 --- a/macro/src/gen/mod.rs +++ b/macro/src/gen/mod.rs @@ -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)? }, } } diff --git a/macro/src/ir.rs b/macro/src/ir.rs index f04708f..5694450 100644 --- a/macro/src/ir.rs +++ b/macro/src/ir.rs @@ -24,6 +24,7 @@ pub(crate) enum FieldKind { Leaf { env: Option, deserialize_with: Option, + parse_env: Option, kind: LeafKind, }, diff --git a/macro/src/parse.rs b/macro/src/parse.rs index 6167785..5c0fb99 100644 --- a/macro/src/parse.rs +++ b/macro/src/parse.rs @@ -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, env: Option, deserialize_with: Option, + parse_env: Option, } 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()?; diff --git a/src/env.rs b/src/env.rs index 5b12e05..4580661 100644 --- a/src/env.rs +++ b/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, + /// } + /// + /// 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(input: &str) -> Result::Err> + where + T: FromStr, + C: FromIterator, + { + 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(input: &str) -> Result::Err> + where + T: FromStr, + C: FromIterator, + { + 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, ' '); +} diff --git a/src/error.rs b/src/error.rs index f3e7e2e..f46fa02 100644 --- a/src/error.rs +++ b/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, + }, + /// 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: '{}'", diff --git a/src/internal.rs b/src/internal.rs index ae68f54..3ad175f 100644 --- a/src/internal.rs +++ b/src/internal.rs @@ -4,7 +4,6 @@ use crate::{error::ErrorInner, Error}; - pub fn deserialize_default(src: I) -> Result where I: for<'de> serde::de::IntoDeserializer<'de>, @@ -37,29 +36,53 @@ pub fn map_err_prefix_path(res: Result, prefix: &str) -> Result { + 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, 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( +pub fn from_env_with_parser( + key: &str, + field: &str, + parse: fn(&str) -> Result, +) -> Result, 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( key: &str, field: &str, deserialize: fn(crate::env::Deserializer) -> Result, ) -> Result, 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)), diff --git a/src/lib.rs b/src/lib.rs index 0f63300..da44c22 100644 --- a/src/lib.rs +++ b/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` 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 /// diff --git a/tests/general.rs b/tests/general.rs index 878b0bc..493f689 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -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, + + #[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, { - use serde::Deserialize; - let s = String::deserialize(deserializer)?; Ok(Dummy(format!("dummy {s}"))) } +#[derive(Debug, PartialEq, Deserialize)] +struct DummyCollection(Vec); + +pub(crate) fn parse_dummy_collection(input: &str) -> Result { + 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]