├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── polygo.go └── polygo_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Enrico Candino 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Polygo 2 | 3 | Polygo is a lightweight Go package for decoding polymorphic JSON responses effortlessly. 4 | 5 | Dealing with APIs that return various types of objects can be challenging. 6 | Polygo simplifies this process by allowing you to map your types into a common interface. 7 | 8 | ## Example 9 | 10 | Consider an API endpoint `/v1/shapes` that returns a list of _shapes_, each defined by a type field: 11 | 12 | ```json 13 | [ 14 | { "type": "circle", "radius": 5 }, 15 | { "type": "square", "side": 3 } 16 | ] 17 | ``` 18 | 19 | With Polygo, you can easily handle this polymorphic JSON response. Here's how. 20 | 21 | 1. **Create a Decoder:** Initialize a decoder with a common interface and the field name used to check the object type. 22 | 2. **Register Types:** Register your concrete types with the decoder. 23 | 3. **Unmarshal JSON:** Use one of the available functions to unmarshal your JSON data. 24 | 25 | 26 | ```go 27 | // Define your shape interface 28 | type Shape interface { 29 | Area() float64 30 | } 31 | 32 | // Create a decoder specifying the interface and the field name, 33 | // and register your concrete types 34 | decoder := polygo.NewDecoder[Shape]("type"). 35 | Register("circle", Circle{}). 36 | Register("square", Square{}) 37 | 38 | // unmarshal your JSON 39 | shapes, _ := decoder.UnmarshalArray(jsonBytes) 40 | 41 | for _, shape := range shapes { 42 | // use the methods defined by the interface 43 | fmt.Printf("shape area: %v\n", shape.Area()) 44 | 45 | // or check the concrete type if needed 46 | switch s := shape.(type) { 47 | case *Circle: 48 | fmt.Printf("circle radius: %v\n", s.Radius) 49 | case *Square: 50 | fmt.Printf("square side: %v\n", s.Side) 51 | } 52 | } 53 | ``` 54 | 55 | ## Available functions 56 | 57 | ### UnmarshalObject 58 | 59 | `UnmarshalObject` will unmarshal a plain object: 60 | 61 | ```go 62 | jsonBody := []byte(`{ "type": "circle", "radius": 5 }`) 63 | 64 | shape, err := decoder.UnmarshalObject(jsonBody) 65 | if err != nil { 66 | return err 67 | } 68 | ``` 69 | 70 | ### UnmarshalArray 71 | 72 | `UnmarshalArray` will unmarshal an array of objects: 73 | 74 | ```go 75 | jsonBody := []byte(`[ 76 | { "type": "circle", "radius": 5 }, 77 | { "type": "square", "side": 3 } 78 | ]`) 79 | 80 | shapes, err := decoder.UnmarshalArray(jsonBody) 81 | if err != nil { 82 | return err 83 | } 84 | ``` 85 | 86 | ### UnmarshalInnerObject 87 | 88 | `UnmarshalInnerObject` will unmarshal an object, looking into the specified path (using the [github.com/tidwall/gjson](github.com/tidwall/gjson) library). 89 | 90 | ```go 91 | jsonBody := []byte(`{ 92 | "data": { "type": "circle", "radius": 5 } 93 | }`) 94 | 95 | shapes, err := decoder.UnmarshalInnerObject("data", jsonBody) 96 | if err != nil { 97 | return err 98 | } 99 | ``` 100 | 101 | ### UnmarshalInnerArray 102 | 103 | `UnmarshalInnerArray` will unmarshal an array of objects, looking into the specified path (using the [github.com/tidwall/gjson](github.com/tidwall/gjson) library). 104 | 105 | ```go 106 | jsonBody := []byte(`{ 107 | "data": [ 108 | { "type": "circle", "radius": 5 }, 109 | { "type": "square", "side": 3 } 110 | ] 111 | }`) 112 | 113 | shapes, err := decoder.UnmarshalInnerArray("data", jsonBody) 114 | if err != nil { 115 | return err 116 | } 117 | ``` 118 | 119 | ### Wrapped response 120 | 121 | If your data is wrapped in an object with fields that you are interested to check, you should use a struct with a `json.RawMessage` field. Then you can unmarshal this field with the decoder. 122 | 123 | 124 | 125 | ```go 126 | type Response struct { 127 | Code int `json:"code"` 128 | Message string `json:"message"` 129 | Data json.RawMessage `json:"data"` 130 | } 131 | 132 | jsonData := []byte(`{ 133 | "code": 200, 134 | "message": "all good", 135 | "data": [ 136 | { "type": "circle", "radius": 5 }, 137 | { "type": "square", "side": 3 } 138 | ] 139 | }`) 140 | 141 | var resp Response 142 | err := json.Unmarshal(jsonData, &resp) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | shapes, err := decoder.UnmarshalArray(resp.Data) 148 | if err != nil { 149 | return err 150 | } 151 | ``` 152 | 153 | ## Installation 154 | To use Polygo in your Go project, simply import it: 155 | 156 | ```go 157 | import "github.com/enrichman/polygo" 158 | ``` 159 | 160 | ## Contributing 161 | 162 | Contributions are welcome! Feel free to open issues or pull requests on GitHub. 163 | 164 | ## License 165 | 166 | [MIT](https://github.com/enrichman/polygo/blob/main/LICENSE) 167 | 168 | ## Feedback 169 | 170 | If you like the project please star it on Github 🌟, and feel free to drop me a note on [Twitter](https://twitter.com/enrichmann)https://twitter.com/enrichmann, or open an issue! 171 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/enrichman/polygo 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/stretchr/testify v1.8.4 7 | github.com/tidwall/gjson v1.17.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | github.com/tidwall/match v1.1.1 // indirect 14 | github.com/tidwall/pretty v1.2.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 6 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 7 | github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= 8 | github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 9 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 10 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 11 | github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= 12 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /polygo.go: -------------------------------------------------------------------------------- 1 | package polygo 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | 9 | "github.com/tidwall/gjson" 10 | ) 11 | 12 | type Decoder[T any] struct { 13 | Type T 14 | FieldName string 15 | TypeMap map[string]reflect.Type 16 | } 17 | 18 | func NewDecoder[T any](field string) *Decoder[T] { 19 | return &Decoder[T]{ 20 | FieldName: field, 21 | TypeMap: map[string]reflect.Type{}, 22 | } 23 | } 24 | 25 | func (d *Decoder[T]) Register(value string, v any) *Decoder[T] { 26 | d.TypeMap[value] = reflect.TypeOf(v) 27 | return d 28 | } 29 | 30 | func (d *Decoder[T]) UnmarshalArray(b []byte) ([]T, error) { 31 | res := gjson.ParseBytes(b) 32 | return d.unmarshalArray("", res) 33 | } 34 | 35 | func (d *Decoder[T]) UnmarshalInnerArray(path string, b []byte) ([]T, error) { 36 | res := gjson.ParseBytes(b) 37 | return d.unmarshalArray(path, res) 38 | } 39 | 40 | func (d *Decoder[T]) unmarshalArray(path string, res gjson.Result) ([]T, error) { 41 | if path != "" { 42 | res = res.Get(path) 43 | } 44 | 45 | if !res.IsArray() { 46 | return nil, errors.New("object is not an array") 47 | } 48 | 49 | arr := []T{} 50 | var err error 51 | 52 | res.ForEach(func(key, value gjson.Result) bool { 53 | a, errParse := d.unmarshalObject("", value) 54 | if errParse != nil { 55 | err = errParse 56 | return false 57 | } 58 | 59 | arr = append(arr, a) 60 | return true 61 | }) 62 | 63 | return arr, err 64 | } 65 | 66 | func (d *Decoder[T]) UnmarshalObject(b []byte) (T, error) { 67 | res := gjson.ParseBytes(b) 68 | return d.unmarshalObject("", res) 69 | } 70 | 71 | func (d *Decoder[T]) UnmarshalInnerObject(path string, b []byte) (T, error) { 72 | res := gjson.ParseBytes(b) 73 | return d.unmarshalObject(path, res) 74 | } 75 | 76 | func (d *Decoder[T]) unmarshalObject(path string, res gjson.Result) (T, error) { 77 | var zero T 78 | 79 | if path != "" { 80 | res = res.Get(path) 81 | } 82 | 83 | if res.IsArray() { 84 | return zero, errors.New("cannot unmarshal object: JSON is array") 85 | } 86 | 87 | fieldValue := res.Get(d.FieldName).String() 88 | // TODO check if string 89 | if fieldValue == "" { 90 | return zero, fmt.Errorf("field '%s' not found", d.FieldName) 91 | } 92 | 93 | matchedType, found := d.TypeMap[fieldValue] 94 | if !found { 95 | return zero, fmt.Errorf("type '%s' not registered", fieldValue) 96 | } 97 | 98 | var v reflect.Value 99 | if matchedType.Kind() == reflect.Ptr { 100 | v = reflect.New(matchedType.Elem()) 101 | } else { 102 | v = reflect.New(matchedType) 103 | } 104 | 105 | err := json.Unmarshal([]byte(res.Raw), v.Interface()) 106 | if err != nil { 107 | return zero, err 108 | } 109 | 110 | a, ok := v.Interface().(T) 111 | if !ok { 112 | return zero, errors.New("error casting") 113 | } 114 | 115 | return a, nil 116 | } 117 | -------------------------------------------------------------------------------- /polygo_test.go: -------------------------------------------------------------------------------- 1 | package polygo_test 2 | 3 | import ( 4 | "encoding/json" 5 | "math" 6 | "testing" 7 | 8 | "github.com/enrichman/polygo" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | type Shape interface { 13 | GetType() string 14 | Area() float64 15 | } 16 | 17 | type Circle struct { 18 | Type string `json:"type"` 19 | Radius float64 `json:"radius"` 20 | } 21 | 22 | func NewCircle(radius float64) *Circle { 23 | return &Circle{Type: "circle", Radius: radius} 24 | } 25 | 26 | func (c *Circle) GetType() string { 27 | return c.Type 28 | } 29 | 30 | func (c *Circle) Area() float64 { 31 | return math.Phi * math.Pow(c.Radius, 2) 32 | } 33 | 34 | type Square struct { 35 | Type string `json:"type"` 36 | Side float64 `json:"side"` 37 | } 38 | 39 | func NewSquare(side float64) *Square { 40 | return &Square{Type: "square", Side: side} 41 | } 42 | 43 | func (s *Square) GetType() string { return s.Type } 44 | 45 | func (s *Square) Area() float64 { 46 | return math.Pow(s.Side, 2) 47 | } 48 | 49 | func Test_UnmarshalObject(t *testing.T) { 50 | tt := []struct { 51 | name string 52 | json []byte 53 | expectedObj any 54 | expectedErr string 55 | }{ 56 | { 57 | name: "simple circle", 58 | json: []byte(`{ 59 | "type": "circle", 60 | "radius": 5 61 | }`), 62 | expectedObj: NewCircle(5), 63 | }, 64 | { 65 | name: "unknown type", 66 | json: []byte(`{ 67 | "type": "unknown", 68 | "color": "red", 69 | "foo": "bar" 70 | }`), 71 | expectedErr: "type 'unknown' not registered", 72 | }, 73 | { 74 | name: "field not present", 75 | json: []byte(`{ 76 | "no-type": "circle", 77 | "radius": 5 78 | }`), 79 | expectedErr: "field 'type' not found", 80 | }, 81 | } 82 | 83 | decoder := polygo.NewDecoder[Shape]("type"). 84 | Register("circle", Circle{}). 85 | Register("square", Square{}) 86 | 87 | for _, tc := range tt { 88 | t.Run(tc.name, func(t *testing.T) { 89 | shape, err := decoder.UnmarshalObject(tc.json) 90 | 91 | if tc.expectedErr != "" { 92 | assert.EqualError(t, err, tc.expectedErr) 93 | } else { 94 | assert.NoError(t, err) 95 | } 96 | assert.Equal(t, tc.expectedObj, shape) 97 | }) 98 | } 99 | } 100 | 101 | func Test_UnmarshalArray(t *testing.T) { 102 | tt := []struct { 103 | name string 104 | json []byte 105 | expectedObj any 106 | expectedErr string 107 | }{ 108 | { 109 | name: "simple array", 110 | json: []byte(`[ 111 | { "type": "circle", "radius": 5 }, 112 | { "type": "square", "side": 3 } 113 | ]`), 114 | expectedObj: []Shape{ 115 | NewCircle(5), 116 | NewSquare(3), 117 | }, 118 | }, 119 | } 120 | 121 | decoder := polygo.NewDecoder[Shape]("type"). 122 | Register("circle", Circle{}). 123 | Register("square", Square{}) 124 | 125 | for _, tc := range tt { 126 | t.Run(tc.name, func(t *testing.T) { 127 | shapes, err := decoder.UnmarshalArray(tc.json) 128 | 129 | if tc.expectedErr != "" { 130 | assert.EqualError(t, err, tc.expectedErr) 131 | } else { 132 | assert.NoError(t, err) 133 | } 134 | assert.Equal(t, tc.expectedObj, shapes) 135 | }) 136 | } 137 | } 138 | 139 | func Test_UnmarshalInnerArray(t *testing.T) { 140 | tt := []struct { 141 | name string 142 | json []byte 143 | path string 144 | expectedObj any 145 | expectedErr string 146 | }{ 147 | { 148 | name: "simple data response", 149 | json: []byte(`{ 150 | "data": [ 151 | { "type": "circle", "radius": 5 }, 152 | { "type": "square", "side": 3 } 153 | ] 154 | }`), 155 | path: "data", 156 | expectedObj: []Shape{ 157 | NewCircle(5), 158 | NewSquare(3), 159 | }, 160 | }, 161 | } 162 | 163 | decoder := polygo.NewDecoder[Shape]("type"). 164 | Register("circle", Circle{}). 165 | Register("square", Square{}) 166 | 167 | for _, tc := range tt { 168 | t.Run(tc.name, func(t *testing.T) { 169 | shape, err := decoder.UnmarshalInnerArray(tc.path, tc.json) 170 | 171 | if tc.expectedErr != "" { 172 | assert.EqualError(t, err, tc.expectedErr) 173 | } else { 174 | assert.NoError(t, err) 175 | } 176 | assert.Equal(t, tc.expectedObj, shape) 177 | }) 178 | } 179 | } 180 | 181 | func Test_UnmarshalInnerArrayInResponse(t *testing.T) { 182 | type Response struct { 183 | Message string `json:"message"` 184 | Data json.RawMessage `json:"data"` 185 | } 186 | 187 | jsonData := []byte(`{ 188 | "message": "response returned", 189 | "data": [ 190 | { "type": "circle", "radius": 5 }, 191 | { "type": "square", "side": 3 } 192 | ] 193 | }`) 194 | 195 | decoder := polygo.NewDecoder[Shape]("type"). 196 | Register("circle", &Circle{}). 197 | Register("square", Square{}) 198 | 199 | var resp Response 200 | err := json.Unmarshal(jsonData, &resp) 201 | assert.NoError(t, err) 202 | assert.Equal(t, "response returned", resp.Message) 203 | 204 | shapes, err := decoder.UnmarshalArray(resp.Data) 205 | assert.NoError(t, err) 206 | 207 | expectedObj := []Shape{ 208 | NewCircle(5), 209 | NewSquare(3), 210 | } 211 | assert.Equal(t, expectedObj, shapes) 212 | } 213 | --------------------------------------------------------------------------------