Add Source trait and implement it for files (TOML and YAML for now)

This commit is contained in:
Lukas Kalbertodt
2021-05-16 15:27:47 +02:00
parent 6d1e9e99c6
commit 32fbfd3a5a
8 changed files with 268 additions and 25 deletions

View File

@@ -5,16 +5,21 @@ authors = ["Lukas Kalbertodt <lukas.kalbertodt@gmail.com>"]
edition = "2018"
[dependencies]
confique-macro = { path = "macro" }
serde = { version = "1", features = ["derive"] }
log = { version = "0.4", features = ["serde"], optional = true }
[dev-dependencies]
log = { version = "0.4", features = ["serde"] }
[features]
default = ["toml", "yaml"]
yaml = ["serde_yaml"]
# This is used to enable the `example` module. This is only useful to generate
# the docs for this library!
doc-example = ["log"]
[dependencies]
confique-macro = { path = "macro" }
log = { version = "0.4", features = ["serde"], optional = true }
serde = { version = "1", features = ["derive"] }
serde_yaml = { version = "0.8", optional = true }
toml = { version = "0.5", optional = true }
[dev-dependencies]
log = { version = "0.4", features = ["serde"] }
anyhow = "1"

View File

@@ -0,0 +1,2 @@
http:
port: 4321

View File

@@ -0,0 +1,2 @@
[cat]
foo = "yes"

View File

