-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support filtering workflows by schema and advanced error parsing
- Loading branch information
Showing
8 changed files
with
396 additions
and
82 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
package invopop | ||
|
||
import ( | ||
"encoding/json" | ||
"strings" | ||
) | ||
|
||
// Dict helps manage a nested map of strings to either messages or | ||
// or other dictionaries. This is useful for accessing error messages | ||
// provided by endpoints that include a "fields" property. | ||
// | ||
// This is based on the Dict model included by the | ||
// [ctxi18n](https://github.com/invopop/ctxi18n) project. | ||
type Dict struct { | ||
msg string | ||
entries map[string]*Dict | ||
} | ||
|
||
// NewDict instantiates a new dict object. | ||
func NewDict() *Dict { | ||
return &Dict{ | ||
entries: make(map[string]*Dict), | ||
} | ||
} | ||
|
||
// Add adds a new key value pair to the dictionary. | ||
func (d *Dict) Add(key string, value any) { | ||
switch v := value.(type) { | ||
case string: | ||
d.entries[key] = &Dict{msg: v} | ||
case map[string]any: | ||
nd := NewDict() | ||
for k, row := range v { | ||
nd.Add(k, row) | ||
} | ||
d.entries[key] = nd | ||
case *Dict: | ||
d.entries[key] = v | ||
default: | ||
// ignore | ||
} | ||
} | ||
|
||
// Message returns the dictionary message or an empty string | ||
// if the dictionary is nil. | ||
func (d *Dict) Message() string { | ||
if d == nil { | ||
return "" | ||
} | ||
return d.msg | ||
} | ||
|
||
// Get recursively retrieves the dictionary at the provided key location. | ||
func (d *Dict) Get(key string) *Dict { | ||
if d == nil { | ||
return nil | ||
} | ||
if key == "" { | ||
return nil | ||
} | ||
n := strings.SplitN(key, ".", 2) | ||
entry, ok := d.entries[n[0]] | ||
if !ok { | ||
return nil | ||
} | ||
if len(n) == 1 { | ||
return entry | ||
} | ||
return entry.Get(n[1]) | ||
} | ||
|
||
// Merge combines the entries of the second dictionary into this one. If a | ||
// key is duplicated in the second diction, the original value takes priority. | ||
func (d *Dict) Merge(d2 *Dict) { | ||
if d2 == nil { | ||
return | ||
} | ||
if d.entries == nil { | ||
d.entries = make(map[string]*Dict) | ||
} | ||
for k, v := range d2.entries { | ||
if d.entries[k] == nil { | ||
d.entries[k] = v | ||
continue | ||
} | ||
d.entries[k].Merge(v) | ||
} | ||
} | ||
|
||
// Flatten returns a simple flat map of the dictionary entries. This might make | ||
// it easier to list out all the error messages for user interfaces. | ||
func (d *Dict) Flatten() map[string]string { | ||
if d == nil { | ||
return nil | ||
} | ||
if d.msg != "" { | ||
return map[string]string{"": d.msg} | ||
} | ||
m := make(map[string]string) | ||
for k, v := range d.entries { | ||
for kk, vv := range v.Flatten() { | ||
x := k | ||
if kk != "" { | ||
x += "." + kk | ||
} | ||
m[x] = vv | ||
} | ||
} | ||
return m | ||
} | ||
|
||
// UnmarshalJSON attempts to load the dictionary data from a JSON byte slice. | ||
func (d *Dict) UnmarshalJSON(data []byte) error { | ||
if len(data) == 0 { | ||
return nil | ||
} | ||
if data[0] == '"' { | ||
d.msg = string(data[1 : len(data)-1]) | ||
return nil | ||
} | ||
d.entries = make(map[string]*Dict) | ||
return json.Unmarshal(data, &d.entries) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
package invopop | ||
|
||
import ( | ||
"encoding/json" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestDictUnmarshalJSON(t *testing.T) { | ||
ex := `{ | ||
"foo": "bar", | ||
"data": { | ||
"supplier": { | ||
"emails": { | ||
"0": { | ||
"addr": "must be a valid email address" | ||
} | ||
} | ||
} | ||
} | ||
}` | ||
dict := new(Dict) | ||
err := json.Unmarshal([]byte(ex), dict) | ||
require.NoError(t, err) | ||
assert.Equal(t, "bar", dict.Get("foo").Message()) | ||
assert.Equal(t, "must be a valid email address", dict.Get("data.supplier.emails.0.addr").Message()) | ||
assert.Empty(t, dict.Get("data.missing").Message()) | ||
assert.Empty(t, dict.Get("random").Message()) | ||
} | ||
|
||
func TestDictFlatten(t *testing.T) { | ||
ex := `{ | ||
"foo": "bar", | ||
"data": { | ||
"supplier": { | ||
"emails": { | ||
"0": { | ||
"addr": "must be a valid email address" | ||
} | ||
} | ||
} | ||
} | ||
}` | ||
dict := new(Dict) | ||
err := json.Unmarshal([]byte(ex), dict) | ||
require.NoError(t, err) | ||
out := dict.Flatten() | ||
assert.Equal(t, "bar", out["foo"]) | ||
assert.Equal(t, "must be a valid email address", out["data.supplier.emails.0.addr"]) | ||
} | ||
|
||
func TestDictAdd(t *testing.T) { | ||
d := NewDict() | ||
assert.Nil(t, d.Get("")) | ||
d.Add("foo", "bar") | ||
assert.Equal(t, "bar", d.Get("foo").Message()) | ||
|
||
d.Add("plural", map[string]any{ | ||
"zero": "no mice", | ||
"one": "%s mouse", | ||
"other": "%s mice", | ||
}) | ||
assert.Equal(t, "no mice", d.Get("plural.zero").Message()) | ||
assert.Equal(t, "%s mice", d.Get("plural.other").Message()) | ||
|
||
d.Add("bad", 10) // ignore | ||
assert.Nil(t, d.Get("bad")) | ||
|
||
d.Add("self", d) | ||
assert.Equal(t, "bar", d.Get("self.foo").Message()) | ||
} | ||
|
||
func TestDictMerge(t *testing.T) { | ||
ex := `{ | ||
"foo": "bar", | ||
"baz": { | ||
"qux": "quux", | ||
"plural": { | ||
"zero": "no mice", | ||
"one": "%s mouse", | ||
"other": "%s mice" | ||
} | ||
} | ||
}` | ||
d1 := new(Dict) | ||
require.NoError(t, json.Unmarshal([]byte(ex), d1)) | ||
|
||
ex2 := `{ | ||
"foo": "baz", | ||
"extra": "value" | ||
}` | ||
d2 := new(Dict) | ||
require.NoError(t, json.Unmarshal([]byte(ex2), d2)) | ||
|
||
d1.Merge(nil) // does nothing | ||
|
||
d3 := new(Dict) | ||
d3.Merge(d2) | ||
assert.Equal(t, "value", d3.Get("extra").Message()) | ||
|
||
d1.Merge(d2) | ||
assert.Equal(t, "bar", d1.Get("foo").Message(), "should not overwrite") | ||
assert.Equal(t, "value", d1.Get("extra").Message()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
package invopop | ||
|
||
import ( | ||
"errors" | ||
"fmt" | ||
"net/http" | ||
|
||
"github.com/go-resty/resty/v2" | ||
) | ||
|
||
// ResponseError is a wrapper around error responses from the server that will handle | ||
// error messages. | ||
type ResponseError struct { | ||
response *resty.Response | ||
|
||
// Code is the error code which may have been provided by the server. | ||
Code string `json:"code"` | ||
|
||
// Message contains a human readable response message from the API in the case | ||
// of an error. | ||
Message string `json:"message"` | ||
|
||
// Fields provides a nested map of | ||
Fields *Dict `json:"fields,omitempty"` | ||
} | ||
|
||
// handle will wrap the resty response to provide our own Response object that | ||
// wraps around any errors that might have happened with the connection or response. | ||
func (r *ResponseError) handle(res *resty.Response) error { | ||
if res.IsSuccess() { | ||
return nil | ||
} | ||
r.response = res | ||
return r | ||
} | ||
|
||
// StatusCode provides the response status code, or 0 if an error occurred. | ||
func (r *ResponseError) StatusCode() int { | ||
return r.response.StatusCode() | ||
} | ||
|
||
// Error provides the response error string. | ||
func (r *ResponseError) Error() string { | ||
if r.Code != "" { | ||
return fmt.Sprintf("%d: (%s) %s", r.response.StatusCode(), r.Code, r.Message) | ||
} | ||
return fmt.Sprintf("%d: %v", r.response.StatusCode(), r.Message) | ||
} | ||
|
||
// Response provides underlying response, in case it might be useful for | ||
// debugging. | ||
func (r *ResponseError) Response() *resty.Response { | ||
return r.response | ||
} | ||
|
||
// IsConflict is a helper that will provide the response error object | ||
// if the error is a conflict. | ||
func IsConflict(err error) *ResponseError { | ||
return isError(err, http.StatusConflict) | ||
} | ||
|
||
// IsNotFound returns the error response if the status is not found. | ||
func IsNotFound(err error) *ResponseError { | ||
return isError(err, http.StatusNotFound) | ||
} | ||
|
||
// IsForbidden returns the error response if the status is forbidden. | ||
func IsForbidden(err error) *ResponseError { | ||
return isError(err, http.StatusForbidden) | ||
} | ||
|
||
func isError(err error, status int) *ResponseError { | ||
var re *ResponseError | ||
if errors.As(err, &re) { | ||
if re.StatusCode() == status { | ||
return re | ||
} | ||
} | ||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.