diff --git a/Cargo.toml b/Cargo.toml index 07d15c7..0ef925b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ homepage = "https://github.com/init4tech/bin-base" repository = "https://github.com/init4tech/bin-base" [dependencies] +init4-from-env-derive = { path = "./from-env-derive" } + # Tracing tracing = "0.1.40" tracing-core = "0.1.33" diff --git a/from-env-derive/.gitignore b/from-env-derive/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/from-env-derive/.gitignore @@ -0,0 +1 @@ +/target diff --git a/from-env-derive/Cargo.toml b/from-env-derive/Cargo.toml new file mode 100644 index 0000000..08a5c16 --- /dev/null +++ b/from-env-derive/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "init4-from-env-derive" +description = "The `FromEnv` derive macro" +version = "0.1.0" +edition = "2024" + +[dependencies] +heck = "0.5.0" +proc-macro2 = "1.0.95" +quote = "1.0.40" +syn = { version = "2.0.100", features = ["full", "parsing"] } + +[lib] +proc-macro = true + +[dev-dependencies] +init4-bin-base = "0.2" diff --git a/from-env-derive/README.md b/from-env-derive/README.md new file mode 100644 index 0000000..dc96a97 --- /dev/null +++ b/from-env-derive/README.md @@ -0,0 +1 @@ +# init4-from-env-derive diff --git a/from-env-derive/src/field.rs b/from-env-derive/src/field.rs new file mode 100644 index 0000000..5cbb1e4 --- /dev/null +++ b/from-env-derive/src/field.rs @@ -0,0 +1,221 @@ +use heck::ToPascalCase; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Ident, LitStr, spanned::Spanned}; + +/// A parsed Field of a struct +pub(crate) struct Field { + env_var: Option, + field_name: Option, + field_type: syn::Type, + + optional: bool, + infallible: bool, + desc: Option, + + _attrs: Vec, + + span: proc_macro2::Span, +} + +impl TryFrom<&syn::Field> for Field { + type Error = syn::Error; + + fn try_from(field: &syn::Field) -> Result { + let mut optional = false; + let mut env_var = None; + let mut infallible = false; + let mut desc = None; + + field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("from_env")) + .for_each(|attr| { + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("optional") { + optional = true; + return Ok(()); + } + if meta.path.is_ident("var") { + env_var = Some(meta.value()?.parse::()?); + return Ok(()); + } + if meta.path.is_ident("desc") { + desc = Some(meta.value()?.parse::()?.value()); + return Ok(()); + } + if meta.path.is_ident("infallible") { + infallible = true; + } + Ok(()) + }); + }); + + if desc.is_none() && env_var.is_some() { + return Err(syn::Error::new( + field.span(), + "Missing description for field. Use `#[from_env(desc = \"DESC\")]`", + )); + } + + let field_type = field.ty.clone(); + let field_name = field.ident.clone(); + let span = field.span(); + + Ok(Field { + env_var, + field_name, + field_type, + optional, + infallible, + desc, + _attrs: field + .attrs + .iter() + .filter(|attr| !attr.path().is_ident("from_env")) + .cloned() + .collect(), + span, + }) + } +} + +impl Field { + pub(crate) fn trait_name(&self) -> TokenStream { + self.env_var + .as_ref() + .map(|_| quote! { FromEnvVar }) + .unwrap_or(quote! { FromEnv }) + } + + pub(crate) fn as_trait(&self) -> TokenStream { + let field_trait = self.trait_name(); + let field_type = &self.field_type; + + quote! { <#field_type as #field_trait> } + } + + pub(crate) fn assoc_err(&self) -> TokenStream { + let as_trait = self.as_trait(); + + quote! { #as_trait::Error } + } + + pub(crate) fn field_name(&self, idx: usize) -> Ident { + if let Some(field_name) = self.field_name.as_ref() { + return field_name.clone(); + } + + let n = format!("field_{}", idx); + syn::parse_str::(&n) + .map_err(|_| syn::Error::new(self.span, "Failed to create field name")) + .unwrap() + } + + /// Produces the name of the enum variant for the field + pub(crate) fn enum_variant_name(&self, idx: usize) -> Option { + if self.infallible { + return None; + } + + let n = self.field_name(idx).to_string().to_pascal_case(); + + let n: Ident = syn::parse_str::(&n) + .map_err(|_| syn::Error::new(self.span, "Failed to create field name")) + .unwrap(); + + Some(quote! { #n }) + } + + /// Produces the variant, containing the error type + pub(crate) fn expand_enum_variant(&self, idx: usize) -> Option { + let variant_name = self.enum_variant_name(idx)?; + let var_name_str = variant_name.to_string(); + let assoc_err = self.assoc_err(); + + Some(quote! { + #[doc = "Error for "] + #[doc = #var_name_str] + #variant_name(#assoc_err) + }) + } + + /// Produces the a line for the `inventory` function + /// of the form + /// items.push(...); + /// or + /// items.extend(...); + pub(crate) fn expand_env_item_info(&self) -> TokenStream { + let description = self.desc.clone().unwrap_or_default(); + let optional = self.optional; + + if let Some(env_var) = &self.env_var { + let var_name = env_var.value(); + + return quote! { + items.push(&EnvItemInfo { + var: #var_name, + description: #description, + optional: #optional, + }); + }; + } + + let field_ty = &self.field_type; + quote! { + items.extend( + <#field_ty as FromEnv>::inventory() + ); + } + } + + pub(crate) fn expand_variant_display(&self, idx: usize) -> Option { + let variant_name = self.enum_variant_name(idx)?; + + Some(quote! { + Self::#variant_name(err) => err.fmt(f) + }) + } + + pub(crate) fn expand_variant_source(&self, idx: usize) -> Option { + let variant_name = self.enum_variant_name(idx)?; + + Some(quote! { + Self::#variant_name(err) => Some(err) + }) + } + + pub(crate) fn expand_item_from_env(&self, err_ident: &Ident, idx: usize) -> TokenStream { + // Produces code fo the following form: + // ```rust + // // EITHER + // let field_name = env::var(#self.env_var.unwrap()).map_err(|e| e.map(#ErroEnum::FieldName))?; + + // // OR + // let field_name = FromEnvVar::from_env_var(#self.env_var.unwrap()).map_err(|e| e.map(#ErroEnum::FieldName))?; + + // // OR + // let field_name = FromEnv::from_env().map_err()?; + //``` + let variant = self.enum_variant_name(idx); + let field_name = self.field_name(idx); + + let fn_invoc = if let Some(ref env_var) = self.env_var { + quote! { FromEnvVar::from_env_var(#env_var) } + } else { + quote! { FromEnv::from_env() } + }; + + let map_line = if self.infallible { + quote! { FromEnvErr::infallible_into } + } else { + quote! { |e| e.map(#err_ident::#variant) } + }; + + quote! { + let #field_name = #fn_invoc + .map_err(#map_line)?; + } + } +} diff --git a/from-env-derive/src/lib.rs b/from-env-derive/src/lib.rs new file mode 100644 index 0000000..62b6212 --- /dev/null +++ b/from-env-derive/src/lib.rs @@ -0,0 +1,249 @@ +use proc_macro::TokenStream as Ts; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{DeriveInput, parse_macro_input}; + +mod field; +use field::Field; + +/// This macro generates an implementation of the `FromEnv` trait for a struct. +/// See the documenetation in init4_bin_base for more details. +#[proc_macro_derive(FromEnv, attributes(from_env))] +pub fn derive(input: Ts) -> Ts { + let input = parse_macro_input!(input as DeriveInput); + + if !matches!(input.data, syn::Data::Struct(_)) { + syn::Error::new( + input.ident.span(), + "FromEnv can only be derived for structs", + ) + .to_compile_error(); + }; + + let syn::Data::Struct(data) = &input.data else { + unreachable!() + }; + + let crate_name = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("from_env")) + .and_then(|attr| attr.parse_args::().ok()) + .unwrap_or_else(|| syn::parse_str::("::init4_bin_base").unwrap()); + + let tuple_like = matches!(data.fields, syn::Fields::Unnamed(_)); + + if matches!(data.fields, syn::Fields::Unit) { + syn::Error::new( + input.ident.span(), + "FromEnv can only be derived for structs with fields", + ) + .to_compile_error(); + } + + let fields = match &data.fields { + syn::Fields::Named(fields) => fields.named.iter().map(Field::try_from), + syn::Fields::Unnamed(fields) => fields.unnamed.iter().map(Field::try_from), + syn::Fields::Unit => unreachable!(), + }; + + let fields = match fields.collect::, _>>() { + Ok(fields) => fields, + Err(err) => { + return err.to_compile_error().into(); + } + }; + + let input = Input { + ident: input.ident.clone(), + fields, + crate_name, + tuple_like, + }; + + input.expand_mod().into() +} + +struct Input { + ident: syn::Ident, + + fields: Vec, + + crate_name: syn::Path, + + tuple_like: bool, +} + +impl Input { + fn field_names(&self) -> Vec { + self.fields + .iter() + .enumerate() + .map(|(idx, field)| field.field_name(idx)) + .collect() + } + + fn instantiate_struct(&self) -> TokenStream { + let struct_name = &self.ident; + let field_names = self.field_names(); + + if self.tuple_like { + return quote! { + #struct_name( + #(#field_names),* + ) + }; + } + + quote! { + #struct_name { + #(#field_names),* + } + } + } + + fn error_ident(&self) -> syn::Ident { + let error_name = format!("{}EnvError", self.ident); + syn::parse_str::(&error_name) + .map_err(|_| { + syn::Error::new(self.ident.span(), "Failed to parse error ident").to_compile_error() + }) + .unwrap() + } + + fn error_variants(&self) -> Vec { + self.fields + .iter() + .enumerate() + .flat_map(|(idx, field)| field.expand_enum_variant(idx)) + .collect() + } + + fn error_variant_displays(&self) -> Vec { + self.fields + .iter() + .enumerate() + .flat_map(|(idx, field)| field.expand_variant_display(idx)) + .collect::>() + } + + fn expand_variant_sources(&self) -> Vec { + self.fields + .iter() + .enumerate() + .flat_map(|(idx, field)| field.expand_variant_source(idx)) + .collect::>() + } + + fn item_from_envs(&self) -> Vec { + let error_ident = self.error_ident(); + self.fields + .iter() + .enumerate() + .map(|(idx, field)| field.expand_item_from_env(&error_ident, idx)) + .collect() + } + + fn expand_error(&self) -> TokenStream { + let error_ident = self.error_ident(); + let struct_name_str = &self.ident.to_string(); + + let error_variants = self.error_variants(); + let error_variant_displays = self.error_variant_displays(); + let error_variant_sources = self.expand_variant_sources(); + + quote! { + #[doc = "Generated error type for [`FromEnv`] for"] + #[doc = #struct_name_str] + #[doc = ". This error type is used to represent errors that occur when trying to create an instance of the struct from environment variables."] + #[derive(Debug, PartialEq, Eq, Clone)] + pub enum #error_ident { + #(#error_variants),* + } + + #[automatically_derived] + impl ::core::fmt::Display for #error_ident { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + match self { + #( + #error_variant_displays, + )* + } + } + } + + #[automatically_derived] + impl ::core::error::Error for #error_ident { + fn source(&self) -> Option<&(dyn ::core::error::Error + 'static)> { + match self { + #( + #error_variant_sources, + )* + } + } + } + } + } + + fn env_item_info(&self) -> Vec { + self.fields + .iter() + .map(|field| field.expand_env_item_info()) + .collect() + } + + fn expand_impl(&self) -> TokenStream { + let env_item_info = self.env_item_info(); + let struct_name = &self.ident; + let error_ident = self.error_ident(); + + let item_from_envs = self.item_from_envs(); + let struct_instantiation = self.instantiate_struct(); + + quote! { + #[automatically_derived] + impl FromEnv for #struct_name { + type Error = #error_ident; + + fn inventory() -> ::std::vec::Vec<&'static EnvItemInfo> { + let mut items = ::std::vec::Vec::new(); + #( + #env_item_info + )* + items + } + + fn from_env() -> ::std::result::Result> { + #( + #item_from_envs + )* + + ::std::result::Result::Ok(#struct_instantiation) + } + } + } + } + + fn expand_mod(&self) -> TokenStream { + // let expanded_impl = expand_impl(input); + let expanded_error = self.expand_error(); + let expanded_impl = self.expand_impl(); + let crate_name = &self.crate_name; + let error_ident = self.error_ident(); + + let mod_ident = + syn::parse_str::(&format!("__from_env_impls_{}", self.ident)).unwrap(); + + quote! { + pub use #mod_ident::#error_ident; + mod #mod_ident { + use super::*; + use #crate_name::utils::from_env::{FromEnv, FromEnvErr, FromEnvVar, EnvItemInfo}; + + #expanded_impl + + #expanded_error + } + } + } +} diff --git a/from-env-derive/tests/macro.rs b/from-env-derive/tests/macro.rs new file mode 100644 index 0000000..3a921e6 --- /dev/null +++ b/from-env-derive/tests/macro.rs @@ -0,0 +1,131 @@ +use init4_from_env_derive::FromEnv; + +#[derive(FromEnv, Debug)] +pub struct FromEnvTest { + /// This is a guy named tony + /// He is cool + /// He is a good guy + #[from_env(var = "FIELD1", desc = "Tony is cool and a u8")] + pub tony: u8, + + /// This guy is named charles + /// whatever. + #[from_env(var = "FIELD2", desc = "Charles is a u64")] + pub charles: u64, + + /// This is a guy named patrick + #[from_env(var = "FIELD3", infallible, desc = "Patrick is a String")] + pub patrick: String, + + /// This is a guy named oliver + #[from_env( + var = "FIELD4", + optional, + infallible, + desc = "Oliver is an Option" + )] + pub oliver: Option, +} + +#[derive(Debug, FromEnv)] +pub struct Nested { + #[from_env(var = "FFFFFF", desc = "This is a guy named ffffff")] + pub ffffff: String, + + /// Hi + pub from_env_test: FromEnvTest, +} + +#[cfg(test)] +mod test { + use super::*; + use init4_bin_base::utils::from_env::{EnvItemInfo, FromEnv}; + + #[test] + fn load_nested() { + unsafe { + std::env::set_var("FIELD1", "1"); + std::env::set_var("FIELD2", "2"); + std::env::set_var("FIELD3", "3"); + std::env::set_var("FIELD4", "4"); + std::env::set_var("FFFFFF", "5"); + } + + let nested = Nested::from_env().unwrap(); + assert_eq!(nested.from_env_test.tony, 1); + assert_eq!(nested.from_env_test.charles, 2); + assert_eq!(nested.from_env_test.patrick, "3"); + assert_eq!(nested.from_env_test.oliver, Some("4".to_string())); + assert_eq!(nested.ffffff, "5"); + + unsafe { + std::env::remove_var("FIELD4"); + } + + let nested = Nested::from_env().unwrap(); + assert_eq!(nested.from_env_test.tony, 1); + assert_eq!(nested.from_env_test.charles, 2); + assert_eq!(nested.from_env_test.patrick, "3"); + assert_eq!(nested.from_env_test.oliver, None); + assert_eq!(nested.ffffff, "5"); + } + + fn assert_contains(vec: &Vec<&'static EnvItemInfo>, item: &EnvItemInfo) { + let item = vec.iter().find(|i| i.var == item.var).unwrap(); + assert_eq!(item.var, item.var); + assert_eq!(item.description, item.description); + assert_eq!(item.optional, item.optional); + } + + #[test] + fn nested_inventory() { + let fet_inv = FromEnvTest::inventory(); + assert_eq!(fet_inv.len(), 4); + assert_contains( + &fet_inv, + &EnvItemInfo { + var: "FIELD1", + description: "Tony is cool and a u8", + optional: false, + }, + ); + assert_contains( + &fet_inv, + &EnvItemInfo { + var: "FIELD2", + description: "Charles is a u64", + optional: false, + }, + ); + assert_contains( + &fet_inv, + &EnvItemInfo { + var: "FIELD3", + description: "Patrick is a String", + optional: false, + }, + ); + assert_contains( + &fet_inv, + &EnvItemInfo { + var: "FIELD4", + description: "Oliver is an Option", + optional: true, + }, + ); + + let nest_inv = Nested::inventory(); + assert_eq!(nest_inv.len(), fet_inv.len() + 1); + for item in fet_inv { + assert_contains(&nest_inv, item); + } + assert_contains( + &nest_inv, + &EnvItemInfo { + var: "FFFFFF", + description: "This is a guy named ffffff", + optional: false, + }, + ); + } +} diff --git a/src/perms/builders.rs b/src/perms/builders.rs index 68a590d..8e37e85 100644 --- a/src/perms/builders.rs +++ b/src/perms/builders.rs @@ -7,17 +7,14 @@ //! token they acquire from our OAuth service. use crate::{ - perms::{SlotAuthzConfig, SlotAuthzConfigError}, + perms::SlotAuthzConfig, utils::{ calc::SlotCalculator, - from_env::{EnvItemInfo, FromEnv, FromEnvErr, FromEnvVar}, + from_env::{FromEnv, FromEnvErr, FromEnvVar}, }, }; use serde::{Deserialize, Deserializer}; -/// The builder list env var. -const BUILDERS: &str = "PERMISSIONED_BUILDERS"; - fn now() -> u64 { chrono::Utc::now().timestamp().try_into().unwrap() } @@ -38,25 +35,6 @@ pub enum BuilderPermissionError { NotPermissioned, } -/// Possible errors when loading the builder configuration. -#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] -pub enum BuilderConfigError { - /// Error loading the environment variable. - #[error( - "failed to parse environment variable. Expected a comma-seperated list of UUIDs. Got: {input}" - )] - ParseError { - /// The environment variable name. - env_var: String, - /// The contents of the environment variable. - input: String, - }, - - /// Error loading the slot authorization configuration. - #[error(transparent)] - SlotAutzConfig(#[from] SlotAuthzConfigError), -} - /// An individual builder. #[derive(Clone, Debug, serde::Deserialize)] #[serde(from = "String")] @@ -85,14 +63,30 @@ impl Builder { } } +impl FromEnvVar for Builder { + type Error = std::convert::Infallible; + + fn from_env_var(env_var: &str) -> Result> { + Ok(Self { + sub: String::from_env_var(env_var)?, + }) + } +} + /// Builders struct to keep track of the builders that are allowed to perform actions. -#[derive(Clone, Debug, serde::Deserialize)] +#[derive(Clone, Debug, serde::Deserialize, FromEnv)] +#[from_env(crate)] pub struct Builders { /// The list of builders. /// /// This is configured in the environment variable `PERMISSIONED_BUILDERS`, /// as a list of comma-separated UUIDs. #[serde(deserialize_with = "deser_builders")] + #[from_env( + infallible, + var = "BUILDERS", + desc = "A comma-separated list of UUIDs representing the builders that are allowed to perform actions." + )] pub builders: Vec, /// The slot authorization configuration. See [`SlotAuthzConfig`] for more @@ -191,49 +185,21 @@ impl Builders { } } -impl FromEnv for Builders { - type Error = BuilderConfigError; - - fn inventory() -> Vec<&'static EnvItemInfo> { - let mut v = vec![ - &EnvItemInfo { - var: BUILDERS, - description: "A comma-separated list of UUIDs representing the builders that are allowed to perform actions.", - optional: false, - }, - ]; - v.extend(SlotAuthzConfig::inventory()); - v - } - - fn from_env() -> Result> { - let s = String::from_env_var(BUILDERS) - .map_err(FromEnvErr::infallible_into::)?; - let builders = split_builders(&s); - - let config = SlotAuthzConfig::from_env().map_err(FromEnvErr::from)?; - - Ok(Self { builders, config }) - } -} - #[cfg(test)] mod test { - use super::*; - use crate::{perms, utils::calc}; #[test] fn load_builders() { unsafe { - std::env::set_var(BUILDERS, "0,1,2,3,4,5"); + std::env::set_var("BUILDERS", "0,1,2,3,4,5"); - std::env::set_var(calc::START_TIMESTAMP, "1"); - std::env::set_var(calc::SLOT_OFFSET, "0"); - std::env::set_var(calc::SLOT_DURATION, "12"); + std::env::set_var("START_TIMESTAMP", "1"); + std::env::set_var("SLOT_OFFSET", "0"); + std::env::set_var("SLOT_DURATION", "12"); - std::env::set_var(perms::config::BLOCK_QUERY_START, "1"); - std::env::set_var(perms::config::BLOCK_QUERY_CUTOFF, "11"); + std::env::set_var("BLOCK_QUERY_START", "1"); + std::env::set_var("BLOCK_QUERY_CUTOFF", "11"); }; let builders = Builders::from_env().unwrap(); diff --git a/src/perms/config.rs b/src/perms/config.rs index 93846ef..6796ee0 100644 --- a/src/perms/config.rs +++ b/src/perms/config.rs @@ -1,31 +1,10 @@ -use crate::utils::{ - calc::{SlotCalcEnvError, SlotCalculator}, - from_env::{EnvItemInfo, FromEnv, FromEnvErr, FromEnvVar}, -}; -use core::num; - -// Environment variable names for configuration -pub(crate) const BLOCK_QUERY_CUTOFF: &str = "BLOCK_QUERY_CUTOFF"; -pub(crate) const BLOCK_QUERY_START: &str = "BLOCK_QUERY_START"; - -/// Possible errors when loading the slot authorization configuration. -#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] -pub enum SlotAuthzConfigError { - /// Error reading environment variable. - #[error("error reading chain offset: {0}")] - Calculator(#[from] SlotCalcEnvError), - /// Error reading block query cutoff. - #[error("error reading block query cutoff: {0}")] - BlockQueryCutoff(num::ParseIntError), - /// Error reading block query start. - #[error("error reading block query start: {0}")] - BlockQueryStart(num::ParseIntError), -} +use crate::utils::{calc::SlotCalculator, from_env::FromEnv}; /// Configuration object that describes the slot time settings for a chain. /// /// This struct is used to configure the slot authorization system -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, FromEnv)] +#[from_env(crate)] pub struct SlotAuthzConfig { /// A [`SlotCalculator`] instance that can be used to calculate the slot /// number for a given timestamp. @@ -36,6 +15,10 @@ pub struct SlotAuthzConfig { /// /// On loading from env, the number will be clamped between 0 and 11, as /// the slot duration is 12 seconds. + #[from_env( + var = "BLOCK_QUERY_CUTOFF", + desc = "The block query cutoff time in seconds." + )] block_query_cutoff: u8, /// The block query start time in seconds. This is the slot second before /// which requests will not be serviced. E.g. a value of 1 means that @@ -43,6 +26,10 @@ pub struct SlotAuthzConfig { /// /// On loading from env, the number will be clamped between 0 and 11, as /// the slot duration is 12 seconds. + #[from_env( + var = "BLOCK_QUERY_START", + desc = "The block query start time in seconds." + )] block_query_start: u8, } @@ -72,38 +59,3 @@ impl SlotAuthzConfig { self.block_query_start as u64 } } - -impl FromEnv for SlotAuthzConfig { - type Error = SlotAuthzConfigError; - - fn inventory() -> Vec<&'static EnvItemInfo> { - let mut v = vec![ - &EnvItemInfo { - var: BLOCK_QUERY_CUTOFF, - description: "The block query cutoff time in seconds. This is the slot second after which requests will not be serviced. E.g. a value of 1 means that requests will not be serviced for the last second of any given slot.", - optional: false, - }, &EnvItemInfo { - var: BLOCK_QUERY_START, - description: "The block query start time in seconds. This is the slot second before which requests will not be serviced. E.g. a value of 1 means that requests will not be serviced for the first second of any given slot.", - optional: false, - }]; - v.extend(SlotCalculator::inventory()); - v - } - - fn from_env() -> Result> { - let calc = SlotCalculator::from_env().map_err(FromEnvErr::from)?; - let block_query_cutoff = u8::from_env_var(BLOCK_QUERY_CUTOFF) - .map_err(|e| e.map(SlotAuthzConfigError::BlockQueryCutoff))? - .clamp(0, 11); - let block_query_start = u8::from_env_var(BLOCK_QUERY_START) - .map_err(|e| e.map(SlotAuthzConfigError::BlockQueryStart))? - .clamp(0, 11); - - Ok(Self { - calc, - block_query_cutoff, - block_query_start, - }) - } -} diff --git a/src/perms/mod.rs b/src/perms/mod.rs index 6bbed82..d81e802 100644 --- a/src/perms/mod.rs +++ b/src/perms/mod.rs @@ -1,5 +1,5 @@ pub(crate) mod builders; -pub use builders::{Builder, BuilderConfigError, BuilderPermissionError, Builders}; +pub use builders::{Builder, BuilderPermissionError, Builders, BuildersEnvError}; pub(crate) mod config; -pub use config::{SlotAuthzConfig, SlotAuthzConfigError}; +pub use config::{SlotAuthzConfig, SlotAuthzConfigEnvError}; diff --git a/src/utils/calc.rs b/src/utils/calc.rs index 6efa44e..d2c81cb 100644 --- a/src/utils/calc.rs +++ b/src/utils/calc.rs @@ -1,32 +1,15 @@ -use crate::utils::from_env::{FromEnv, FromEnvErr, FromEnvVar}; -use core::num; - -use super::from_env::EnvItemInfo; - -// Env vars -pub(crate) const START_TIMESTAMP: &str = "START_TIMESTAMP"; -pub(crate) const SLOT_OFFSET: &str = "SLOT_OFFSET"; -pub(crate) const SLOT_DURATION: &str = "SLOT_DURATION"; - -/// Possible errors when loading the slot authorization configuration. -#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] -pub enum SlotCalcEnvError { - /// Error reading environment variable. - #[error("error reading the start timestamp: {0}")] - StartTimestamp(num::ParseIntError), - /// Error reading block query cutoff. - #[error("error reading slot offset: {0}")] - SlotOffset(num::ParseIntError), - /// Error reading block query start. - #[error("error reading slot duration: {0}")] - SlotDuration(num::ParseIntError), -} +use crate::utils::from_env::FromEnv; /// A slot calculator, which can calculate the slot number for a given /// timestamp. -#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize, FromEnv)] +#[from_env(crate)] pub struct SlotCalculator { /// The start timestamp. + #[from_env( + var = "START_TIMESTAMP", + desc = "The start timestamp of the chain in seconds" + )] start_timestamp: u64, /// This is the number of the slot containing the block which contains the @@ -35,9 +18,17 @@ pub struct SlotCalculator { /// This is needed for chains that contain a merge (like Ethereum Mainnet), /// or for chains with missed slots at the start of the chain (like /// Holesky). + #[from_env( + var = "SLOT_OFFSET", + desc = "The number of the slot containing the start timestamp" + )] slot_offset: u64, /// The slot duration (in seconds). + #[from_env( + var = "SLOT_DURATION", + desc = "The slot duration of the chain in seconds" + )] slot_duration: u64, } @@ -124,42 +115,6 @@ impl SlotCalculator { } } -impl FromEnv for SlotCalculator { - type Error = SlotCalcEnvError; - - fn inventory() -> Vec<&'static EnvItemInfo> { - vec![ - &EnvItemInfo { - var: START_TIMESTAMP, - description: "The start timestamp of the chain in seconds", - optional: false, - }, - &EnvItemInfo { - var: SLOT_OFFSET, - description: "The slot offset of the chain in seconds", - optional: false, - }, - &EnvItemInfo { - var: SLOT_DURATION, - description: "The slot duration of the chain in seconds", - optional: false, - }, - ] - } - - fn from_env() -> Result> { - let start_timestamp = u64::from_env_var(START_TIMESTAMP) - .map_err(|e| e.map(SlotCalcEnvError::StartTimestamp))?; - let slot_offset = - u64::from_env_var(SLOT_OFFSET).map_err(|e| e.map(SlotCalcEnvError::SlotOffset))?; - - let slot_duration = - u64::from_env_var(SLOT_DURATION).map_err(|e| e.map(SlotCalcEnvError::SlotDuration))?; - - Ok(Self::new(start_timestamp, slot_offset, slot_duration)) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/utils/from_env.rs b/src/utils/from_env.rs index 50a8eb5..0f3dde2 100644 --- a/src/utils/from_env.rs +++ b/src/utils/from_env.rs @@ -1,5 +1,96 @@ use std::{convert::Infallible, env::VarError, num::ParseIntError, str::FromStr}; +/// The `derive(FromEnv)` macro. +/// +/// This macro generates a [`FromEnv`] implementation for the struct it is +/// applied to. It will generate a `from_env` function that loads the struct +/// from the environment. It will also generate an `inventory` function that +/// returns a list of all environment variables that are required to load the +/// struct. +/// +/// The macro also generates a `__EnvError` type that captures errors that can +/// occur when trying to create an instance of the struct from environment +/// variables. This error type is used in the `FromEnv` trait implementation. +/// +/// ## Conditions of use +/// +/// There are a few usage requirements: +/// +/// - Struct props MUST implement either [`FromEnvVar`] or [`FromEnv`]. +/// - If the prop implements [`FromEnvVar`], it must be tagged as follows: +/// - `var = "ENV_VAR_NAME"`: The environment variable name to load. +/// - `desc = "description"`: A description of the environment variable. +/// - If the prop is an [`Option`], it must be tagged as follows: +/// - `optional` +/// - If the prop's associated error type is [`Infallible`], it must be tagged +/// as follows: +/// - `infallible` +/// - If used within this crate (`init4_bin_base`), the entire struct must be +/// tagged with `#[from_env(crate)]` (see the [`SlotCalculator`] for an +/// example). +/// +/// # Examples +/// +/// The following example shows how to use the macro: +/// +/// ``` +/// # // I am unsure why we need this, as identical code works in +/// # // integration tests. However, compile test fails without it. +/// # #![allow(proc_macro_derive_resolution_fallback)] +/// use init4_bin_base::utils::from_env::{FromEnv}; +/// +/// #[derive(Debug, FromEnv)] +/// pub struct MyCfg { +/// #[from_env(var = "COOL_DUDE", desc = "Some u8 we like :o)")] +/// pub my_cool_u8: u8, +/// +/// #[from_env(var = "CHUCK", desc = "Charles is a u64")] +/// pub charles: u64, +/// +/// #[from_env( +/// var = "PERFECT", +/// desc = "A bold and neat string", +/// infallible, +/// )] +/// pub strings_cannot_fail: String, +/// +/// #[from_env( +/// var = "MAYBE_NOT_NEEDED", +/// desc = "This is an optional string", +/// optional, +/// infallible, +/// )] +/// maybe_not_needed: Option, +/// } +/// +/// // The `FromEnv` trait is implemented for the struct, and the struct can +/// // be loaded from the environment. +/// # fn use_it() { +/// if let Err(missing) = MyCfg::check_inventory() { +/// println!("Missing environment variables:"); +/// for var in missing { +/// println!("{}: {}", var.var, var.description); +/// } +/// } +/// # } +/// ``` +/// +/// This will generate a `FromEnv` implementation for the struct, and a +/// `MyCfgEnvError` type that is used to represent errors that can occur when +/// loading from the environment. The error generated will look like this: +/// +/// ```ignore +/// pub enum MyCfgEnvError { +/// MyCoolU8(::Error), +/// Charles(::Error), +/// // No variants for infallible errors. +/// } +/// ``` +/// +/// [`Infallible`]: std::convert::Infallible +/// [`SlotCalculator`]: crate::utils::SlotCalculator +pub use init4_from_env_derive::FromEnv; + /// Details about an environment variable. This is used to generate /// documentation for the environment variables and by the [`FromEnv`] trait to /// check if necessary environment variables are present. @@ -101,10 +192,48 @@ pub fn parse_env_if_present(env_var: &str) -> Result`] as +/// the `Error` associated type in [`FromEnv`].** +/// +/// ```no_compile +/// // Do not do this +/// impl FromEnv for MyType { +/// type Error = FromEnvErr; +/// } +/// +/// // Instead do this: +/// impl FromEnv for MyType { +/// type Error = MyTypeErr; +/// } +/// ``` +/// pub trait FromEnv: core::fmt::Debug + Sized + 'static { /// Error type produced when loading from the environment. - type Error: core::error::Error; + type Error: core::error::Error + Clone; /// Get the required environment variable names for this type. /// @@ -144,6 +273,55 @@ pub trait FromEnv: core::fmt::Debug + Sized + 'static { /// /// It aims to make [`FromEnv`] implementations easier to write, by providing a /// default implementation for common types. +/// +/// ## Note on error types +/// +/// [`FromEnv`] and [`FromEnvVar`] are often deeply nested. This means that +/// error types are often nested as well. To avoid this, we use a single error +/// type [`FromEnvVar`] that wraps an inner error type. This allows us to +/// ensure that env-related errors (e.g. missing env vars) are not lost in the +/// recursive structure of parsing errors. Environment errors are always at the +/// top level, and should never be nested. **Do not use [`FromEnvErr`] as +/// the `Error` associated type in [`FromEnv`].** +/// +/// ```no_compile +/// // Do not do this +/// impl FromEnv for MyType { +/// type Error = FromEnvErr; +/// } +/// +/// // Instead do this: +/// impl FromEnv for MyType { +/// type Error = MyTypeErr; +/// } +/// ``` +/// +/// ## Implementing [`FromEnv`] +/// +/// [`FromEnvVar`] is a trait for loading simple types from the environment. It +/// represents a type that can be loaded from a single environment variable. It +/// is similar to [`FromStr`] and will usually be using an existing [`FromStr`] +/// impl. +/// +/// ``` +/// # use init4_bin_base::utils::from_env::FromEnvVar; +/// # pub struct MyCoolType; +/// # impl std::str::FromStr for MyCoolType { +/// # type Err; +/// # fn from_str(s: &str) -> Result { +/// # Ok(MyCoolType) +/// # } +/// # } +/// // We can re-use the `FromStr` implementation for our `FromEnvVar` impl. +/// impl FromEnvVar for MyCoolType { +/// type Error = ::Err; +/// +/// fn from_env_var(env_var: &str) -> Result> +/// { +/// String::from_env_var(env_var).unwrap().parse().map_err(Into::into) +/// } +/// } +/// ``` pub trait FromEnvVar: core::fmt::Debug + Sized + 'static { /// Error type produced when parsing the primitive. type Error: core::error::Error; @@ -183,6 +361,24 @@ impl FromEnvVar for std::time::Duration { } } +impl FromEnvVar for Vec +where + T: From + core::fmt::Debug + 'static, +{ + type Error = Infallible; + + fn from_env_var(env_var: &str) -> Result> { + let s = std::env::var(env_var).map_err(|e| FromEnvErr::env_err(env_var, e))?; + if s.is_empty() { + return Ok(vec![]); + } + Ok(s.split(',') + .map(str::to_string) + .map(Into::into) + .collect::>()) + } +} + macro_rules! impl_for_parseable { ($($t:ty),*) => { $( diff --git a/tests/macro.rs b/tests/macro.rs new file mode 100644 index 0000000..3b81031 --- /dev/null +++ b/tests/macro.rs @@ -0,0 +1,42 @@ +#![deny(proc_macro_derive_resolution_fallback)] + +use init4_bin_base::utils::from_env::FromEnv; + +#[derive(Debug, FromEnv)] +pub struct MyCfg { + #[from_env(var = "COOL_DUDE", desc = "Some u8 we like :o)")] + pub my_cool_u8: u8, + + #[from_env(var = "CHUCK", desc = "Charles is a u64")] + pub charles: u64, + + #[from_env(var = "PERFECT", desc = "A bold and neat string", infallible)] + pub strings_cannot_fail: String, + + #[from_env( + var = "MAYBE_NOT_NEEDED", + desc = "This is an optional string", + optional, + infallible + )] + pub maybe_not_needed: Option, +} + +#[derive(Debug, FromEnv)] +pub struct MyTupleCfg( + #[from_env(var = "FIRST_ELEMENT", desc = "this is the first tuple element")] pub u8, + pub MyCfg, +); + +#[test] +fn basic_inventory() { + let inv = MyCfg::inventory(); + assert_eq!(inv.len(), 4); +} + +#[test] +fn nested_inventory() { + let inv = MyTupleCfg::inventory(); + assert_eq!(inv.len(), 5); + dbg!(inv); +}