Skip to content

Commit

Permalink
Initial implementation of required functionality (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
e-sumin authored Feb 13, 2024
1 parent 4909630 commit 541a584
Show file tree
Hide file tree
Showing 11 changed files with 1,262 additions and 0 deletions.
65 changes: 65 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Go

on:
push:
branches:
- '*'
pull_request:
branches:
- '*'

jobs:
build:
strategy:
matrix:
go: ['stable', 'oldstable']
os: ['ubuntu-latest']

runs-on: ${{ matrix.os }}

name: Go ${{ matrix.go }} in ${{ matrix.os }}

steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
name: Install Go
with:
go-version: ${{ matrix.go }}
check-latest: true
cache: true

- name: Go Environment
run: |
go version
go env
- name: Get dependencies
run: go get -v -t -d ./...

- name: Run Fmt
run: go fmt ./...

- name: Run Vet
run: |
go vet -stdmethods=false $(go list ./...)
go mod tidy
if ! test -z "$(git status --porcelain)"; then
echo "Please run 'go mod tidy'"
exit 1
fi
- name: Run Lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
only-new-issues: true
skip-pkg-cache: true

- name: Run Staticcheck
run: |
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
- name: Run Test
run: go test -race -coverpkg=./... -coverprofile=coverage.txt ./...
124 changes: 124 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Usage:

## Creation
When creating an error, we can use one of the following ways. All errors created this way will capture the current line and filename.

```go
// Creation of error with some message.
someError := errkit.New("Sample of std error")

// Creation of error with some additional details
anotherError := errkit.New("Some error with details", "TextDetail", "Text value", "NumericDetail", 123)
```

Sometimes it could be useful to create predefined errors. Such errors will not capture the stack.
```go
var (
ErrNotFound := errkit.NewSentinelErr("Not found")
ErrAlreadyExists := errkit.NewSentinelErr("Already exists")
)

func Foo() error {
...
return ErrNotFound
}
```

## Wrapping

### Adding stack trace
If you are interested in adding information about the line and filename where the sentinel error happened, you can do the following:
```go
func Foo() error {
...
err := errkit.WithStack(ErrNotFound)
return err
}

func Bar() error {
err := Foo()
if err != nil && errkit.Is(err, ErrNotFound) {
fmt.Println("Resource not found, do nothing")
return nil
}
...
}
```

### Adding error cause information
Sometimes you might be interested in returning a sentinel error, but also add some cause error to it, in such cases you can do the following:
```go
func FetchSomething(ID string) error {
err := doSomething() // Here we have an error
if err != nil { // At this step we decide that in such a case we'd like to say that the resource is not found
return errkit.WithCause(ErrNotFound, err)
}

return nil
}

func FooBar() error {
err := FetchSomething()
if err == nil {
return nil
}

if errkit.Is(err, ErrNotFound) {
return nil // Not found is an expected scenario here, do nothing
}

// Errors other than NotFound should be returned as is
return err
}
```

### Wrapping an error with a high-level message
Sometimes you might want to add some high-level information to an error before passing it up to the invoker.
```go
func LoadProfile() error {
err := makeAnApiCall()
if err != nil {
return errkit.Wrap(err, "Unable to load profile")
}
return nil
}

```

### Unwrapping errors
If needed, you can always get the wrapped error using the standard errors.Unwrap method, it also has an alias errkit.Unwrap
```go
var (
ErrSomething = errkit.NewSentinelErr("Some error")
)

wrappedError := errkit.Wrap(ErrSomething, "Wrapped error")

err := errors.Unwrap(wrappedError)
if err != ErrSomething {
return errors.New("Unable to unwrap error cause")
}
```

### Matching an errors
You can use standard errors matching methods like errors.Is and errors.As, they also have aliases errkit.Is and errkit.As
```go
// testErrorType which implements std error interface
type testErrorType struct {
message string
}

var (
ErrTestType = newTestError("Sample error of custom type")
)

wrappedTestError := errkit.Wrap(ErrTestType, "Wrapped TEST error")
if !errors.Is(wrappedTestError, ErrTestType) {
return errors.New("error is not implementing requested type")
}

var asErr *testErrorType
if !errors.As(origErr, &asErr) {
return errors.New("unable to cast error to its cause")
}
```
50 changes: 50 additions & 0 deletions error_details.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package errkit

import (
"fmt"
)

const (
noVal = "NOVAL"
badKey = "BADKEY"
)

type ErrorDetails map[string]any

// ToErrorDetails accepts either an even size array which contains pais of key/value
// or array of one element of ErrorDetails type.
// Result of function is an ErrorDetails
func ToErrorDetails(details []any) ErrorDetails {
if len(details) == 0 {
return nil
}

if len(details) == 1 {
if dp, ok := details[0].(ErrorDetails); ok {
// Actually we have ErrorDetails on input, so just make a copy
errorDetails := make(ErrorDetails, len(dp))
for k, v := range dp {
errorDetails[k] = v
}
return errorDetails
}
}

// It might happen that odd number of elements will be passed, trying our best to handle this case
if len(details)%2 != 0 {
details = append(details, noVal)
}

errorDetails := make(ErrorDetails, len(details)/2)
for i := 0; i < len(details); i += 2 {
name := details[i]
nameStr, ok := name.(string)
if !ok {
nameStr = fmt.Sprintf("%s:(%v)", badKey, name)
}

errorDetails[nameStr] = details[i+1]
}

return errorDetails
}
51 changes: 51 additions & 0 deletions error_details_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package errkit_test

import (
"testing"

qt "github.com/frankban/quicktest"
"github.com/kanisterio/errkit"
)

type testStruct struct {
Foo string
Bar int
Baz *testStruct
}

func TestToErrorDetails(t *testing.T) {
cases := []struct {
testName string
args []any
expected errkit.ErrorDetails
}{
{
testName: "ErrorDetails as an argument",
args: []any{errkit.ErrorDetails{"key": "value"}},
expected: errkit.ErrorDetails{"key": "value"},
},
{
testName: "Sequence of keys and values of any type",
args: []any{"string_key", "string value", "int key", 123, "struct key", testStruct{Foo: "aaa", Bar: 123, Baz: &testStruct{Foo: "bbb", Bar: 234}}},
expected: errkit.ErrorDetails{"string_key": "string value", "int key": 123, "struct key": testStruct{Foo: "aaa", Bar: 123, Baz: &testStruct{Foo: "bbb", Bar: 234}}},
},
{
testName: "Odd number of arguments",
args: []any{"key_1", 1, "key_2"},
expected: errkit.ErrorDetails{"key_1": 1, "key_2": "NOVAL"},
},
{
testName: "Argument which is supposed to be a key is not a string",
args: []any{123, 456},
expected: errkit.ErrorDetails{"BADKEY:(123)": 456},
},
}

for _, tc := range cases {
t.Run(tc.testName, func(t *testing.T) {
c := qt.New(t)
result := errkit.ToErrorDetails(tc.args)
c.Assert(result, qt.DeepEquals, tc.expected)
})
}
}
103 changes: 103 additions & 0 deletions error_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package errkit

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strconv"
)

