Skip to content

Commit

Permalink
Support filtering workflows by schema and advanced error parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
samlown committed Sep 3, 2024
1 parent 2569c48 commit 5e2ee1a
Show file tree
Hide file tree
Showing 8 changed files with 396 additions and 82 deletions.
123 changes: 123 additions & 0 deletions invopop/dict.go
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)
}
106 changes: 106 additions & 0 deletions invopop/dict_test.go
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())
}
80 changes: 80 additions & 0 deletions invopop/errors.go
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
}
71 changes: 0 additions & 71 deletions invopop/invopop.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ package invopop

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/go-resty/resty/v2"
)
Expand Down Expand Up @@ -195,71 +192,3 @@ func (c *Client) patch(ctx context.Context, path string, in, out any) error {
}
return re.handle(res)
}

// 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"`
}

// 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
}
Loading

0 comments on commit 5e2ee1a

Please sign in to comment.