Add parse_env attribute to confique::Config macro

Since there is no established format for representing collections within env variables, to enable their support, the feature with a custom parser attribute added.

Add helpers for parsing env variables into collections

Improve rustdoc for `env_utils` functions

Improve `parse_env` example
This commit is contained in:
Cyphersnake
2022-10-24 17:25:10 +04:00
parent 337fb3204f
commit cb8f879b92
10 changed files with 290 additions and 42 deletions

View File

@@ -4,7 +4,8 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
- `parse_env` attribute which is using for parse env variable in arbitrary type
## [0.2.0] - 2022-10-21
### Added

View File

@@ -28,6 +28,7 @@ yaml = ["serde_yaml"]
[dependencies]
confique-macro = { version = "=0.0.5", path = "macro" }
json5 = { version = "0.4.1", optional = true }
paste = "1.0.9"
serde = { version = "1", features = ["derive"] }
serde_yaml = { version = "0.8", optional = true }
toml = { version = "0.5", optional = true }

83
examples/parse_env.rs Normal file
View File

@@ -0,0 +1,83 @@
#![allow(dead_code)]
use confique::{
env_utils::{
to_collection_by_char_separator, to_collection_by_comma, to_collection_by_semicolon,
},
Config,
};
use std::{collections::HashSet, num::NonZeroU64, path::PathBuf, str::FromStr};
#[derive(Debug, Config)]
struct Conf {
#[config(
env = "PATHS",
parse_env = to_collection_by_comma
)]
paths: HashSet<PathBuf>,
#[config(
env = "PORTS",
parse_env = to_collection_by_semicolon
)]
ports: Vec<u16>,
#[config(
env = "NAMES",
parse_env = to_collection_by_char_separator::<'|', _, _>
)]
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,
}
#[derive(Debug)]
enum Error {}
fn parse_formats(input: &str) -> Result<Vec<Format>, Error> {
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",
"Mervinc Harmon|Alfreda Valenzuela|Arlen Cabrera|Damon Rice|Willie Schwartz",
);
std::env::set_var("TIMEOUT", "100");
std::env::set_var("FORMATS", "json5,yaml;.env");
println!("{:#?}", Conf::builder().env().load()?);
Ok(())
}

View File

