feat: Implement .env file support (#618)

Co-authored-by: Jacob Marshall <me@jhqcat.com>
This commit is contained in:
Marco Pasqualetti
2025-03-09 12:00:12 +01:00
committed by GitHub
parent 8fcebae4a9
commit 67de777fd1
24 changed files with 361 additions and 14 deletions
+1 -2
View File
@@ -1,4 +1,5 @@
use crate::mode::Mode;
use crate::route::Route;
use glob::glob;
use glob::GlobError;
use http::Method;
@@ -16,8 +17,6 @@ use std::process::Stdio;
use tracing::error;
use tuono_internal::config::Config;
use crate::route::Route;
const IGNORE_EXTENSIONS: [&str; 3] = ["css", "scss", "sass"];
const IGNORE_FILES: [&str; 1] = ["__layout"];
+4 -3
View File
@@ -143,7 +143,7 @@ fn create_modules_declaration(routes: &HashMap<String, Route>) -> String {
}
pub fn bundle_axum_source(mode: Mode) -> io::Result<App> {
let base_path = std::env::current_dir().unwrap();
let base_path = std::env::current_dir()?;
let app = App::new();
let bundled_file = generate_axum_source(&app, mode);
@@ -261,10 +261,9 @@ mod tests {
let source_builder = App::new();
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
let prod_bundle = generate_axum_source(&source_builder, Mode::Prod);
assert!(dev_bundle.contains("const MODE: Mode = Mode::Dev;"));
assert!(prod_bundle.contains("const MODE: Mode = Mode::Prod;"));
}
@@ -273,6 +272,7 @@ mod tests {
let source_builder = App::new();
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
assert!(!dev_bundle.contains("use tuono_lib::axum::routing::get;"));
}
@@ -288,6 +288,7 @@ mod tests {
.insert(String::from("index.rs"), route);
let dev_bundle = generate_axum_source(&source_builder, Mode::Dev);
assert!(dev_bundle.contains("use tuono_lib::axum::routing::get;"));
}
+25 -4
View File
@@ -1,3 +1,4 @@
use std::fs;
use std::path::Path;
use std::sync::Arc;
use watchexec_supervisor::command::{Command, Program};
@@ -74,6 +75,17 @@ fn build_react_ssr_src() -> Job {
.0
}
fn ssr_reload_needed(path: &Path) -> bool {
let file_name_starts_with_env = path
.file_name()
.map(|f| f.to_string_lossy().starts_with(".env"))
.unwrap_or(false);
let file_path = path.to_string_lossy();
file_name_starts_with_env || file_path.ends_with("sx") || file_path.ends_with("mdx")
}
#[tokio::main]
pub async fn watch() -> Result<()> {
let term = Term::stdout();
@@ -86,6 +98,13 @@ pub async fn watch() -> Result<()> {
let build_ssr_bundle = build_react_ssr_src();
let env_files = fs::read_dir("./")
.expect("Error reading env files from current directory")
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_name().to_string_lossy().starts_with(".env"))
.map(|entry| entry.path().to_string_lossy().into_owned())
.collect::<Vec<String>>();
build_ssr_bundle.start().await;
build_rust_src.start().await;
@@ -110,8 +129,7 @@ pub async fn watch() -> Result<()> {
should_reload_rust_server = true
}
// Either tsx, jsx and mdx
if file_path.ends_with("sx") || file_path.ends_with("mdx") {
if ssr_reload_needed(path.0) {
should_reload_ssr_bundle = true
}
}
@@ -137,8 +155,11 @@ pub async fn watch() -> Result<()> {
action
})?;
// watch the current directory
wx.config.pathset(["./src"]);
// watch the current directory and all types of .env file
let mut paths_to_watch = vec!["./src".to_string()];
paths_to_watch.extend(env_files);
wx.config.pathset(paths_to_watch);
let _ = wx.main().await.into_diagnostic()?;
Ok(())
+268
View File
@@ -0,0 +1,268 @@
use crate::mode::Mode;
use std::collections::HashSet;
use std::env;
use std::fs;
pub fn load_env_vars(mode: Mode) {
let mut env_files = vec![String::from(".env"), String::from(".env.local")];
let mode_name = match mode {
Mode::Dev => "development",
Mode::Prod => "production",
};
env_files.push(format!(".env.{}", mode_name));
env_files.push(String::from(".env.local"));
env_files.push(format!(".env.{}.local", mode_name));
let system_env_names: HashSet<String> = env::vars().map(|(k, _)| k).collect();
for env_file in env_files {
if let Ok(contents) = fs::read_to_string(env_file) {
for line in contents.lines() {
if let Some((key, mut value)) = line.split_once('=') {
if value.starts_with('"') && value.ends_with('"') {
value = &value[1..value.len() - 1];
}
let key = key.trim().to_string();
let value = value.trim().to_string();
if system_env_names.contains(&key) {
continue; // Skip if key exists in system env
}
env::set_var(key, value);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mode::Mode;
use serial_test::serial;
use std::collections::HashMap;
use std::env;
use std::fs;
struct MockEnv {
files: Vec<String>,
vars: HashMap<String, String>,
}
impl MockEnv {
fn new() -> Self {
Self {
files: Vec::new(),
vars: HashMap::new(),
}
}
fn add_system_var(&mut self, k: &str, v: &str) {
self.vars.insert(k.to_string(), v.to_string());
env::set_var(k, v);
}
pub fn setup_env_file(&mut self, file_name: &str, contents: &str) {
self.files.push(file_name.to_string());
fs::write(file_name, contents).expect("Failed to write test .env file");
}
pub fn capture_keys(&mut self, keys: &[&str]) {
for key in keys {
if let Ok(val) = env::var(key) {
self.vars.insert(key.to_string(), val);
}
}
}
}
impl Drop for MockEnv {
fn drop(&mut self) {
for file in self.files.iter() {
let _ = fs::remove_file(file.as_str());
}
for key in self.vars.keys() {
env::remove_var(key);
}
}
}
#[test]
#[serial]
fn test_system_env_var_precedence() {
let mut mock_env = MockEnv::new();
mock_env.add_system_var("TEST_KEY", "system_value");
mock_env.setup_env_file(".env", "TEST_KEY=file_value");
load_env_vars(Mode::Dev);
mock_env.capture_keys(&["TEST_KEY"]);
assert_eq!(env::var("TEST_KEY").unwrap(), "system_value");
}
#[test]
#[serial]
fn test_mode_specific_env_var_precedence_dev() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
load_env_vars(Mode::Dev);
mock_env.capture_keys(&["TEST_KEY"]);
assert_eq!(env::var("TEST_KEY").unwrap(), "development_value");
}
#[test]
#[serial]
fn test_mode_specific_env_var_precedence_prod() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
load_env_vars(Mode::Prod);
mock_env.capture_keys(&["TEST_KEY"]);
assert_eq!(env::var("TEST_KEY").unwrap(), "production_value");
}
#[test]
#[serial]
fn test_local_env_var_precedence() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
mock_env.setup_env_file(".env.local", "TEST_KEY=local_value");
load_env_vars(Mode::Dev);
mock_env.capture_keys(&["TEST_KEY"]);
assert_eq!(env::var("TEST_KEY").unwrap(), "local_value");
}
#[test]
#[serial]
fn test_mode_local_env_var_precedence_dev() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
mock_env.setup_env_file(".env.development.local", "TEST_KEY=local_dev_value");
load_env_vars(Mode::Dev);
mock_env.capture_keys(&["TEST_KEY"]);
assert_eq!(env::var("TEST_KEY").unwrap(), "local_dev_value");
}
#[test]
#[serial]
fn test_mode_local_env_var_precedence_prod() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", "TEST_KEY=base_value");
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
mock_env.setup_env_file(".env.production.local", "TEST_KEY=local_prod_value");
load_env_vars(Mode::Prod);
mock_env.capture_keys(&["TEST_KEY"]);
assert_eq!(env::var("TEST_KEY").unwrap(), "local_prod_value");
}
#[test]
#[serial]
fn test_ignores_files_from_other_mode() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env.development", "TEST_KEY=development_value");
mock_env.setup_env_file(".env.production", "TEST_KEY=production_value");
load_env_vars(Mode::Prod);
mock_env.capture_keys(&["TEST_KEY"]);
assert_eq!(env::var("TEST_KEY").unwrap(), "production_value");
}
#[test]
#[serial]
fn test_empty_env_file() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", "");
load_env_vars(Mode::Dev);
assert!(env::var("NON_EXISTENT_KEY").is_err());
}
#[test]
#[serial]
fn test_malformed_env_entries() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", "INVALID_LINE\nMISSING_EQUALS_SIGN");
load_env_vars(Mode::Dev);
mock_env.capture_keys(&["INVALID_LINE", "MISSING_EQUALS_SIGN"]);
assert!(env::var("INVALID_LINE").is_err());
assert!(env::var("MISSING_EQUALS_SIGN").is_err());
}
#[test]
#[serial]
fn test_quoted_values_parsing() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", r#"TEST_KEY="quoted_value""#);
load_env_vars(Mode::Dev);
mock_env.capture_keys(&["TEST_KEY"]);
assert_eq!(env::var("TEST_KEY").unwrap(), "quoted_value");
}
#[test]
#[serial]
fn test_non_existent_env_file() {
let mut mock_env = MockEnv::new();
load_env_vars(Mode::Dev);
mock_env.capture_keys(&["NON_EXISTENT_KEY"]);
assert!(env::var("NON_EXISTENT_KEY").is_err());
}
#[test]
#[serial]
fn test_multiple_env_vars() {
let mut mock_env = MockEnv::new();
mock_env.setup_env_file(".env", "KEY1=value1\nKEY2=value2");
load_env_vars(Mode::Dev);
mock_env.capture_keys(&["KEY1", "KEY2"]);
assert_eq!(env::var("KEY1").unwrap(), "value1");
assert_eq!(env::var("KEY2").unwrap(), "value2");
}
}
+1
View File
@@ -5,6 +5,7 @@
mod catch_all;
mod config;
mod env;
mod logger;
mod manifest;
mod mode;
+3
View File
@@ -7,6 +7,7 @@ use ssr_rs::Ssr;
use tower_http::services::ServeDir;
use tuono_internal::config::Config;
use crate::env::load_env_vars;
use crate::{
catch_all::catch_all, logger::LoggerLayer, vite_reverse_proxy::vite_reverse_proxy,
vite_websocket_proxy::vite_websocket_proxy,
@@ -62,6 +63,8 @@ impl Server {
let server_address = format!("{}:{}", config.server.host, config.server.port);
load_env_vars(mode);
Server {
router,
mode,
+22
View File
@@ -147,3 +147,25 @@ async fn it_reads_the_path_parameter() {
assert!(response.status().is_success());
assert_eq!(response.text().await.unwrap(), "url_parameter");
}
#[tokio::test]
#[serial]
async fn it_reads_an_env_var() {
let app = MockTuonoServer::spawn().await;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let server_url = format!("http://{}:{}", &app.address, &app.port);
let response = client
.get(format!("{server_url}/env"))
.send()
.await
.expect("Failed to execute request.");
assert!(response.status().is_success());
assert_eq!(response.text().await.unwrap(), "foobar");
}
+7
View File
@@ -0,0 +1,7 @@
use std::env;
use tuono_lib::Request;
#[tuono_lib::api(GET)]
pub async fn test_env(_req: Request) -> String {
env::var("MY_TEST_KEY").unwrap_or("error".parse().unwrap())
}
+5 -1
View File
@@ -9,6 +9,7 @@ use tuono_lib::{axum::Router, tuono_internal_init_v8_platform, Mode, Server};
use crate::utils::catch_all::get_tuono_internal_api as catch_all;
use crate::utils::dynamic_parameter::get_tuono_internal_api as dynamic_parameter;
use crate::utils::env::get_tuono_internal_api as test_env;
use crate::utils::health_check::get_tuono_internal_api as health_check;
use crate::utils::route as html_route;
use crate::utils::route::tuono_internal_api as route_api;
@@ -74,13 +75,16 @@ impl MockTuonoServer {
r#"{"client-main.tsx": { "file": "assets/index.js", "name": "index", "src": "index.tsx", "isEntry": true,"dynamicImports": [],"css": []}}"#,
);
add_file_with_content("./.env", r#"MY_TEST_KEY="foobar""#);
let router = Router::new()
.route("/", get(html_route::tuono_internal_route))
.route("/tuono/data", get(html_route::tuono_internal_api))
.route("/health_check", get(health_check))
.route("/route-api", get(route_api))
.route("/catch_all/{*catch_all}", get(catch_all))
.route("/dynamic/{parameter}", get(dynamic_parameter));
.route("/dynamic/{parameter}", get(dynamic_parameter))
.route("/env", get(test_env));
let server = Server::init(router, Mode::Prod).await;
+1
View File
@@ -1,5 +1,6 @@
pub mod catch_all;
pub mod dynamic_parameter;
pub mod env;
pub mod health_check;
pub mod mock_server;
pub mod route;