diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..8e401ce --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + tests: + runs-on: ubuntu-20.04 + strategy: + matrix: + go: [ '1.16', '1.17' ] + name: Run tests on Go ${{ matrix.go }} + steps: + - uses: actions/checkout@v2 + - name: Setup go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go }} + - run: go test -v ./... diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..782940a --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +* @MartialGeek + diff --git a/README.md b/README.md index b283a22..b17f59a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,173 @@ -# tortilla -A golang package to wrap our errors as easily as your tortillas. +![Tortilla logo](./tortilla.png) +*(Author: [Micheile](https://unsplash.com/@micheile), original: https://unsplash.com/photos/1zyj8nOdwPs)* + +# Tortilla + +A Go package to wrap your errors as easily as your tortillas. *Bon appétit!* + +# Purpose + +Errors in Go is a very divisive topic. There are pros and cons, and for me the thing I really miss +compared to "traditional" exceptions mechanism is the ability to keep an history of the errors you +handled in the lifetime of your program. Although it is possible to wrap or embed errors to keep an +information of what happened in a lower level in vanilla Go with `fmt.Errorf`, I found really +hard to set up a strong but simple, standardized but not too "cumbersome" error handling. + +That's why a thought about creating a package to make my error handling easier, and because Go wraps +errors in other errors, (and because I love Tex-Mex cuisine as well), I liked the idea of wrapping all +that things with big Tortillas! + +# Examples + +OK, enough talk. Let's take some examples to illustrate what you can do with a Tortilla! +These examples can be found in [the examples directory of the project](./examples). + +## Wrap and pretty print + +In this example we simulate a call to a function that fetches data from a DB and stores it in cache. +The cache returns an error, a Tortilla is created from it and we wrap with our business error +`errDataFetching`. Then we can use `errors.Is` to make a decision regarding the error. Please note that +only the last error used to wrap a Tortilla can be matched by `errors.Is` or `errors.As`. + +```go +package main + +import ( + "errors" + "log" + + "github.com/MartialGeek/tortilla" +) + +var ( + errDataFetching = errors.New("unable to fetch data") +) + +func main() { + err := fetchSomeData() + if err != nil { + if errors.Is(err, errDataFetching) { + log.Fatal(tortilla.New(err).RollOut().PrettyPrint()) + } + + log.Println("unknown error:", err) + } +} + +func fetchSomeData() error { + cacheErr := cache() + if cacheErr != nil { + return tortilla.New(cacheErr).Wrap(errDataFetching) + } + + return nil +} + +func cache() error { + return errors.New("some cache error") +} +``` + +Here we simply re-create our Tortilla with `tortilla.New` to call `RollOut().PrettyPrint()`. This will +generate a string with a visual representation of the encountered errors in the reverse order of creation: + +``` +unable to fetch data: +some cache error: +``` + +## Adding errors and hierarchy + +Now what if you want to add some errors without using them to wrap your Tortilla? + +```go +package main + +import ( + "errors" + "log" + + "github.com/MartialGeek/tortilla" +) + +var ( + errObfuscate = errors.New("unable to obsufcate data") + errEncode = errors.New("encoding failed") +) + +func main() { + err := obsufcate() + if err != nil { + if errors.Is(err, errObfuscate) { + log.Fatal(tortilla.New(err).RollOut().PrettyPrint()) + } + } +} + +func obsufcate() error { + err := encode() + if err != nil { + err = tortilla. + New(errObfuscate). + Add(err). + Add(errors.New("some context of what happened")) + } + + return err +} + +func encode() error { + err := encrypt() + if err != nil { + err = tortilla.New(errEncode).Add(err) + } + + return err +} + +func encrypt() error { + return errors.New("encryption error") +} +``` + +* First, in `encode` function we create a Tortilla `errEncode` and we add the encryption error into it. +* Then in `obsufcate` function a new Tortilla `errObfuscate` wraps the previous Tortilla and add another +error to explain what happaned. +* Finally in `main` the error matched `errObfuscate` and the Tortilla is rolled-out to be displayed in a +human-readable string: + +``` +unable to obsufcate data: +....some context of what happened +....encoding failed: encryption error. +``` + +Here we see the hierachy of the errors: the first line is the last error used to wrap the Tortilla. +The second and third are shifted to identify them as "children" of the first one. + +As you can see, the third line contains the encoding error followed by the encryption error. It's because +we added the error returned by `encode`, which was flattened. + +If you want to keep the exact hierachy, you must create a Torilla from the returned error, and then wrapping +with your own error: + +```go +func obsufcate() error { + err := encode() + if err != nil { + err = tortilla. + New(err). + Wrap(errObfuscate). + Add(errors.New("some context of what happened")) + } + + return err +} +``` + +``` +unable to obsufcate data: +....some context of what happened +encoding failed: +....encryption error +``` diff --git a/examples/add_errors/main.go b/examples/add_errors/main.go new file mode 100644 index 0000000..ccf085d --- /dev/null +++ b/examples/add_errors/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "errors" + "log" + + "github.com/MartialGeek/tortilla" +) + +var ( + errObfuscate = errors.New("unable to obsufcate data") + errEncode = errors.New("encoding failed") +) + +func main() { + err := obsufcate() + if err != nil { + if errors.Is(err, errObfuscate) { + log.Fatal(tortilla.New(err).RollOut().PrettyPrint()) + } + } +} + +func obsufcate() error { + err := encode() + if err != nil { + err = tortilla. + New(errObfuscate). + Add(err). + Add(errors.New("some context of what happened")) + } + + return err +} + +func encode() error { + err := encrypt() + if err != nil { + err = tortilla.New(errEncode).Add(err) + } + + return err +} + +func encrypt() error { + return errors.New("encryption error") +} diff --git a/examples/keep_hierachy/main.go b/examples/keep_hierachy/main.go new file mode 100644 index 0000000..fe54c66 --- /dev/null +++ b/examples/keep_hierachy/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "errors" + "log" + + "github.com/MartialGeek/tortilla" +) + +var ( + errObfuscate = errors.New("unable to obsufcate data") + errEncode = errors.New("encoding failed") +) + +func main() { + err := obsufcate() + if err != nil { + if errors.Is(err, errObfuscate) { + log.Fatal(tortilla.New(err).RollOut().PrettyPrint()) + } + } +} + +func obsufcate() error { + err := encode() + if err != nil { + err = tortilla. + New(err). + Wrap(errObfuscate). + Add(errors.New("some context of what happened")) + } + + return err +} + +func encode() error { + err := encrypt() + if err != nil { + err = tortilla.New(errEncode).Add(err) + } + + return err +} + +func encrypt() error { + return errors.New("encryption error") +} diff --git a/examples/simple_wrapping/main.go b/examples/simple_wrapping/main.go new file mode 100644 index 0000000..9430121 --- /dev/null +++ b/examples/simple_wrapping/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "errors" + "log" + + "github.com/MartialGeek/tortilla" +) + +var ( + errDataFetching = errors.New("unable to fetch data") +) + +func main() { + err := fetchSomeData() + if err != nil { + if errors.Is(err, errDataFetching) { + log.Fatal(tortilla.New(err).RollOut().PrettyPrint()) + } + + log.Println("unknown error:", err) + } +} + +func fetchSomeData() error { + cacheErr := cache() + if cacheErr != nil { + return tortilla.New(cacheErr).Wrap(errDataFetching) + } + + return nil +} + +func cache() error { + return errors.New("some cache error") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ae9f8ba --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/MartialGeek/tortilla + +go 1.17 + +require github.com/stretchr/testify v1.7.0 + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..acb88a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tortilla.png b/tortilla.png new file mode 100644 index 0000000..ca95fb1 Binary files /dev/null and b/tortilla.png differ diff --git a/wrap.go b/wrap.go new file mode 100644 index 0000000..2b27652 --- /dev/null +++ b/wrap.go @@ -0,0 +1,164 @@ +package tortilla + +import ( + "bytes" + "fmt" + "strings" + "text/template" +) + +const stackPrettyPrintTpl = ` +{{- range $layer := .}}{{range $target, $chain := .}}{{$target}}:{{range $chain}} +....{{.}}{{end}} +{{end}}{{end}}` + +var parsedTpl *template.Template + +func init() { + tpl, err := template.New("pretty-print").Parse(stackPrettyPrintTpl) + if err != nil { + err = fmt.Errorf("tortilla: unable to parse the pretty print tpl: %s", err) + panic(err) + } + + parsedTpl = tpl +} + +// Stack is an alias for the slice of layers returned by Tortilla.RollOut() +type Stack []map[string][]string + +// PrettyPrint allows to render a visual string of the error stack. +// The format is a slice of maps, each map is "wrapping error": []list of wrapped error. Example: +// +// last error used to wrap others: +// ....last wrapped error +// ....older error +// older wrapping error: +// ....blablabla +func (s Stack) PrettyPrint() string { + output := new(bytes.Buffer) + err := parsedTpl.Execute(output, s) + if err != nil { + return "Pretty print error" + } + + return strings.TrimSpace(output.String()) +} + +type layer struct { + target error + chain []error +} + +// Tortilla holds the layers of the errors added in the stack. +// Create a Tortilla with New(err). +// +// Then use Wrap(err) to wrap your Tortilla with the a new error. This is the +// equivalent of using fmt.Errorf("%w %s", wrapWithErr, initialErr) +// +// You can also add an error in the stack without wrapping with the Add(err) method. This +// can be useful to keep the history of what happened in your program without "typing" +// with (i.e. errors.Is or errors.As won't return true). +// +// If an error is printed with Error(), a string will be generated with the errors in the +// stack in an inlined form. The errors are sorted in reverse order of creation. +// +// Of course a Tortilla can be rolled out! The RollOut method returns the layers of your +// Tortilla as a Stack type (an alias of []map[string][]string). Then you can use the +// method Stack.PrettyPrint method to display a hierarchy of the errors wrapped and added +// in your Tortilla lifetime. +type Tortilla struct { + layers []layer +} + +// Error returns a flattened string composed by the errors in the stack. +func (t Tortilla) Error() string { + var msg string + for _, layer := range t.layers { + msg += buildLayerMsg(layer) + } + + return strings.TrimSpace(msg) +} + +// Unwrap allows to compare a Tortilla with errors.Is and errors.As. +func (t Tortilla) Unwrap() error { + return t.layers[0].target +} + +// Wrap wraps your Tortilla with a new error. +func (t Tortilla) Wrap(target error) Tortilla { + return t.appendLayer(target, nil) +} + +// Add adds a new error in the error chain of the last wrapping. +func (t Tortilla) Add(err error) Tortilla { + chain := []error{err} + chain = append(chain, t.layers[0].chain...) + layers := t.layers + layers[0].chain = chain + + return Tortilla{layers: layers} +} + +// RollOut allows you to see what's inside your Tortilla. +func (t Tortilla) RollOut() Stack { + s := make(Stack, 0, len(t.layers)) + for _, l := range t.layers { + c := make([]string, 0, len(l.chain)) + for _, e := range l.chain { + c = append(c, e.Error()) + } + + layer := map[string][]string{ + l.target.Error(): c, + } + + s = append(s, layer) + } + + return s +} + +func (t Tortilla) appendLayer(target error, chain []error) Tortilla { + layers := []layer{{target: target, chain: chain}} + layers = append(layers, t.layers...) + + return Tortilla{ + layers: layers, + } +} + +// New creates a new Tortilla from the given error. +// If err is an existing Tortilla, the returned value is that Tortilla. +// Otherwise a new Tortilla is created from err. +func New(err error) Tortilla { + t, ok := err.(Tortilla) + if !ok { + return Tortilla{ + layers: []layer{ + {target: err}, + }, + } + } + + return t +} + +func buildChainMsg(chain []error) (msg string) { + for _, err := range chain { + msg += err.Error() + ", " + } + + msg = strings.TrimRight(msg, ", ") + return +} + +func buildLayerMsg(layer layer) (msg string) { + msg = layer.target.Error() + ": " + msg += buildChainMsg(layer.chain) + msg = strings.TrimRight(msg, ": ") + msg += ". " + + return +} diff --git a/wrap_test.go b/wrap_test.go new file mode 100644 index 0000000..6b9939c --- /dev/null +++ b/wrap_test.go @@ -0,0 +1,99 @@ +package tortilla + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/suite" +) + +type tortillaTestSuite struct { + suite.Suite +} + +func (s *tortillaTestSuite) TestItCreatesATortillaFromAnError() { + err := errors.New("initial error") + got := New(err) + + s.ErrorIs(got, err) +} + +func (s *tortillaTestSuite) TestItWrapsAnError() { + err := errors.New("initial error") + t := New(err) + + wrappedWith := errors.New("wrapping error") + got := t.Wrap(wrappedWith) + + s.ErrorIs(got, wrappedWith) + s.EqualError(got, "wrapping error. initial error.") +} + +func (s *tortillaTestSuite) TestItAddsErrorsWithoutWrapping() { + err := errors.New("initial error") + t := New(err) + + second := errors.New("second") + got := t.Add(second).Add(errors.New("third")) + + s.ErrorIs(got, err) + s.NotErrorIs(got, second) + s.EqualError(got, "initial error: third, second.") +} + +func (s *tortillaTestSuite) TestItCreatesATortillaFromATortilla() { + lastWrap := errors.New("second") + t1 := New(errors.New("first")).Wrap(lastWrap) + got := New(t1) + + s.ErrorIs(got, lastWrap) + s.EqualError(got, "second. first.") +} + +func (s *tortillaTestSuite) TestItCanBeRolledOut() { + t := New(newError("first")). + Wrap(newError("second")). + Add(newError("third")). + Add(newError("fourth")). + Wrap(newError("fifth")). + Wrap(newError("sixth")). + Add(newError("seventh")) + + s.EqualError(t, "sixth: seventh. fifth. second: fourth, third. first.") + + got := t.RollOut() + expected := Stack{ + { + "sixth": []string{"seventh"}, + }, + { + "fifth": []string{}, + }, + { + "second": []string{"fourth", "third"}, + }, + { + "first": []string{}, + }, + } + + s.Equal(expected, got) + + expectedPrettyPrint := `sixth: +....seventh +fifth: +second: +....fourth +....third +first:` + + s.Equal(expectedPrettyPrint, got.PrettyPrint()) +} + +func TestTortilla(t *testing.T) { + suite.Run(t, new(tortillaTestSuite)) +} + +func newError(msg string) error { + return errors.New(msg) +}