type ErrorList []error

var _ error = (ErrorList)(nil)
var _ json.Marshaler = (ErrorList)(nil)

func (e ErrorList) String() string {
sep := ""
var buf bytes.Buffer
buf.WriteRune('[')
for _, err := range e {
buf.WriteString(sep)
sep = ","
buf.WriteString(strconv.Quote(err.Error()))
}
buf.WriteRune(']')
return buf.String()
}

func (e ErrorList) Error() string {
return e.String()
}

// As allows error.As to work against any error in the list.
func (e ErrorList) As(target any) bool {
for _, err := range e {
if errors.As(err, target) {
return true
}
}
return false
}

// Is allows error.Is to work against any error in the list.
func (e ErrorList) Is(target error) bool {
for _, err := range e {
if errors.Is(err, target) {
return true
}
}
return false
}

func (e ErrorList) MarshalJSON() ([]byte, error) {
var je struct {
Message string `json:"message"`
Errors []json.RawMessage `json:"errors"`
}

switch len(e) {
case 0:
// no errors
return []byte("null"), nil
case 1:
// this is unlikely to happen as kerrors.Append won't allow having just a single error on the list
je.Message = "1 error has occurred"
default:
je.Message = fmt.Sprintf("%d errors have occurred", len(e))
}

je.Errors = make([]json.RawMessage, 0, len(e))
for i := range e {
raw, err := json.Marshal(jsonMarshable(e[i]))
if err != nil {
return nil, err
}

je.Errors = append(je.Errors, raw)
}

return json.Marshal(je)
}

// Append creates a new combined error from err1, err2. If either error is nil,
// then the other error is returned.
func Append(err1, err2 error) error {
if err1 == nil {
return ErrorList{err2}
}
if err2 == nil {
return ErrorList{err1}
}
el1, ok1 := err1.(ErrorList)
el2, ok2 := err2.(ErrorList)
switch {
case ok1 && ok2:
return append(el1, el2...)
case ok1:
return append(el1, err2)
case ok2:
return append(el2, err1)
}
return ErrorList{err1, err2}
}
Loading

0 comments on commit 541a584

Please sign in to comment.