@@ -147,22 +147,28 @@ 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, .. } => {
let field = format!("{}::{}", input.name, f.name);
match deserialize_with {
None => quote! {
confique::internal::from_env(#key, #field)?
},
Some(d) => quote! {
confique::internal::from_env_with(#key, #field, #d)?
},
}
let from_env_fields = input.fields.iter().map(|f| match &f.kind {
FieldKind::Leaf {
env: Some(key),
deserialize_with,
parse_env,
..
} => {
let field = format!("{}::{}", input.name, f.name);
match (parse_env, deserialize_with) {
(None, None) => quote! {
confique::internal::from_env(#key, #field)?
},
(None, Some(deserialize_with)) => quote! {
confique::internal::deserialize_from_env_with(#key, #field, #deserialize_with)?
},
(Some(parse_env), None) | (Some(parse_env), Some(_)) => quote! {
confique::internal::parse_from_env_with(#key, #field, #parse_env)?
},
}
FieldKind::Leaf { .. } => quote! { None },
FieldKind::Nested { .. } => quote! { confique::Partial::from_env()? },
}
FieldKind::Leaf { .. } => quote! { None },
FieldKind::Nested { .. } => quote! { confique::Partial::from_env()? },
});
let fallbacks = input.fields.iter().map(|f| {

View File

@@ -24,6 +24,7 @@ pub(crate) enum FieldKind {
Leaf {
env: Option<String>,
deserialize_with: Option<syn::Path>,
parse_env: Option<syn::Path>,
kind: LeafKind,
},

View File

@@ -65,32 +65,27 @@ 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() {
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,
parse_env: attrs.parse_env,
kind: match unwrap_option(&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 = ...)]`)",
));
}
FieldKind::Leaf {
env: attrs.env,
deserialize_with: attrs.deserialize_with,
kind: LeafKind::Optional {
inner_ty: inner.clone(),
},
}
}
},
Some(inner) => LeafKind::Optional { inner_ty: inner.clone() },
None => LeafKind::Required { default: attrs.default, ty: field.ty },
},
}
};
@@ -242,6 +237,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 +258,7 @@ struct InternalAttrs {
default: Option<Expr>,
env: Option<String>,
deserialize_with: Option<syn::Path>,
parse_env: Option<syn::Path>,
}
enum InternalAttr {
@@ -266,6 +266,7 @@ enum InternalAttr {
Default(Expr),
Env(String),
DeserializeWith(syn::Path),
ParseEnv(syn::Path),
}
impl InternalAttr {
@@ -274,6 +275,7 @@ impl InternalAttr {
Self::Nested => "nested",
Self::Default(_) => "default",
Self::Env(_) => "env",
Self::ParseEnv(_) => "parse_env",
Self::DeserializeWith(_) => "deserialize_with",
}
}
@@ -310,6 +312,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()?;

View File

@@ -128,7 +128,6 @@ impl<'de> serde::Deserializer<'de> for Deserializer {
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -166,3 +165,105 @@ mod tests {
assert_eq!(de("-123.456"), Ok(-123.456f64));
}
}
/// This module contains helper methods to simplify configuration via environment variables
pub mod env_utils {
use std::str::FromStr;
/// Helper function to parse any type that implements [`std::str::FromStr`]
/// into a collection that implements [`std::iter::FromIterator`], spliting original string by
/// const char separator.
///
/// # To Vec
/// ```
/// use crate::confique::Config;
/// #[derive(Debug, confique::Config)]
/// struct Conf {
/// #[config(
/// env = "PORTS",
/// parse_env = confique::env_utils::to_collection_by_char_separator::<',', _, _>
/// )]
/// ports: Vec<u16>
/// }
///
/// std::env::set_var("PORTS", "8080,8000,8888");
/// println!("{:?}", Conf::builder().env().load().unwrap())
/// ```
///
/// # To HashSet
/// ```
/// use crate::confique::Config;
/// #[derive(Debug, confique::Config)]
/// struct Conf {
/// #[config(
/// env = "PATHS",
/// parse_env = confique::env_utils::to_collection_by_char_separator::<';', _, _>
/// )]
/// paths: std::collections::HashSet<std::path::PathBuf>,
/// }
///
/// std::env::set_var("PATHS", "/bin;/user/bin;/home/user/.cargo/bin");
/// println!("{:?}", Conf::builder().env().load().unwrap())
/// ```
pub fn to_collection_by_char_separator<
const SEPARATOR: char,
T: FromStr,
C: FromIterator<Result<T, <T as FromStr>::Err>>,
>(
input: &str,
) -> C {
input.split(SEPARATOR.to_owned()).map(T::from_str).collect()
}
macro_rules! specify_fn_wrapper {
($symbol_name:ident, $symbol:tt) => {
::paste::paste! {
/// Helper function to parse any type that implements [`std::str::FromStr`]
/// into a collection that implements [`std::iter::FromIterator`], spliting original string by
#[doc = stringify!($symbol_name)]
/// .
///
/// # To Vec
/// ```
/// use crate::confique::Config;
/// #[derive(Debug, confique::Config)]
/// struct Conf {
/// #[config(
/// env = "PORTS",
#[doc = concat!(" parse_env = confique::env_utils::", stringify!([<to_collection_by_ $symbol_name>],))]
/// )]
/// ports: Vec<u16>
/// }
///
#[doc = concat!("std::env::set_var(\"PORTS\", \"8080", $symbol, "8000", $symbol, "8888", "\");")]
/// println!("{:#?}", Conf::builder().env().load().unwrap())
/// ```
///
/// # To HashSet
/// ```
/// use crate::confique::Config;
/// #[derive(Debug, confique::Config)]
/// struct Conf {
/// #[config(
/// env = "PATHS",
#[doc = concat!(" parse_env = confique::env_utils::", stringify!([<to_collection_by_ $symbol_name>],))]
/// )]
/// paths: std::collections::HashSet<std::path::PathBuf>,
/// }
///
#[doc = concat!("std::env::set_var(\"PATHS\", \"/bin", $symbol, "/user/bin", $symbol, "/home/user/.cargo/bin", "\");")]
/// println!("{:#?}", Conf::builder().env().load().unwrap())
/// ```
pub fn [<to_collection_by_ $symbol_name>]<T: FromStr, C: FromIterator<Result<T, <T as FromStr>::Err>>>(
input: &str,
) -> C {
to_collection_by_char_separator::<$symbol, _, _>(input)
}
}
}
}
specify_fn_wrapper!(comma, ',');
specify_fn_wrapper!(semicolon, ';');
specify_fn_wrapper!(space, ' ');
}

View File

@@ -2,8 +2,9 @@
//! intended to be used directly. None of this is covered by semver! Do not use
//! any of this directly.
use crate::{error::ErrorInner, Error};
use std::fmt::Debug;
use crate::{error::ErrorInner, Error};
pub fn deserialize_default<I, O>(src: I) -> Result<O, serde::de::value::Error>
where
@@ -41,10 +42,29 @@ 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))
deserialize_from_env_with(key, field, |de| T::deserialize(de))
}
pub fn from_env_with<T>(
pub fn parse_from_env_with<T, E: Debug>(
key: &str,
field: &str,
parse: fn(&str) -> Result<T, E>,
) -> Result<Option<T>, Error> {
from_env::<String>(key, field)?
.as_deref()
.map(parse)
.transpose()
.map_err(|err| {
ErrorInner::EnvDeserialization {
field: field.to_owned(),
key: key.to_owned(),
msg: format!("Error while parse: {:?}", err),
}
.into()
})
}
pub fn deserialize_from_env_with<T>(
key: &str,
field: &str,
deserialize: fn(crate::env::Deserializer) -> Result<T, crate::env::DeError>,

View File

@@ -176,6 +176,7 @@ pub mod internal;
mod builder;
mod env;
pub use env::env_utils;
mod error;
pub mod meta;
@@ -322,6 +323,9 @@ pub use crate::{
///
/// [serde-deser]: https://serde.rs/field-attrs.html#deserialize_with
///
/// - **`#[config(from_env = path::to::function, env = "KEY")]`**: like
/// deserialize_with` attribute, but only for fields from an environment variable.
/// Can only be present if the `env` attribute is present
///
/// ## Special types for leaf fields
///

View File

@@ -1,5 +1,7 @@
use std::{collections::HashMap, net::IpAddr, path::PathBuf};
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<const SEPARATOR: char>(Vec<String>);
pub(crate) fn parse_dummy_collection<const SEPARATOR: char>(input: &str) -> Result<DummyCollection<SEPARATOR>, String> {
Ok(DummyCollection(
input.split(SEPARATOR).map(ToString::to_string).collect(),
))
}
// This only makes sure this compiles and doesn't result in any "cannot infer
// type" problems.
#[test]