Skip to content

Commit 597c3a1

Browse files
committed
Move config to its own file
1 parent c43959d commit 597c3a1

File tree

7 files changed

+171
-25
lines changed

7 files changed

+171
-25
lines changed

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
# The database URL
1+
# The database URL to use when compiling.
2+
# WARNING: This value is only used at compile time. To configure the runtime
3+
# behavior of database connection, see the below parameters.
24
DATABASE_URL = "postgres://postgres:password@localhost/supermarket_tracker"
35

46
DATABASE_USER = "postgres"
57
DATABASE_PASSWORD = "password"
8+
DATABASE_HOST = "localhost"
69
DATABASE_NAME = "supermarket_tracker"

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ tracing-subscriber = { version = "0.3.18", default-features = false, features =
3434
"fmt",
3535
"env-filter",
3636
] }
37+
secrecy = "0.8.0"
3738

3839
[lints.clippy]
3940
cargo = "deny"

src/config.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
use std::{
2+
collections::HashSet,
3+
env,
4+
ffi::OsStr,
5+
fmt::{Debug, Display},
6+
};
7+
8+
use error_stack::{Context, Result, ResultExt};
9+
use secrecy::{ExposeSecret, Secret};
10+
use sqlx::postgres::PgConnectOptions;
11+
12+
use crate::supermarket::{get_supermarket_type, Supermarket};
13+
14+
pub struct Config {
15+
pub(crate) application: ApplicationConfig,
16+
pub(crate) database: DatabaseConfig,
17+
}
18+
19+
#[allow(clippy::module_name_repetitions)]
20+
pub struct ApplicationConfig {
21+
/// The supermarket we are targeting to get price information for.
22+
pub(crate) supermarket: Supermarket,
23+
}
24+
25+
#[allow(clippy::module_name_repetitions)]
26+
pub struct DatabaseConfig {
27+
/// If we should insert information into the Postgres database, or if we
28+
/// are in read-only mode.
29+
pub(crate) should_insert: bool,
30+
/// The username to use when connect to the Postgres database.
31+
///
32+
/// Common values include `postgres`.
33+
username: String,
34+
/// The password to use when connecting to the Postgres database.
35+
password: Secret<String>,
36+
/// The host to use to connect to the database. Commonly `localhost`.
37+
host: String,
38+
/// The name of the database to use. Commonly `supermarket_tracker`.
39+
name: String,
40+
}
41+
42+
#[derive(Debug)]
43+
#[allow(clippy::module_name_repetitions)]
44+
pub enum ConfigError {
45+
/// The variable that was missing when trying to load it.
46+
LoadVariable { variable: String },
47+
InvalidOption {
48+
/// The invalid option the user passed.
49+
option: String,
50+
},
51+
}
52+
53+
impl Display for ConfigError {
54+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55+
match self {
56+
Self::LoadVariable { variable } => {
57+
write!(f, "Failed to load environment variable '{variable}'")
58+
}
59+
Self::InvalidOption { option } => write!(f, "Invalid option '{option}'"),
60+
}
61+
}
62+
}
63+
impl Context for ConfigError {}
64+
65+
impl Config {
66+
/// Reads configuration values from environment and arguments passed to the application.
67+
///
68+
/// # Errors
69+
/// - If unable to load a section of the config.
70+
pub fn read_from_env() -> Result<Self, ConfigError> {
71+
dotenvy::dotenv().ok();
72+
let args = env::args().skip(1).collect::<Vec<_>>();
73+
74+
Ok(Self {
75+
application: ApplicationConfig::read_from_env(&args)
76+
.attach_printable("When loading application configuration")?,
77+
database: DatabaseConfig::read_from_env(&args)
78+
.attach_printable("When loading database configuration")?,
79+
})
80+
}
81+
}
82+
83+
impl ApplicationConfig {
84+
/// Reads the application configuration from environment variables passed
85+
/// as the primary argument.
86+
///
87+
/// # Errors
88+
/// Errors if the user provides an invalid `--supermarket` option.
89+
fn read_from_env(args: &[String]) -> Result<Self, ConfigError> {
90+
let supermarket =
91+
get_supermarket_type(args).change_context(ConfigError::InvalidOption {
92+
option: "--supermarket".to_string(),
93+
})?;
94+
95+
Ok(Self { supermarket })
96+
}
97+
}
98+
99+
fn load_env<U>(variable: U) -> Result<String, ConfigError>
100+
where
101+
U: AsRef<OsStr> + Into<String> + Clone,
102+
{
103+
env::var(variable.clone()).change_context(ConfigError::LoadVariable {
104+
variable: variable.into(),
105+
})
106+
}
107+
108+
impl DatabaseConfig {
109+
/// Reads the database configuration from environment variables passed as
110+
/// the primary argument.
111+
fn read_from_env(args: &[String]) -> Result<Self, ConfigError> {
112+
let user = load_env("DATABASE_USER")?;
113+
let password = load_env("DATABASE_PASSWORD").map(Secret::new)?;
114+
let host = load_env("DATABASE_HOST")?;
115+
let name = load_env("DATABASE_NAME")?;
116+
117+
let hashed_args = args.iter().collect::<HashSet<_>>();
118+
let no_insert = hashed_args.contains(&"--no-insert".to_string());
119+
120+
Ok(Self {
121+
should_insert: !no_insert,
122+
123+
username: user,
124+
password,
125+
host,
126+
name,
127+
})
128+
}
129+
130+
/// Generates the Postgres connection string to use to connect to the
131+
/// database.
132+
///
133+
/// Because this string contains the database password, it is wrapped with
134+
/// the [`Secret`] type.
135+
pub fn connection_string(&self) -> PgConnectOptions {
136+
PgConnectOptions::new()
137+
.application_name("supermarket-tracker")
138+
.database(&self.name)
139+
.host(&self.host)
140+
.password(self.password.expose_secret())
141+
.username(&self.username)
142+
}
143+
}

