Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,26 @@ You can also use cenv as a single-util package. See [the package source](cenv.go
go get github.com/echo-webkom/cenv
```

```go
func main() {
err := cenv.Load()
if err != nil {
log.Fatal(err)
}

...
}
```

## Use

Add one or more of the following `@tags` above a field:

- `public`: Marks the field as public. The value will be included in the schema. This is for required static values.
- `required`: Marks the field as required. The field has to be present, and have a non-empty value.
- `length [number]`: Requires a specified length for the fields value.
- `default [value]`: Set a default value. Running `cenv fix` will automatically fill this in if the field is empty.
- `enum [value1] | [value2] | ...`: Require that the field value is one of the given enum values, separated by `|`.
- `format [format]`: Requires a specified format for the value. Uses [gokenizer patterns](https://github.com/jesperkha/gokenizer).

```py
Expand All @@ -57,23 +70,27 @@ OTHER_KEY=abcdefgh
# @length 4
# @format {number}
PIN_CODE=1234

# @enum user | guest | admin
# @default user
ROLE=user
```

Create a schema file from your env:
Create a schema file from your .env:

```sh
# Creates a cenv.schema.json file
cenv update
```

Check you .env after fetching the latest changes
Check your .env after fetching the latest changes

```sh
# Compares your env with the existing cenv.schema.json
cenv check
```

You can fix and outdated .env with the fix command. Note that this will overwrite the existing .env, but use the values that were there before, like secret API keys etc. This may not work correctly if the .env is formatted incorrectly.
The `fix` command creates a .env file based on the schema, or fills in missing fields in an existing one. Any values already in your .env, like API keys will be kept, while any missing values will be added if a default or public one is provided.

```sh
cenv fix
Expand All @@ -92,3 +109,4 @@ If you want to overwrite the `Version` variable in `main.go` you have add the fo
```sh
go build -o bin/cenv -ldflags "-X 'github.com/echo-webkom/cenv/cmd.Version=<your-version>'" app/main.go
```

34 changes: 24 additions & 10 deletions cenv/cenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ type CenvFile struct {
}

type CenvField struct {
Required bool `json:"required,omitempty"` // This field has to be present and have a non-empty value
Public bool `json:"public,omitempty"` // This has a publicly known but required value, stored in the schema
LengthRequired bool `json:"lengthRequired,omitempty"` // The length of this field is specified in the schema
Length uint32 `json:"length,omitempty"` // The required length, if LengthRequired is true
Format string `json:"format,omitempty"` // Require a specified format for the value
Key string `json:"key,omitempty"` // Field name
Value string `json:"value,omitempty"` // Public only
Required bool `json:"required,omitempty"` // This field has to be present and have a non-empty value
Public bool `json:"public,omitempty"` // This has a publicly known but required value, stored in the schema
LengthRequired bool `json:"lengthRequired,omitempty"` // The length of this field is specified in the schema
Length uint32 `json:"length,omitempty"` // The required length, if LengthRequired is true
Format string `json:"format,omitempty"` // Require a specified format for the value
Key string `json:"key,omitempty"` // Field name
Value string `json:"value,omitempty"` // Public only
Enum []string `json:"enum,omitempty"` // List of legal enum values
Default string `json:"default,omitempty"` // Default value if none is given

value string
}
Expand Down Expand Up @@ -62,7 +64,6 @@ func Fix(envPath, schemaPath string) error {
}

env, _ := ReadEnv(envPath)

file := strings.Builder{}

for _, f := range schema.Fields {
Expand All @@ -80,11 +81,24 @@ func Fix(envPath, schemaPath string) error {
if f.Format != "" {
s += fmt.Sprintf("# @format %s\n", f.Format)
}
if len(f.Enum) != 0 {
s += fmt.Sprintf("# @enum %s\n", strings.Join(f.Enum, " | "))
}
if f.Default != "" {
s += fmt.Sprintf("# @default %s\n", f.Default)
}

// Public > Private > Default

if v, ok := env[f.Key]; ok && !f.Public {
if f.Public {
// The value is public (and static)
s += fmt.Sprintf("%s=%s\n", f.Key, f.Value)
} else if v, ok := env[f.Key]; ok && v.value != "" {
// There is already a value here
s += fmt.Sprintf("%s=%s\n", f.Key, v.value)
} else {
s += fmt.Sprintf("%s=%s\n", f.Key, f.Value)
// The value is either empty or set to default (if any)
s += fmt.Sprintf("%s=%s\n", f.Key, f.Default)
}

fmt.Printf("cenv: added '%s'\n", f.Key)
Expand Down
157 changes: 85 additions & 72 deletions cenv/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,121 +3,128 @@ package cenv
import (
"fmt"
"os"
"slices"
"strconv"
"strings"

"github.com/jesperkha/gokenizer"
)

const prefix string = "@"
const PREFIX byte = '@'

// ReadEnv parses the env file and tags. It also checks that
// tags are defined correctly and will return an error otherwise.
func ReadEnv(filepath string) (env map[string]CenvField, err error) {
env = make(map[string]CenvField)

file, err := os.ReadFile(filepath)
if err != nil {
return env, err
}

formats := []string{
// Dont change order
fmt.Sprintf("#{ws}%s{word} {number}", prefix),
fmt.Sprintf("#{ws}%s{word} {text}", prefix),
fmt.Sprintf("#{ws}%s{word}", prefix),
}

tokr := gokenizer.New()

tokr.ClassFunc("env_var", func(b byte) bool {
return strings.Contains("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_", string(b))
})

tokr.Class("key", "{env_var}")
tokr.Class("value", "{string}", "{text}")
tokr.Class("keyValue", "{ws}{key}{ws}={ws}{value}", "{ws}{key}{ws}={ws}")
tokr.Class("comment", "#{any}")
tokr.Class("tag", formats...)
tokr.Class("expression", "{tag}", "{comment}", "{keyValue}")

env = make(map[string]CenvField)
fld := CenvField{}
errs := longError{}

tokr.Pattern("{expression}", func(t gokenizer.Token) error {
tag := t.Get("expression").Get("tag")
if tag.Length != 0 {
name := tag.Get("word").Lexeme
num := tag.Get("number").Lexeme
format := tag.Get("text").Lexeme
for idx, line := range strings.Split(string(file), "\n") {
line := strings.TrimSpace(line)
if line == "" {
continue
}

usesNum := false // Tag uses number value
n := uint64(0)
linenr := idx + 1

if num != "" {
n, err = strconv.ParseUint(num, 10, 32)
if err != nil {
return fmt.Errorf("expected unsigned int, got '%s'", num)
}
// Parse tag
// Tags are comment with any amount of whitespace followed by
// the prefix '@' then the tag name, and finally the tag value if any.
if strings.HasPrefix(line, "#") {
tagLine := strings.TrimSpace(line[1:])
if len(tagLine) == 0 || tagLine[0] != PREFIX {
continue
}

switch name {
case "required":
fld.Required = true
split := strings.SplitN(tagLine, " ", 2)
tag := strings.TrimPrefix(split[0], string(PREFIX))
value := ""
if len(split) > 1 {
value = split[1]
}

case "public":
switch tag {
case "required": // @required
fld.Required = true
case "public": // @public
fld.Public = true
case "format": // @format <any>
fld.Format = value
case "default": // @default <any>
fld.Default = value

case "enum": // @enum <name> | <name> | ...
values := strings.Split(value, "|")
for _, v := range values {
v = strings.TrimSpace(v)
if v != "" {
fld.Enum = append(fld.Enum, v)
}
}

case "length":
fld.LengthRequired = true
case "length": // @length <number>
n, err := strconv.Atoi(value)
if err != nil {
errs.Add(fmt.Errorf("invalid number literal '%s', line %d", value, linenr))
}
fld.Length = uint32(n)
usesNum = true

case "format":
fld.Format = format
fld.LengthRequired = true

default:
return fmt.Errorf("unknown tag '%s'", name)
errs.Add(fmt.Errorf("unknown tag '%s', line %d", tag, linenr))
}

if !usesNum && num != "" {
return fmt.Errorf("expected newline after tag name, got '%s'", num)
}
continue
}

keyval := t.Get("expression").Get("keyValue")
// Parse key-value pair
// For the sake of consistency across code/config/.env we require that
// keys are alphanumeric (in addition to $ and _).
split := strings.SplitN(line, "=", 2)
if len(split) == 0 {
errs.Add(fmt.Errorf("syntax error, line %d", linenr))
}

if keyval.Length != 0 {
fld.Key = keyval.Get("key").Lexeme
fld.value = keyval.Get("value").Lexeme
key := strings.TrimSpace(split[0])
value := ""
if len(split) > 1 {
value = strings.Trim(split[1], "\" ")
}

// Strip string quotes
if fld.value != "" && fld.value[0] == '"' && fld.value[len(fld.value)-1] == '"' {
fld.value = fld.value[1 : len(fld.value)-1]
}
if !isAlnum(key) {
errs.Add(fmt.Errorf("key must be alphanumeric, line %d", linenr))
}

if fld.Public {
fld.Value = fld.value
}
fld.Key = key
fld.value = value

env[fld.Key] = fld
fld = CenvField{}
if fld.Public {
fld.Value = value
}

return nil
})
env[fld.Key] = fld
fld = CenvField{} // Reset
}

errs := longError{}
return env, validateEnv(env, errs)
}

// Run for each line
for _, line := range strings.Split(string(file), "\n") {
if err = tokr.Run(line); err != nil {
errs.Add(err)
func isAlnum(s string) bool {
for _, c := range s {
if strings.IndexRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$", c) == -1 {
return false
}
}

return env, validateEnv(env, errs)
return true
}

// Validate the env file in-place; check that all tags actually match the
// values before comparing with schema.
func validateEnv(fs map[string]CenvField, longErr longError) error {
for _, f := range fs {
if err := validateEnvField(f); err != nil {
Expand All @@ -135,6 +142,12 @@ func validateEnvField(field CenvField) error {
return fmt.Errorf("'%s': value did not match the format '%s'", field.Key, field.Format)
}
}
if len(field.Enum) != 0 {
if !slices.Contains(field.Enum, field.value) {
list := strings.Join(field.Enum, ", ")
return fmt.Errorf("'%s': field must have one of the specified enum values: [%s]", field.Key, list)
}
}
if field.Required && len(field.value) == 0 {
return fmt.Errorf("'%s': required field must have a value", field.Key)
}
Expand Down
10 changes: 10 additions & 0 deletions cenv/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
)

func ReadSchema(filepath string) (schema CenvFile, err error) {
Expand Down Expand Up @@ -53,10 +54,19 @@ func assertBoolEqual(key, name string, schema, env bool) error {
return nil
}

// Check if tags and fields in env match the ones in schema.
func compareFields(sf CenvField, ef CenvField) (errs longError) {
if err := assertBoolEqual(sf.Key, "required", sf.Required, ef.Required); err != nil {
errs.Add(err)
}
if err := assertBoolEqual(sf.Key, "a default value", sf.Default != "", ef.Default != ""); err != nil {
errs.Add(err)
} else if sf.Default != ef.Default {
errs.Add(fmt.Errorf("'%s' has default '%s' in schema, but '%s' in .env", sf.Key, sf.Default, ef.Default))
}
if schemaEnum, envEnum := strings.Join(sf.Enum, " | "), strings.Join(ef.Enum, " | "); schemaEnum != envEnum {
errs.Add(fmt.Errorf("'%s's enum tag does not match schema", sf.Key))
}
if err := assertBoolEqual(sf.Key, "public", sf.Public, ef.Public); err != nil {
errs.Add(err)
}
Expand Down