Skip to content

Commit

Permalink
Updates on command and add new slog based logger (#1)
Browse files Browse the repository at this point in the history
* command: add context to initConfig errors

When the `initConfig()` function fails, the previous behaviour was to
just print out the error. This made it hard to understand where the
problem was coming from since most of the errors are internal to viper
and don't provide a lot of context of when the error happened.

* command: unmarshal configuration after load

Without unmarshalling the configuration, callers need call it themselves
or rely on `viper.Get*()` calls to read configuration values. By
unmarshalling it internally we remove this requirement from users.

Use the `mapstructure.TextUnmarshallerHookFunc()` hook so configuration
fields with complex structs can provide customized unmarshalling by
implementing the `encoding.TextUnmarshaler` interface.

* log: add logger based on stdlib slog

---------

Signed-off-by: Shivansh Vij <shivanshvij@loopholelabs.io>
Co-authored-by: Shivansh Vij <shivanshvij@loopholelabs.io>
  • Loading branch information
lgfa29 and ShivanshVij authored Jul 5, 2024
1 parent 6dc0f96 commit a531957
Show file tree
Hide file tree
Showing 9 changed files with 593 additions and 14 deletions.
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ require (
github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d
github.com/lensesio/tableprinter v0.0.0-20201125135848-89e81fc956e7
github.com/mattn/go-isatty v0.0.19
github.com/mitchellh/mapstructure v1.5.0
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.9.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
Expand All @@ -25,8 +29,8 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
Expand Down
5 changes: 4 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
Expand Down Expand Up @@ -522,6 +523,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
32 changes: 20 additions & 12 deletions pkg/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/loopholelabs/cmdutils/pkg/config"
"github.com/loopholelabs/cmdutils/pkg/printer"
"github.com/loopholelabs/cmdutils/pkg/version"
"github.com/mitchellh/mapstructure"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
Expand Down Expand Up @@ -123,7 +124,13 @@ func (c *Command[T]) runCmd(ctx context.Context, format *printer.Format, debug *
c.command.PersistentFlags().StringVar(&cfgFile, "config", "", fmt.Sprintf("Config file (default is %s)", configPath))
c.command.PersistentFlags().StringVar(&logFile, "log", logPath, "Log File")

cobra.OnInitialize(c.initConfig)
cobra.OnInitialize(func() {
err := c.initConfig()
if err != nil {
fmt.Printf("Error: %s\n", err)
os.Exit(cmdutils.FatalErrExitCode)
}
})

c.command.SilenceUsage = true
c.command.SilenceErrors = true
Expand Down Expand Up @@ -169,15 +176,14 @@ func (c *Command[T]) runCmd(ctx context.Context, format *printer.Format, debug *
}

// initConfig reads in config file and ENV variables if set.
func (c *Command[T]) initConfig() {
func (c *Command[T]) initConfig() error {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
configDir, err := c.config.DefaultConfigDir()
if err != nil {
fmt.Println(err)
os.Exit(cmdutils.FatalErrExitCode)
return fmt.Errorf("failed to read default configuration directory: %w", err)
}

viper.AddConfigPath(configDir)
Expand All @@ -199,19 +205,21 @@ func (c *Command[T]) initConfig() {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// Only handle errors when it's something unrelated to the config file not
// existing.
fmt.Println(err)
os.Exit(cmdutils.FatalErrExitCode)
return fmt.Errorf("failed to read configuration: %w", err)
}
}
err := viper.Unmarshal(c.config, viper.DecodeHook(mapstructure.TextUnmarshallerHookFunc()))
if err != nil {
return fmt.Errorf("failed to unmarshal configuration: %w", err)
}

c.postInitCommands(c.command.Commands())

if c.config.GetConfigFile() != "" {
err := os.MkdirAll(filepath.Dir(c.config.GetConfigFile()), 0700)
if err != nil {
if !os.IsExist(err) {
fmt.Println(err)
os.Exit(cmdutils.FatalErrExitCode)
return fmt.Errorf("failed to create configuration directory: %w", err)
}
}
}
Expand All @@ -220,14 +228,14 @@ func (c *Command[T]) initConfig() {
err := os.MkdirAll(filepath.Dir(c.config.GetLogFile()), 0700)
if err != nil {
if !os.IsExist(err) {
fmt.Println(err)
os.Exit(cmdutils.FatalErrExitCode)
return fmt.Errorf("failed to create log directory: %w", err)
}
}
} else {
fmt.Println("No log file specified")
os.Exit(cmdutils.FatalErrExitCode)
return errors.New("No log file specified")
}

return nil
}

// Hacky fix for getting Cobra required flags and Viper playing well together.
Expand Down
74 changes: 74 additions & 0 deletions pkg/log/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
Copyright 2022 Loophole Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package log

import (
"fmt"
"strings"
)

// Format represents a log output format.
type Format int

const (
FormatText Format = iota
FormatJSON
)

// String returns the string representation of a log format.
//
// Implements the fmt.Stringer and pflag.Value interfaces.
func (f Format) String() string {
switch f {
case FormatText:
return "text"
case FormatJSON:
return "json"
default:
return "<undefined>"
}
}

// Type returns the string type of a log format.
//
// Implements the pflag.Value interface.
func (f Format) Type() string {
return "string"
}

// Set updates the value of a Format variable.
//
// Implements the pflag.Value interface.
func (f *Format) Set(s string) error {
switch strings.ToLower(s) {
case "text":
*f = FormatText
case "json":
*f = FormatJSON
default:
return fmt.Errorf("unknown log format %q", s)
}
return nil
}

// UnmarshalText decodes the text representation of a log format.
//
// Implements the encoding.TextUnmarshaler interface used by mapstructure and
// viper.
func (f *Format) UnmarshalText(text []byte) error {
return f.Set(string(text))
}
106 changes: 106 additions & 0 deletions pkg/log/format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
Copyright 2022 Loophole Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package log

import (
"testing"

"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_Format_String(t *testing.T) {
t.Parallel()

assert.Equal(t, "text", FormatText.String())
assert.Equal(t, "json", FormatJSON.String())
}

func Test_Format_Set(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
input string
expected Format
expectedErr string
}{
{
name: "text",
input: "text",
expected: FormatText,
},
{
name: "text all caps",
input: "TEXT",
expected: FormatText,
},
{
name: "text mixed caps",
input: "TeXt",
expected: FormatText,
},
{
name: "json",
input: "json",
expected: FormatJSON,
},
{
name: "json all caps",
input: "JSON",
expected: FormatJSON,
},
{
name: "json mixed caps",
input: "jSoN",
expected: FormatJSON,
},
{
name: "invalid input",
input: "not valid",
expectedErr: "unknown log format",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var f Format
err := f.Set(tc.input)

if tc.expectedErr != "" {
require.ErrorContains(t, err, tc.expectedErr)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, f)
}

})
}
}