@@ -1,5 +1,5 @@
use std::net::IpAddr;
use confique::{Config, Partial};
use std::{net::IpAddr, path::Path};
use confique::Config;
#[derive(Debug, Config)]
struct Conf {
@@ -26,6 +26,13 @@ struct Cat {
}
fn main() {
println!("{:#?}", Conf::from_partial(<Conf as Config>::Partial::default_values()));
fn main() -> Result<(), anyhow::Error> {
let r = Conf::from_sources(&[
&Path::new("examples/files/simple.toml"),
&Path::new("examples/files/etc/simple.yaml"),
])?;
println!("{:#?}", r);
Ok(())
}

View File

@@ -26,17 +26,12 @@ fn gen_config_impl(input: &ir::Input) -> TokenStream {
if !f.is_leaf() {
quote! {
confique::Config::from_partial(partial.#field_name).map_err(|e| {
match e {
confique::Error::MissingValue(path) => {
confique::Error::MissingValue(format!("{}.{}", #path, path))
}
e => e,
}
confique::internal::prepend_missing_value_error(e, #path)
})?
}
} else if unwrap_option(&f.ty).is_none() {
quote! {
partial.#field_name.ok_or(confique::Error::MissingValue(#path.into()))?
partial.#field_name.ok_or(confique::internal::missing_value_error(#path.into()))?
}
} else {
quote! { partial.#field_name }

View File

@@ -2,6 +2,8 @@
//! intended to be used directly. None of this is covered by semver! Do not use
//! any of this directly.
use crate::{Error, ErrorInner};
pub fn deserialize_default<I, O>(src: I) -> Result<O, serde::de::value::Error>
where
I: for<'de> serde::de::IntoDeserializer<'de>,
@@ -9,3 +11,16 @@ where
{
O::deserialize(src.into_deserializer())
}
pub fn missing_value_error(path: String) -> Error {
ErrorInner::MissingValue(path).into()
}
pub fn prepend_missing_value_error(e: Error, prefix: &str) -> Error {
match *e.inner {
ErrorInner::MissingValue(path) => {
ErrorInner::MissingValue(format!("{}.{}", prefix, path)).into()
}
e => e.into(),
}
}

View File

@@ -1,4 +1,4 @@
use std::fmt;
use std::{ffi::OsString, fmt, path::PathBuf};
use serde::Deserialize;
@@ -13,11 +13,23 @@ pub use confique_macro::Config;
#[doc(hidden)]
pub mod internal;
pub mod source;
pub trait Config: Sized {
type Partial: Partial;
fn from_partial(partial: Self::Partial) -> Result<Self, Error>;
fn from_sources(sources: &[&dyn Source<Self>]) -> Result<Self, Error> {
let mut partial = Self::Partial::default_values();
for src in sources.iter().rev() {
let layer = src.load()?;
partial = layer.with_fallback(partial);
}
Self::from_partial(partial)
}
}
pub trait Partial: for<'de> Deserialize<'de> {
@@ -26,22 +38,99 @@ pub trait Partial: for<'de> Deserialize<'de> {
fn with_fallback(self, fallback: Self) -> Self;
}
/// A source of configuration values for the configuration `T`, e.g. a file or
/// environment variables.
pub trait Source<C: Config> {
fn load(&self) -> Result<C::Partial, Error>;
}
pub enum Error {
pub struct Error {
inner: Box<ErrorInner>,
}
enum ErrorInner {
/// Returned by `Config::from_partial` when the partial does not contain
/// values for all required configuration values. The string is a
/// human-readable path to the value, e.g. `http.port`.
MissingValue(String),
/// An IO error occured, e.g. when reading a file.
Io {
path: Option<PathBuf>,
err: std::io::Error,
},
/// Returned by `Source::load` implementations when deserialization fails.
Deserialization {
/// A human readable description for the error message, describing from
/// what source it was attempted to deserialize. Completes the sentence
/// "failed to deserialize configuration from ". E.g. "file 'foo.toml'"
/// or "environment variable 'FOO_PORT'".
source: Option<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.
UnsupportedFileFormat {
path: PathBuf,
extension: OsString,
},
/// Returned by the [`Source`] impls for `Path` and `PathBuf` if the path
/// does not contain a file extension.
MissingFileExtension {
path: PathBuf,
}
}
impl std::error::Error for Error {}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &*self.inner {
ErrorInner::Io { err, .. } => Some(err),
ErrorInner::Deserialization { err, .. } => Some(&**err),
ErrorInner::MissingValue(_)
| ErrorInner::UnsupportedFileFormat { .. }
| ErrorInner::MissingFileExtension { .. } => None,
}
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::MissingValue(path) => {
match &*self.inner {
ErrorInner::MissingValue(path) => {
std::write!(f, "required configuration value is missing: '{}'", path)
}
ErrorInner::Io { path: Some(path), .. } => {
std::write!(f,
"IO error occured while reading configuration file '{}'",
path.display(),
)
}
ErrorInner::Io { path: None, .. } => {
std::write!(f, "IO error occured while loading configuration")
}
ErrorInner::Deserialization { source: Some(source), .. } => {
std::write!(f, "failed to deserialize configuration from {}", source)
}
ErrorInner::Deserialization { source: None, .. } => {
std::write!(f, "failed to deserialize configuration")
}
ErrorInner::UnsupportedFileFormat { path, extension } => {
std::write!(f,
"unknown configuration file format '{}' of '{}'",
extension.to_string_lossy(),
path.display(),
)
}
ErrorInner::MissingFileExtension { path } => {
std::write!(f,
"cannot guess configuration file format due to missing file extension in '{}'",
path.display(),
)
}
}
}
}
@@ -51,3 +140,9 @@ impl fmt::Debug for Error {
fmt::Display::fmt(self, f)
}
}
impl From<ErrorInner> for Error {
fn from(inner: ErrorInner) -> Self {
Self { inner: Box::new(inner) }
}
}

122
src/source.rs Normal file
View File

@@ -0,0 +1,122 @@
//! Types implementing [`Source`], thus representing some source of
//! configuration values.
use std::{ffi::OsStr, fs, io, path::{Path, PathBuf}};
use crate::{Config, Error, ErrorInner, Partial, Source};
impl<C: Config> Source<C> for &Path {
fn load(&self) -> Result<C::Partial, Error> {
let ext = self.extension().ok_or_else(|| {
ErrorInner::MissingFileExtension { path: self.into() }
})?;
let format = FileFormat::from_extension(ext).ok_or_else(|| {
ErrorInner::UnsupportedFileFormat { extension: ext.into(), path: self.into() }
})?;
<File as Source<C>>::load(&File::new(self, format))
}
}
impl<C: Config> Source<C> for PathBuf {
fn load(&self) -> Result<C::Partial, Error> {
<&Path as Source<C>>::load(&&**self)
}
}
pub struct File {
path: PathBuf,
format: FileFormat,
required: bool,
}
impl File {
pub fn new(path: impl Into<PathBuf>, format: FileFormat) -> Self {
Self {
path: path.into(),
format,
required: false,
}
}
#[cfg(feature = "toml")]
pub fn toml(path: impl Into<PathBuf>) -> Self {
Self::new(path, FileFormat::Toml)
}
#[cfg(feature = "yaml")]
pub fn yaml(path: impl Into<PathBuf>) -> Self {
Self::new(path, FileFormat::Yaml)
}
/// Marks this file as required, meaning that `<File as Source<_>>::load`
/// will return an error if the file does not exist. Otherwise, an empty
/// layer (all values are `None`) is returned.
pub fn required(mut self) -> Self {
self.required = true;
self
}
}
impl<C: Config> Source<C> for File {
// Unfortunately, if no file format is enabled, this emits unused variable
// warnings. This should not happen as `self`, a type containing an empty
// enum, is in scope, meaning that the code cannot be reached.
#[cfg_attr(
not(any(feature = "toml", feature = "yaml")),
allow(unused_variables),
)]
fn load(&self) -> Result<C::Partial, Error> {
// Load file contents. If the file does not exist and was not marked as
// required, we just return an empty layer.
let file_content = match fs::read(&self.path) {
Ok(v) => v,
Err(e) if e.kind() == io::ErrorKind::NotFound && !self.required => {
return Ok(C::Partial::empty());
}
Err(e) => {
return Err(ErrorInner::Io {
path: Some(self.path.clone()),
err: e,
}.into());
}
};
// Helper closure to create an error.
let error = |err| Error::from(ErrorInner::Deserialization {
err,
source: Some(format!("file '{}'", self.path.display())),
});
match self.format {
#[cfg(feature = "toml")]
FileFormat::Toml => toml::from_slice(&file_content)
.map_err(|e| error(Box::new(e))),
#[cfg(feature = "yaml")]
FileFormat::Yaml => serde_yaml::from_slice(&file_content)
.map_err(|e| error(Box::new(e))),
}
}
}
pub enum FileFormat {
#[cfg(feature = "toml")] Toml,
#[cfg(feature = "yaml")] Yaml,
}
impl FileFormat {
pub fn from_extension(ext: impl AsRef<OsStr>) -> Option<Self> {
match ext.as_ref().to_str()? {
#[cfg(feature = "toml")]
"toml" => Some(Self::Toml),
#[cfg(feature = "yaml")]
"yaml" | "yml" => Some(Self::Yaml),
_ => None,
}
}
}