diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2f923c4..7f67134 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,6 +13,6 @@ jobs: - uses: erlef/setup-beam@v1 with: otp-version: "26.0.2" - gleam-version: "1.0.0" + gleam-version: "1.2.1" rebar3-version: "3" - run: gleam publish --yes diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 15bba36..d298938 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: - uses: erlef/setup-beam@v1 with: otp-version: "26.0.2" - gleam-version: "1.0.0" + gleam-version: "1.2.1" rebar3-version: "3" - run: gleam format --check src test - run: gleam deps download diff --git a/README.md b/README.md index bc4e625..5d367a1 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,9 @@ -- [dot_env](#dotenv) - - [Quick start](#quick-start) - - [Installation](#installation) - +- [Quick start](#quick-start) +- [Installation](#installation) + [![Package Version](https://img.shields.io/hexpm/v/dot_env)](https://hex.pm/packages/dotenv) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/dot_env/) @@ -14,24 +13,37 @@ dot_env is a port of the popular JavaScript [dotenv](https://github.com/motdotla > This package may support other formats in the future but for now, supports the popular .env format > -> You can find the Javascript test [here](https://github.com/aosasona/dot_js_test) +> You can find the Javascript "tests" [here](https://github.com/aosasona/dot_js_test) ## Quick start ```gleam -import dot_env +import dot_env as dot import dot_env/env import gleam/io pub fn main() { - dot_env.load_with_opts(dot_env.Opts(path: "path/to/.env", debug: False, capitalize: False)) - // or `dot_env.load()` to load the `.env` file in the root path + dot.new() + |> dot.set_path("path/to/.env") + |> dot.set_debug(False) + |> dot.load + + // or dot_env.load_with_opts(dot_env.Opts(path: "path/to/.env", debug: False, capitalize: False)) + // or `dot_env.load_default()` to load the `.env` file in the root path case env.get("MY_ENV_VAR") { Ok(value) -> io.println(value) Error(_) -> io.println("something went wrong") } + let app_name = env.get_or("APP_NAME", "my app name") + let port = env.get_int_or("PORT", 3000) + let enable_signup = env.get_bool_or("ENABLE_SIGNUP", True) + + io.debug(app_name) + io.debug(port) + io.debug(enable_signup) + Nil } ``` diff --git a/gleam.toml b/gleam.toml index 5dedfd4..4a90d56 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "dot_env" -version = "0.5.1" +version = "1.0.0" description = "Load and use environment variables from files" licences = ["Apache-2.0"] @@ -7,11 +7,11 @@ repository = { type = "github", user = "aosasona", repo = "dotenv" } internal_modules = ["dot_env/internal/*"] -gleam = ">= 0.34.0" +gleam = ">= 1.0.0" [dependencies] -simplifile = "~> 1.5" -gleam_stdlib = "~> 0.34 or ~> 1.0" +simplifile = ">= 2.0.0 and < 3.0.0" +gleam_stdlib = ">= 0.38.0 and < 1.0.0" [dev-dependencies] -gleeunit = "~> 1.0" +gleeunit = ">= 1.1.2 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 06a746a..06a8890 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,12 +2,13 @@ # You typically do not need to edit this file packages = [ - { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, - { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, - { name = "simplifile", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "EB9AA8E65E5C1E3E0FDCFC81BC363FD433CB122D7D062750FFDF24DE4AC40116" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "simplifile", version = "2.0.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "95219227A43FCFE62C6E494F413A1D56FF953B68FE420698612E3D89A1EFE029" }, ] [requirements] -gleam_stdlib = { version = "~> 0.34 or ~> 1.0" } -gleeunit = { version = "~> 1.0"} -simplifile = { version = "~> 1.5" } +gleam_stdlib = { version = ">= 0.38.0 and < 1.0.0"} +gleeunit = { version = ">= 1.1.2 and < 2.0.0" } +simplifile = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/src/dot_env.gleam b/src/dot_env.gleam index d634b04..9456c9c 100644 --- a/src/dot_env.gleam +++ b/src/dot_env.gleam @@ -1,9 +1,9 @@ +import dot_env/env +import dot_env/internal/parser import gleam/bool import gleam/io -import gleam/string import gleam/result.{try} -import dot_env/internal/parser -import dot_env/env +import gleam/string import simplifile pub type Opts { @@ -34,7 +34,66 @@ pub const default = DotEnv( ignore_missing_file: True, ) +/// Create a default DotEnv instance. This is designed to use used as the starting point for using any of the builder methods +pub fn new() -> DotEnv { + default +} + +/// Create a new DotEnv instance with the specified path +pub fn new_with_path(path: String) -> DotEnv { + DotEnv(..default, path: path) +} + +/// Set whether to print debug information in the current DotEnv instance +pub fn set_debug(instance: DotEnv, debug: Bool) -> DotEnv { + DotEnv(..instance, debug: debug) +} + +/// Set whether to capitalize all keys in the current DotEnv instance +pub fn set_capitalize(instance: DotEnv, capitalize: Bool) -> DotEnv { + DotEnv(..instance, capitalize: capitalize) +} + +/// Set whether to ignore missing file errors in the current DotEnv instance +pub fn set_ignore_missing_file( + instance: DotEnv, + ignore_missing_file: Bool, +) -> DotEnv { + DotEnv(..instance, ignore_missing_file: ignore_missing_file) +} + +/// Set the path to the .env file in the current DotEnv instance +pub fn set_path(instance: DotEnv, path: String) -> DotEnv { + DotEnv(..instance, path: path) +} + +/// Get the path to the .env file in the current DotEnv instance +pub fn path(instance: DotEnv) -> String { + instance.path +} + +/// Load the .env file using the current DotEnv instance and set the environment variables +/// +/// # Example +/// +/// ```gleam +/// import dot_env as dot /// +/// pub fn main() { +/// dot.new() +/// |> dot.set_path("src/.env") +/// |> dot.set_debug(False) +/// |> dot.load +/// } +pub fn load(dotenv: DotEnv) -> Nil { + load_with_opts(Opts( + path: dotenv.path, + debug: dotenv.debug, + capitalize: dotenv.capitalize, + ignore_missing_file: dotenv.ignore_missing_file, + )) +} + /// Load the .env file at the default path (.env) and set the environment variables /// /// Debug information will be printed to the console if something goes wrong and all keys will be capitalized @@ -45,14 +104,13 @@ pub const default = DotEnv( /// import dot_env /// /// pub fn main() { -/// dot_env.load() +/// dot_env.load_default() /// } /// ``` -pub fn load() { +pub fn load_default() -> Nil { load_with_opts(Default) } -/// /// Load the .env file at the specified path and set the environment variables /// /// Debug information and key capitalization can be customized @@ -73,9 +131,7 @@ pub fn load_with_opts(opts: Opts) { Default -> default } - let state = - dotenv - |> load_and_return_error + let state = dotenv |> load_and_return_error case state { Ok(_) -> Nil @@ -96,8 +152,6 @@ fn load_and_return_error(dotenv: DotEnv) -> Result(Nil, String) { dotenv |> recursively_set_environment_variables(kv_pairs) - - Ok(Nil) } fn handle_file_result( @@ -108,26 +162,25 @@ fn handle_file_result( res } -fn set_env(config: DotEnv, pair: #(String, String)) { - let #(key, value) = pair - +fn set_env(config: DotEnv, pair: #(String, String)) -> Result(Nil, String) { let key = { - use <- bool.guard(when: !config.capitalize, return: key) - string.uppercase(key) + use <- bool.guard(when: !config.capitalize, return: pair.0) + string.uppercase(pair.0) } - env.set(key, value) + key + |> env.set(pair.1) } fn recursively_set_environment_variables( config: DotEnv, kv_pairs: parser.KVPairs, -) { +) -> Result(Nil, String) { case kv_pairs { - [] -> Nil + [] -> Ok(Nil) [pair] -> set_env(config, pair) [pair, ..rest] -> { - set_env(config, pair) + use _ <- result.try(set_env(config, pair)) recursively_set_environment_variables(config, rest) } } @@ -135,7 +188,7 @@ fn recursively_set_environment_variables( fn read_file(dotenv: DotEnv) -> Result(String, String) { use is_file <- result.try( - simplifile.verify_is_file(dotenv.path) + simplifile.is_file(dotenv.path) |> result.map_error(with: fn(_) { "Failed to access file, ensure the file exists and is a readable file" }), @@ -149,11 +202,9 @@ fn read_file(dotenv: DotEnv) -> Result(String, String) { use contents <- result.try( simplifile.read(dotenv.path) |> result.map_error(with: fn(_) { - let msg = - "Unable to read file at `" - <> dotenv.path - <> "`, ensure the file exists and is readable" - msg + "Unable to read file at `" + <> dotenv.path + <> "`, ensure the file exists and is readable" }), ) diff --git a/src/dot_env/env.gleam b/src/dot_env/env.gleam index aff80b2..632f902 100644 --- a/src/dot_env/env.gleam +++ b/src/dot_env/env.gleam @@ -1,5 +1,6 @@ import gleam/int import gleam/result +import gleam/string /// Set an environment variable (supports both Erlang and JavaScript targets) /// @@ -7,12 +8,12 @@ import gleam/result /// ```gleam /// import dot_env/env /// -/// env.set("MY_ENV_VAR", "my value") +/// env.set("FOO", "my value") /// ``` /// @external(erlang, "dot_env_ffi", "set_env") @external(javascript, "../dot_env_ffi.mjs", "set_env") -pub fn set(key: String, value: String) -> Nil +pub fn set(key: String, value: String) -> Result(Nil, String) /// Get an environment variable (supports both Erlang and JavaScript targets) /// @@ -22,7 +23,7 @@ pub fn set(key: String, value: String) -> Nil /// import gleam/io /// import gleam/result /// -/// env.get("MY_ENV_VAR") +/// env.get("FOO") /// |> result.unwrap("NOT SET") /// |> io.println /// ``` @@ -30,36 +31,57 @@ pub fn set(key: String, value: String) -> Nil @external(javascript, "../dot_env_ffi.mjs", "get_env") pub fn get(key: String) -> Result(String, String) -/// Get an environment variable or return a default value +/// Get an environment variable or return a default value if it is not set pub fn get_or(key: String, default: String) -> String { + get(key) + |> result.unwrap(default) +} + +/// An alternative implementation of `get` that allows for chaining using `use` +pub fn get_then( + key: String, + f: fn(String) -> Result(t, String), +) -> Result(t, String) { case get(key) { - Ok(value) -> value - Error(_) -> default + Ok(value) -> f(value) + Error(err) -> Error(err) } } /// Get an environment variable as an integer pub fn get_int(key: String) -> Result(Int, String) { - case get(key) { - Ok(value) -> { - int.parse(value) - |> result.map_error(fn(_) { - "Failed to parse string to int, confirm the value you are trying to retrieve is a valid integer" - }) - } - Error(e) -> Error(e) - } + use raw_value <- get_then(key) + + int.parse(raw_value) + |> result.map_error(fn(_) { + "Failed to parse environment variable for `" <> key <> "` as integer" + }) +} + +/// Get an environment variable as an integer or return a default value if it is not set +pub fn get_int_or(key: String, default: Int) -> Int { + get_int(key) + |> result.unwrap(default) } /// Get an environment variable as a boolean pub fn get_bool(key: String) -> Result(Bool, String) { - case get(key) { - Ok(value) -> { - case value { - "True" | "true" | "1" -> Ok(True) - _ -> Ok(False) - } - } - Error(e) -> Error(e) + use raw_value <- get_then(key) + + case string.lowercase(raw_value) { + "true" | "1" -> Ok(True) + "false" | "0" -> Ok(True) + _ -> + Error( + "Invalid boolean value for environment variable `" + <> key + <> "`. Expected one of `true`, `false`, `1`, or `0`.", + ) } } + +/// Get an environment variable as a boolean or return a default value if it is not set +pub fn get_bool_or(key: String, default: Bool) -> Bool { + get_bool(key) + |> result.unwrap(default) +} diff --git a/src/dot_env/internal/parser.gleam b/src/dot_env/internal/parser.gleam index 6559343..391f204 100644 --- a/src/dot_env/internal/parser.gleam +++ b/src/dot_env/internal/parser.gleam @@ -1,6 +1,6 @@ -import gleam/string import gleam/list import gleam/result.{try} +import gleam/string pub type KVPair = #(String, String) @@ -12,9 +12,7 @@ type Chars = List(String) pub fn parse(text: String) -> Result(KVPairs, String) { - text - |> string.to_graphemes - |> parse_kvs([]) + text |> string.to_graphemes |> parse_kvs([]) } fn parse_kvs(text: Chars, acc: KVPairs) -> Result(KVPairs, String) { @@ -117,7 +115,5 @@ fn parse_comment(text: Chars, next: fn(Chars) -> a) -> a { } fn join(strings: List(String)) -> String { - strings - |> list.reverse - |> string.join("") + strings |> list.reverse |> string.join("") } diff --git a/src/dot_env_ffi.erl b/src/dot_env_ffi.erl index 1d5f58a..a88ce2e 100644 --- a/src/dot_env_ffi.erl +++ b/src/dot_env_ffi.erl @@ -12,4 +12,4 @@ get_env(Name) -> set_env(Name, Value) -> os:putenv(binary_to_list(Name), binary_to_list(Value)), - nil. + {ok, nil}. diff --git a/src/dot_env_ffi.mjs b/src/dot_env_ffi.mjs index 2ad4c00..ce0a773 100644 --- a/src/dot_env_ffi.mjs +++ b/src/dot_env_ffi.mjs @@ -1,27 +1,92 @@ -import { Ok as GleamOk, Error as GleamError } from "./gleam.mjs"; +import { Error as GleamError, Ok as GleamOk } from "./gleam.mjs"; const Nil = undefined; +/** + * @param {string} key + * @param {string} value + * @returns {GleamError | GleamOk} + */ export function set_env(key, value) { - if (!process.env) { - console.error("process.env is not available"); - return Nil; + // Ensure we can even run in this runtime + const runtime = get_runtime(); + if (runtime == "unknown") { + return new GleamError("unknown runtime"); } - process.env[key] = value; - return Nil; + // Ensure we have a non-empty key and a non-null/empty value + key = key?.trim(); + if (!key) return GleamError("key is required"); + if (value === undefined || value === null) { + // A blank string counts as a value in this case, useful for situations where the user explicitly wants to override an env var with an empty string + return new GleamError("value is required for key: " + key); + } + + if (runtime == "node" || runtime == "bun") { + process.env[key?.trim()] = value; + } else if (runtime == "deno") { + Deno.env.set(key?.trim(), value); + } else { + return new GleamError("unsupported runtime: " + runtime); + } + + return new GleamOk(Nil); } +/** + * @param {string} key + * @param {string} value + * @returns {GleamError | GleamOk} + */ export function get_env(key) { - if (!process.env) { - console.error("process.env is not available"); - return new GleamError("process.env is not available"); + const runtime = get_runtime(); + if (runtime == "unknown") { + return new GleamError("unknown runtime"); } - const value = process.env[key]; - if (!value) { - return new GleamError(`key \`${key}\` is not set`); + key = key?.trim(); + if (!key) return new GleamError("key is required"); + + let value = Nil; + + switch (runtime) { + case "node": + case "bun": + value = process?.env[key]; + break; + case "deno": + value = Deno.env.get(key); + break; + default: + return new GleamError("unsupported runtime: " + runtime); + } + + if (value == Nil || value === undefined) { + return new GleamError(Nil); } return new GleamOk(value); } + +/** + * @returns {"node" | "deno" | "bun" | "browser" | "unknown"} + */ +function get_runtime() { + if (typeof process !== "undefined") { + return "node"; + } + + if (typeof Deno !== "undefined") { + return "deno"; + } + + if (typeof Bun !== "undefined") { + return "bun"; + } + + if (typeof window !== "undefined") { + return "browser"; + } + + return "unknown"; +} diff --git a/test/dot_env_test.gleam b/test/dot_env_test.gleam index 002f6e7..e70e6d7 100644 --- a/test/dot_env_test.gleam +++ b/test/dot_env_test.gleam @@ -8,7 +8,13 @@ pub fn main() { } pub fn get_test() { - dot_env.load() + dot_env.load_default() + + env.get("DEFINITELY_NOT_A_REAL_KEY") + |> should.be_error + + env.get("PORT") + |> should.equal(Ok("9000")) env.get_or("UNDEFINED_KEY", "default") |> should.equal("default") @@ -18,10 +24,22 @@ pub fn get_test() { env.get_bool("APP_DEBUG") |> should.equal(Ok(True)) + + env.get_int_or("PORT", 3000) + |> should.equal(9000) + + env.get_bool_or("APP_DEBUG", False) + |> should.equal(True) + + env.get_bool("PORT") + |> should.be_error + + env.get_int("APP_DEBUG") + |> should.be_error } pub fn load_missing_env_file_test() { - env.set("PORT", "9000") + let assert Ok(Nil) = env.set("PORT", "9000") // This should not fail or crash dot_env.load_with_opts(Opts( @@ -36,7 +54,7 @@ pub fn load_missing_env_file_test() { } pub fn load_default_test() { - dot_env.load() + dot_env.load_default() env.get("PORT") |> should.equal(Ok("9000")) @@ -223,9 +241,8 @@ pub fn load_multiline_test() { env.get("MULTI_DOUBLE_QUOTED") |> should.equal(Ok("THIS\nIS\nA\nMULTILINE\nSTRING")) - // Currently failing i.e. not supported - // env.get("MULTI_SINGLE_QUOTED") - // |> should.equal(Ok("THIS\nIS\nA\nMULTILINE\nSTRING")) + env.get("MULTI_SINGLE_QUOTED") + |> should.equal(Ok("THIS\nIS\nA\nMULTILINE\nSTRING")) env.get("MULTI_BACKTICKED") |> should.equal(Ok("THIS\nIS\nA\n\"MULTILINE'S\"\nSTRING"))