src/error.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,8 @@ use error_stack::Context;
55
#[derive(Debug)]
66
#[allow(clippy::module_name_repetitions)]
77
pub enum ApplicationError {
8-
/// Invalid user option provided to the binary
9-
InvalidOption {
10-
/// The option which was invalid
11-
option: String,
12-
},
8+
/// Failed to load configuration data
9+
Config,
1310
/// Failed to connect to the database
1411
DatabaseConnectError,
1512
/// Failed to initialize the database with the initial tables
@@ -31,7 +28,7 @@ pub enum ApplicationError {
3128
impl fmt::Display for ApplicationError {
3229
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3330
match self {
34-
ApplicationError::InvalidOption { option } => write!(f, "Invalid option '{option}'"),
31+
ApplicationError::Config => write!(f, "Failed to load configuration"),
3532
ApplicationError::DatabaseConnectError => write!(f, "Failed to connect to database"),
3633
ApplicationError::DatabaseInitializeError => {
3734
write!(f, "Failed to initialize database")

src/initialize_database.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use sqlx::{Pool, Postgres};
55
///
66
/// # Errors
77
/// If unable to migrate the database.
8+
#[tracing::instrument(name = "initialize database", level = "debug", skip_all)]
89
pub async fn initialize_database(conn: &Pool<Postgres>) -> Result<(), sqlx::migrate::MigrateError> {
910
sqlx::migrate!().run(conn).await?;
1011

src/main.rs

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
use std::collections::HashSet;
2-
use std::env;
31
use std::time::Duration;
42

53
use dotenvy::dotenv;
64
use error_stack::{Result, ResultExt};
75
use sqlx::postgres::PgPoolOptions;
86

7+
use crate::config::Config;
98
use crate::error::ApplicationError;
109
use crate::initialize_database::initialize_database;
11-
use crate::supermarket::{get_supermarket_type, Supermarket};
10+
use crate::supermarket::Supermarket;
1211
use crate::telemetry::init_subscriber;
1312

1413
pub const CACHE_PATH: &str = "cache.json";
@@ -17,6 +16,7 @@ const PAGE_ITERATION_INTERVAL: Duration = Duration::from_millis(500);
1716
/// The amount of requests to perform in parallel.
1817
const CONCURRENT_REQUESTS: i64 = 12;
1918

19+
mod config;
2020
mod countdown;
2121
mod error;
2222
mod initialize_database;
@@ -32,32 +32,23 @@ async fn main() -> Result<(), ApplicationError> {
3232
// ignore any error attempting to load .env file
3333
dotenv().ok();
3434

35-
let args: Vec<_> = env::args().skip(1).collect();
36-
let hashed_args: HashSet<String> = args.iter().cloned().collect();
37-
38-
let no_insert = hashed_args.contains("--no-insert");
39-
40-
let supermarket_type =
41-
get_supermarket_type(&args).change_context(ApplicationError::InvalidOption {
42-
option: String::from("--supermarket"),
43-
})?;
35+
let config = Config::read_from_env().change_context(ApplicationError::Config)?;
4436

4537
// connect to database
46-
let database_url =
47-
env::var("DATABASE_URL").change_context(ApplicationError::DatabaseConnectError)?;
38+
tracing::debug!("Connecting to database");
4839
let connection = PgPoolOptions::new()
4940
.max_connections(5)
50-
.connect(&database_url)
41+
.connect_with(config.database.connection_string())
5142
.await
5243
.change_context(ApplicationError::DatabaseConnectError)?;
53-
5444
tracing::debug!("Connected to database");
45+
5546
initialize_database(&connection)
5647
.await
5748
.change_context(ApplicationError::DatabaseInitializeError)?;
5849

59-
match supermarket_type {
60-
Supermarket::Countdown => countdown::run(connection, no_insert).await,
50+
match config.application.supermarket {
51+
Supermarket::Countdown => countdown::run(connection, config.database.should_insert).await,
6152
Supermarket::NewWorld => new_world::run().await,
6253
}
6354
}

0 commit comments

Comments
 (0)