func Test_Format_Unmarshal(t *testing.T) {
t.Parallel()

v := viper.New()
v.Set("format", "json")

c := struct{ Format Format }{}
v.Unmarshal(&c, viper.DecodeHook(mapstructure.TextUnmarshallerHookFunc()))
require.Equal(t, FormatJSON, c.Format)
}
65 changes: 65 additions & 0 deletions pkg/log/level.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
Copyright 2022 Loophole Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package log

import "log/slog"

// Level represents a log level threshold.
type Level slog.Level

const (
LevelDebug = Level(slog.LevelDebug)
LevelInfo = Level(slog.LevelInfo)
LevelWarn = Level(slog.LevelWarn)
LevelError = Level(slog.LevelError)
)

// String returns the string representation of a log level.
//
// Implements the fmt.Stringer and pflag.Value interfaces.
func (l Level) String() string {
return slog.Level(l).String()
}

// Type returns the string type of a log level.
//
// Implements the pflag.Value interface.
func (l Level) Type() string {
return "string"
}

// Level returns the slog.Level value of a Level.
//
// Implements the slog.Leveler interface.
func (l Level) Level() slog.Level {
return slog.Level(l)
}

// Set updates the value of a Level variable.
//
// Implements the pflag.Value interface.
func (l *Level) Set(s string) error {
return (*slog.Level)(l).UnmarshalText([]byte(s))
}

// UnmarshalText decodes the text representation of a log level.
//
// Implements the encoding.TextUnmarshaler interface used by mapstructure and
// viper.
func (l *Level) UnmarshalText(text []byte) error {
return l.Set(string(text))
}
Loading

0 comments on commit a531957

Please sign in to comment.