Skip to content

Commit

Permalink
chore: Cleanup public API, move parse to internal
Browse files Browse the repository at this point in the history
  • Loading branch information
custompro98 committed Jul 3, 2024
1 parent 5e0fc75 commit 21b9544
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 172 deletions.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
[![Package Version](https://img.shields.io/hexpm/v/glenv)](https://hex.pm/packages/glenv)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/glenv/)

Type-safe environment variables for Gleam.

Accessing environment variables doesn't give us the type of safety guarantees we'd like in a langauge like Gleam. glenv aims to guarantee that each environment variable is of the correct type AND is present.

```sh
gleam add glenv
```
```gleam
import gleam/decode
import glenv
pub type Env {
Env(hello: String, foo: Float, count: Int, is_on: Bool)
pub type MyEnv {
MyEnv(hello: String, foo: Float, count: Int, is_on: Bool)
}
pub fn main() {
Expand All @@ -20,14 +25,15 @@ pub fn main() {
#("COUNT", glenv.Int),
#("IS_ON", glenv.Bool),
]
let decoder =
decode.into({
use hello <- decode.parameter
use foo <- decode.parameter
use count <- decode.parameter
use is_on <- decode.parameter
Env(hello: hello, foo: foo, count: count, is_on: is_on)
MyEnv(hello: hello, foo: foo, count: count, is_on: is_on)
})
|> decode.field("HELLO", decode.string)
|> decode.field("FOO", decode.float)
Expand Down
2 changes: 1 addition & 1 deletion gleam.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name = "glenv"
version = "0.1.0"
version = "0.2.0"
licences = ["Apache-2.0"]
repository = { type = "github", user = "custompro98", repo = "glenv" }
description = "A library for type-safe environment variable access."
Expand Down
92 changes: 45 additions & 47 deletions src/glenv.gleam
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
//// glenv is a library for type-sfe environment variable access.
//// It is inspried by a Typescript pattern using zod validators
//// to parse and validate environment variables.
////

import decode
import envoy
import gleam/dict
import gleam/dynamic
import gleam/float
import gleam/int
import gleam/io
import gleam/list
import gleam/result
import gleam/string
import internal/parse

/// Type represents the type of an environment variable.
/// This dictates how the environment variable is parsed.
Expand All @@ -25,11 +23,15 @@ pub type Type {
pub type Definition =
#(String, Type)

type ResolvedType =
dynamic.Dynamic
/// EnvError represents an error that can occur when loading the environment.
pub type EnvError {
MissingKeyError(key: String)
InvalidEnvValue(key: String, expected: Type)
ValidationError(errors: List(dynamic.DecodeError))
}

type Resolution =
#(String, ResolvedType)
#(String, dynamic.Dynamic)

/// Load parses the environment variables and returns a Result containing the environment.
/// Takes a decoder from the gleam/decode library and a list of definitions.
Expand All @@ -47,6 +49,7 @@ type Resolution =
/// #("COUNT", glenv.Int),
/// #("IS_ON", glenv.Bool),
/// ]
///
/// let decoder =
/// decode.into({
/// use hello <- decode.parameter
Expand All @@ -62,6 +65,7 @@ type Resolution =
/// |> decode.field("IS_ON", decode.bool)
///
/// let assert Ok(env) = glenv.load(decoder, definitions)
///
/// env.hello // "world"
/// env.foo // 1.0
/// env.count // 1
Expand All @@ -70,8 +74,8 @@ type Resolution =
pub fn load(
decoder: decode.Decoder(env),
definitions: List(Definition),
) -> Result(env, Nil) {
let assert Ok(parsed_env) = parse(definitions)
) -> Result(env, EnvError) {
use parsed_env <- result.try(parse(definitions))

case
parsed_env
Expand All @@ -81,57 +85,51 @@ pub fn load(
{
Ok(env) -> Ok(env)
Error(err) -> {
io.debug(err)
Error(Nil)
Error(ValidationError(err))
}
}
}

pub fn parse(definitions: List(Definition)) -> Result(List(Resolution), Nil) {
fn parse(definitions: List(Definition)) -> Result(List(Resolution), EnvError) {
let env = envoy.all()

list.try_map(definitions, fn(definition) {
let normalized_definition = #(string.uppercase(definition.0), definition.1)
case dict.get(env, normalized_definition.0) {
Ok(value) -> do_parse(normalized_definition, value)
Error(_) -> Error(Nil)
Error(_) -> Error(MissingKeyError(normalized_definition.0))
}
})
}

fn do_parse(definition: Definition, value: String) -> Result(Resolution, Nil) {
case definition {
#(_, Bool) -> parse_bool(definition, value)
#(_, Float) -> parse_float(definition, value)
#(_, Int) -> parse_int(definition, value)
#(_, String) -> parse_string(definition, value)
}
}

fn parse_bool(definition: Definition, value: String) -> Result(Resolution, Nil) {
let resolution =
["true", "yes", "1"] |> list.contains(string.lowercase(value))

Ok(#(definition.0, dynamic.from(resolution)))
}

fn parse_float(definition: Definition, value: String) -> Result(Resolution, Nil) {
case float.parse(value) {
Ok(resolution) -> Ok(#(definition.0, dynamic.from(resolution)))
Error(_) -> Error(Nil)
}
}

fn parse_int(definition: Definition, value: String) -> Result(Resolution, Nil) {
case int.parse(value) {
Ok(resolution) -> Ok(#(definition.0, dynamic.from(resolution)))
Error(_) -> Error(Nil)
}
}

fn parse_string(
fn do_parse(
definition: Definition,
value: String,
) -> Result(Resolution, Nil) {
Ok(#(definition.0, dynamic.from(value)))
) -> Result(Resolution, EnvError) {
case definition {
#(key, Bool) -> {
case parse.bool(key, value) {
Ok(resolution) -> Ok(resolution)
Error(_) -> Error(InvalidEnvValue(key, Bool))
}
}
#(key, Float) -> {
case parse.float(key, value) {
Ok(resolution) -> Ok(resolution)
Error(_) -> Error(InvalidEnvValue(key, Float))
}
}
#(key, Int) -> {
case parse.int(key, value) {
Ok(resolution) -> Ok(resolution)
Error(_) -> Error(InvalidEnvValue(key, Int))
}
}
#(key, String) -> {
case parse.string(key, value) {
Ok(resolution) -> Ok(resolution)
Error(_) -> Error(InvalidEnvValue(key, String))
}
}
}
}
42 changes: 42 additions & 0 deletions src/internal/parse.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import gleam/dynamic
import gleam/float
import gleam/int
import gleam/list
import gleam/string

pub fn bool(
key: String,
value: String,
) -> Result(#(String, dynamic.Dynamic), Nil) {
let resolution =
["true", "yes", "1"] |> list.contains(string.lowercase(value))

Ok(#(key, dynamic.from(resolution)))
}

pub fn float(
key: String,
value: String,
) -> Result(#(String, dynamic.Dynamic), Nil) {
case float.parse(value) {
Ok(resolution) -> Ok(#(key, dynamic.from(resolution)))
Error(_) -> Error(Nil)
}
}

pub fn int(
key: String,
value: String,
) -> Result(#(String, dynamic.Dynamic), Nil) {
case int.parse(value) {
Ok(resolution) -> Ok(#(key, dynamic.from(resolution)))
Error(_) -> Error(Nil)
}
}

pub fn string(
key: String,
value: String,
) -> Result(#(String, dynamic.Dynamic), Nil) {
Ok(#(key, dynamic.from(value)))
}
96 changes: 96 additions & 0 deletions src/internal/parse_test.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import gleam/dynamic
import gleeunit
import gleeunit/should
import internal/parse

pub fn main() {
gleeunit.main()
}

const key = "GLENV_TEST_VAR"

pub fn parse_bool_test() {
parse.bool(key, "yes")
|> should.equal(Ok(#(key, dynamic.from(True))))

parse.bool(key, "YES")
|> should.equal(Ok(#(key, dynamic.from(True))))

parse.bool(key, "yEs")
|> should.equal(Ok(#(key, dynamic.from(True))))

parse.bool(key, "true")
|> should.equal(Ok(#(key, dynamic.from(True))))

parse.bool(key, "TRUE")
|> should.equal(Ok(#(key, dynamic.from(True))))

parse.bool(key, "trUE")
|> should.equal(Ok(#(key, dynamic.from(True))))

parse.bool(key, "1")
|> should.equal(Ok(#(key, dynamic.from(True))))

parse.bool(key, "no")
|> should.equal(Ok(#(key, dynamic.from(False))))

parse.bool(key, "false")
|> should.equal(Ok(#(key, dynamic.from(False))))

parse.bool(key, "0")
|> should.equal(Ok(#(key, dynamic.from(False))))

parse.bool(key, "anythingelse")
|> should.equal(Ok(#(key, dynamic.from(False))))
}

pub fn parse_float_test() {
parse.float(key, "1.0")
|> should.equal(Ok(#(key, dynamic.from(1.0))))

parse.float(key, "1.0001")
|> should.equal(Ok(#(key, dynamic.from(1.0001))))

parse.float(key, "1.000")
|> should.equal(Ok(#(key, dynamic.from(1.0))))

parse.float(key, "1")
|> should.be_error

parse.float(key, "abc")
|> should.be_error
}

pub fn parse_int_test() {
parse.int(key, "1")
|> should.equal(Ok(#(key, dynamic.from(1))))

parse.int(key, "2000000")
|> should.equal(Ok(#(key, dynamic.from(2_000_000))))

parse.int(key, "1.0")
|> should.be_error

parse.int(key, "abc")
|> should.be_error

parse.int(key, "2_000")
|> should.be_error
}

pub fn parse_string_test() {
parse.string(key, "1")
|> should.equal(Ok(#(key, dynamic.from("1"))))

parse.string(key, "2000000")
|> should.equal(Ok(#(key, dynamic.from("2000000"))))

parse.string(key, "1.0")
|> should.equal(Ok(#(key, dynamic.from("1.0"))))

parse.string(key, "abc")
|> should.equal(Ok(#(key, dynamic.from("abc"))))

parse.string(key, "2_000")
|> should.equal(Ok(#(key, dynamic.from("2_000"))))
}
Loading

0 comments on commit 21b9544

Please sign in to comment.