├── .gitignore ├── go.mod ├── .github └── workflows │ └── go.yml ├── go.sum ├── LICENSE ├── README.md ├── jsonrpc.go └── jsonrpc_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ybbus/jsonrpc/v3 2 | 3 | go 1.21 4 | 5 | require github.com/stretchr/testify v1.8.4 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.21 20 | 21 | - name: Get dependencies 22 | run: go get -v ./... 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v -covermode=atomic -coverprofile=coverage.out ./... 29 | 30 | - name: Upload coverage reports to Codecov 31 | uses: codecov/codecov-action@v3 32 | -------------------------------------------------------------------------------- /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 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexander Gehres 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 | [![Go Report Card](https://goreportcard.com/badge/github.com/ybbus/jsonrpc/v3)](https://goreportcard.com/report/github.com/ybbus/jsonrpc/v3) 2 | [![Go build](https://github.com/ybbus/jsonrpc/actions/workflows/go.yml/badge.svg)](https://github.com/ybbus/jsonrpc) 3 | [![Codecov](https://codecov.io/github/ybbus/jsonrpc/branch/master/graph/badge.svg?token=ARYOQ8R1DT)](https://codecov.io/github/ybbus/jsonrpc) 4 | [![GoDoc](https://godoc.org/github.com/ybbus/jsonrpc/v3?status.svg)](https://godoc.org/github.com/ybbus/jsonrpc/v3) 5 | [![GitHub license](https://img.shields.io/github/license/mashape/apistatus.svg)]() 6 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 7 | 8 | # JSON-RPC 2.0 Client for golang 9 | A go implementation of an rpc client using json as data format over http. 10 | The implementation is based on the JSON-RPC 2.0 specification: http://www.jsonrpc.org/specification 11 | 12 | Supports: 13 | - requests with arbitrary parameters 14 | - convenient response retrieval 15 | - batch requests 16 | - custom http client (e.g. proxy, tls config) 17 | - custom headers (e.g. basic auth) 18 | 19 | ## Installation 20 | 21 | ```sh 22 | go get -u github.com/ybbus/jsonrpc/v3 23 | ``` 24 | 25 | (You can find v2 and v1 in a separate branch.) 26 | 27 | ## Getting started 28 | Let's say we want to retrieve a person struct with a specific id using rpc-json over http. 29 | Then we want to save this person after we changed a property. 30 | (Error handling is omitted here) 31 | 32 | ```go 33 | package main 34 | 35 | import ( 36 | "context" 37 | "github.com/ybbus/jsonrpc/v3" 38 | ) 39 | 40 | type Person struct { 41 | ID int `json:"id"` 42 | Name string `json:"name"` 43 | Age int `json:"age"` 44 | } 45 | 46 | func main() { 47 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 48 | 49 | var person *Person 50 | rpcClient.CallFor(context.Background(), &person, "getPersonById", 4711) 51 | 52 | person.Age = 33 53 | rpcClient.Call(context.Background(), "updatePerson", person) 54 | } 55 | 56 | ``` 57 | 58 | ## In detail 59 | 60 | ### Generating rpc-json requests 61 | 62 | Let's start by executing a simple json-rpc http call: 63 | In production code: Always make sure to check err != nil first! 64 | 65 | This calls generate and send a valid rpc-json object. (see: http://www.jsonrpc.org/specification#request_object) 66 | 67 | ```go 68 | func main() { 69 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 70 | rpcClient.Call(ctx, "getDate") 71 | // generates body: {"method":"getDate","id":0,"jsonrpc":"2.0"} 72 | } 73 | ``` 74 | 75 | Call a function with parameter: 76 | 77 | ```go 78 | func main() { 79 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 80 | rpcClient.Call(ctx, "addNumbers", 1, 2) 81 | // generates body: {"method":"addNumbers","params":[1,2],"id":0,"jsonrpc":"2.0"} 82 | } 83 | ``` 84 | 85 | Call a function with arbitrary parameters: 86 | 87 | ```go 88 | func main() { 89 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 90 | rpcClient.Call(ctx, "createPerson", "Alex", 33, "Germany") 91 | // generates body: {"method":"createPerson","params":["Alex",33,"Germany"],"id":0,"jsonrpc":"2.0"} 92 | } 93 | ``` 94 | 95 | Call a function providing custom data structures as parameters: 96 | 97 | ```go 98 | type Person struct { 99 | Name string `json:"name"` 100 | Age int `json:"age"` 101 | Country string `json:"country"` 102 | } 103 | func main() { 104 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 105 | rpcClient.Call(ctx, "createPerson", &Person{"Alex", 33, "Germany"}) 106 | // generates body: {"jsonrpc":"2.0","method":"createPerson","params":{"name":"Alex","age":33,"country":"Germany"},"id":0} 107 | } 108 | ``` 109 | 110 | Complex example: 111 | 112 | ```go 113 | type Person struct { 114 | Name string `json:"name"` 115 | Age int `json:"age"` 116 | Country string `json:"country"` 117 | } 118 | func main() { 119 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 120 | rpcClient.Call(ctx, "createPersonsWithRole", &Person{"Alex", 33, "Germany"}, &Person{"Barney", 38, "Germany"}, []string{"Admin", "User"}) 121 | // generates body: {"jsonrpc":"2.0","method":"createPersonsWithRole","params":[{"name":"Alex","age":33,"country":"Germany"},{"name":"Barney","age":38,"country":"Germany"},["Admin","User"]],"id":0} 122 | } 123 | ``` 124 | 125 | Some examples and resulting JSON-RPC objects: 126 | 127 | ```go 128 | rpcClient.Call(ctx, "missingParam") 129 | {"method":"missingParam"} 130 | 131 | rpcClient.Call(ctx, "nullParam", nil) 132 | {"method":"nullParam","params":[null]} 133 | 134 | rpcClient.Call(ctx, "boolParam", true) 135 | {"method":"boolParam","params":[true]} 136 | 137 | rpcClient.Call(ctx, "boolParams", true, false, true) 138 | {"method":"boolParams","params":[true,false,true]} 139 | 140 | rpcClient.Call(ctx, "stringParam", "Alex") 141 | {"method":"stringParam","params":["Alex"]} 142 | 143 | rpcClient.Call(ctx, "stringParams", "JSON", "RPC") 144 | {"method":"stringParams","params":["JSON","RPC"]} 145 | 146 | rpcClient.Call(ctx, "numberParam", 123) 147 | {"method":"numberParam","params":[123]} 148 | 149 | rpcClient.Call(ctx, "numberParams", 123, 321) 150 | {"method":"numberParams","params":[123,321]} 151 | 152 | rpcClient.Call(ctx, "floatParam", 1.23) 153 | {"method":"floatParam","params":[1.23]} 154 | 155 | rpcClient.Call(ctx, "floatParams", 1.23, 3.21) 156 | {"method":"floatParams","params":[1.23,3.21]} 157 | 158 | rpcClient.Call(ctx, "manyParams", "Alex", 35, true, nil, 2.34) 159 | {"method":"manyParams","params":["Alex",35,true,null,2.34]} 160 | 161 | rpcClient.Call(ctx, "singlePointerToStruct", &person) 162 | {"method":"singlePointerToStruct","params":{"name":"Alex","age":35,"country":"Germany"}} 163 | 164 | rpcClient.Call(ctx, "multipleStructs", &person, &drink) 165 | {"method":"multipleStructs","params":[{"name":"Alex","age":35,"country":"Germany"},{"name":"Cuba Libre","ingredients":["rum","cola"]}]} 166 | 167 | rpcClient.Call(ctx, "singleStructInArray", []*Person{&person}) 168 | {"method":"singleStructInArray","params":[{"name":"Alex","age":35,"country":"Germany"}]} 169 | 170 | rpcClient.Call(ctx, "namedParameters", map[string]interface{}{ 171 | "name": "Alex", 172 | "age": 35, 173 | }) 174 | {"method":"namedParameters","params":{"age":35,"name":"Alex"}} 175 | 176 | rpcClient.Call(ctx, "anonymousStruct", struct { 177 | Name string `json:"name"` 178 | Age int `json:"age"` 179 | }{"Alex", 33}) 180 | {"method":"anonymousStructWithTags","params":{"name":"Alex","age":33}} 181 | 182 | rpcClient.Call(ctx, "structWithNullField", struct { 183 | Name string `json:"name"` 184 | Address *string `json:"address"` 185 | }{"Alex", nil}) 186 | {"method":"structWithNullField","params":{"name":"Alex","address":null}} 187 | ``` 188 | 189 | ### Working with rpc-json responses 190 | 191 | 192 | Before working with the response object, make sure to check err != nil. 193 | Also keep in mind that the json-rpc result field can be nil even on success. 194 | 195 | ```go 196 | func main() { 197 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 198 | response, err := rpcClient.Call(ctx, "addNumbers", 1, 2) 199 | if err != nil { 200 | // error handling goes here e.g. network / http error 201 | } 202 | } 203 | ``` 204 | 205 | If an http error occurred, maybe you are interested in the error code (403 etc.) 206 | ```go 207 | func main() { 208 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 209 | response, err := rpcClient.Call(ctx, "addNumbers", 1, 2) 210 | 211 | switch e := err.(type) { 212 | case nil: // if error is nil, do nothing 213 | case *HTTPError: 214 | // use e.Code here 215 | return 216 | default: 217 | // any other error 218 | return 219 | } 220 | 221 | // no error, go on... 222 | } 223 | ``` 224 | 225 | The next thing you have to check is if an rpc-json protocol error occurred. This is done by checking if the Error field in the rpc-response != nil: 226 | (see: http://www.jsonrpc.org/specification#error_object) 227 | 228 | ```go 229 | func main() { 230 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 231 | response, err := rpcClient.Call(ctx, "addNumbers", 1, 2) 232 | if err != nil { 233 | //error handling goes here 234 | } 235 | 236 | if response.Error != nil { 237 | // rpc error handling goes here 238 | // check response.Error.Code, response.Error.Message and optional response.Error.Data 239 | } 240 | } 241 | ``` 242 | 243 | After making sure that no errors occurred you can now examine the RPCResponse object. 244 | When executing a json-rpc request, most of the time you will be interested in the "result"-property of the returned json-rpc response object. 245 | (see: http://www.jsonrpc.org/specification#response_object) 246 | The library provides some helper functions to retrieve the result in the data format you are interested in. 247 | Again: check for err != nil here to be sure the expected type was provided in the response and could be parsed. 248 | 249 | ```go 250 | func main() { 251 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 252 | response, _ := rpcClient.Call(ctx, "addNumbers", 1, 2) 253 | 254 | result, err := response.GetInt() 255 | if err != nil { 256 | // result cannot be unmarshalled as integer 257 | } 258 | 259 | // helpers provided for all primitive types: 260 | response.GetInt() 261 | response.GetFloat() 262 | response.GetString() 263 | response.GetBool() 264 | } 265 | ``` 266 | 267 | Retrieving arrays and objects is also very simple: 268 | 269 | ```go 270 | // json annotations are only required to transform the structure back to json 271 | type Person struct { 272 | Id int `json:"id"` 273 | Name string `json:"name"` 274 | Age int `json:"age"` 275 | } 276 | 277 | func main() { 278 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 279 | response, _ := rpcClient.Call(ctx, "getPersonById", 123) 280 | 281 | var person *Person 282 | err := response.GetObject(&person) // expects a rpc-object result value like: {"id": 123, "name": "alex", "age": 33} 283 | if err != nil || person == nil { 284 | // some error on json unmarshal level or json result field was null 285 | } 286 | 287 | fmt.Println(person.Name) 288 | 289 | // we can also set default values if they are missing from the result, or result == null: 290 | person2 := &Person{ 291 | Id: 0, 292 | Name: "", 293 | Age: -1, 294 | } 295 | err := response.GetObject(&person2) // expects a rpc-object result value like: {"id": 123, "name": "alex", "age": 33} 296 | if err != nil || person2 == nil { 297 | // some error on json unmarshal level or json result field was null 298 | } 299 | 300 | fmt.Println(person2.Name) // prints "" if "name" field was missing in result-json 301 | } 302 | ``` 303 | 304 | Retrieving arrays: 305 | 306 | ```go 307 | func main() { 308 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 309 | response, _ := rpcClient.Call(ctx, "getRandomNumbers", 10) 310 | 311 | rndNumbers := []int{} 312 | err := response.GetObject(&rndNumbers) // expects a rpc-object result value like: [10, 188, 14, 3] 313 | if err != nil { 314 | // do error handling 315 | } 316 | 317 | for _, num := range rndNumbers { 318 | fmt.Printf("%v\n", num) 319 | } 320 | } 321 | ``` 322 | 323 | ### Using convenient function CallFor() 324 | A very handy way to quickly invoke methods and retrieve results is by using CallFor() 325 | 326 | You can directly provide an object where the result should be stored. Be sure to provide it be reference. 327 | An error is returned if: 328 | - there was an network / http error 329 | - RPCError object is not nil (err can be casted to this object) 330 | - rpc result could not be parsed into provided object 331 | 332 | One of the above examples could look like this: 333 | 334 | ```go 335 | // json annotations are only required to transform the structure back to json 336 | type Person struct { 337 | Id int `json:"id"` 338 | Name string `json:"name"` 339 | Age int `json:"age"` 340 | } 341 | 342 | func main() { 343 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 344 | 345 | var person *Person 346 | err := rpcClient.CallFor(ctx, &person, "getPersonById", 123) 347 | 348 | if err != nil || person == nil { 349 | // handle error 350 | } 351 | 352 | fmt.Println(person.Name) 353 | } 354 | ``` 355 | 356 | Most of the time it is ok to check if a struct field is 0, empty string "" etc. to check if it was provided by the json rpc response. 357 | But if you want to be sure that a JSON-RPC response field was missing or not, you should use pointers to the fields. 358 | This is just a single example since all this Unmarshaling is standard go json functionality, exactly as if you would call json.Unmarshal(rpcResponse.ResultAsByteArray, &objectToStoreResult) 359 | 360 | ```go 361 | type Person struct { 362 | Id *int `json:"id"` 363 | Name *string `json:"name"` 364 | Age *int `json:"age"` 365 | } 366 | 367 | func main() { 368 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 369 | 370 | var person *Person 371 | err := rpcClient.CallFor(ctx, &person, "getPersonById", 123) 372 | 373 | if err != nil || person == nil { 374 | // handle error 375 | } 376 | 377 | if person.Name == nil { 378 | // json rpc response did not provide a field "name" in the result object 379 | } 380 | } 381 | ``` 382 | 383 | ### Using RPC Batch Requests 384 | 385 | You can send multiple RPC-Requests in one single HTTP request using RPC Batch Requests. 386 | 387 | ```go 388 | func main() { 389 | rpcClient := jsonrpc.NewClient("http://my-rpc-service:8080/rpc") 390 | 391 | response, _ := rpcClient.CallBatch(ctx, RPCRequests{ 392 | NewRequest("myMethod1", 1, 2, 3), 393 | NewRequest("anotherMethod", "Alex", 35, true), 394 | NewRequest("myMethod2", &Person{ 395 | Name: "Emmy", 396 | Age: 4, 397 | }), 398 | }) 399 | } 400 | ``` 401 | 402 | Keep the following in mind: 403 | - the request / response id's are important to map the requests to the responses. CallBatch() automatically sets the ids to requests[i].ID == i 404 | - the response can be provided in an unordered and maybe incomplete form 405 | - when you want to set the id yourself use, CallRaw() 406 | 407 | There are some helper methods for batch request results: 408 | ```go 409 | func main() { 410 | // [...] 411 | 412 | result.HasErrors() // returns true if one of the rpc response objects has Error field != nil 413 | resultMap := result.AsMap() // returns a map for easier retrieval of requests 414 | 415 | if response123, ok := resultMap[123]; ok { 416 | // response object with id 123 exists, use it here 417 | // response123.ID == 123 418 | response123.GetObjectAs(&person) 419 | // ... 420 | } 421 | 422 | } 423 | ``` 424 | 425 | ### Raw functions 426 | There are also Raw function calls. Consider the non Raw functions first, unless you know what you are doing. 427 | You can create invalid json rpc requests and have to take care of id's etc. yourself. 428 | Also check documentation of Params() for raw requests. 429 | 430 | ### Custom Headers, Basic authentication 431 | 432 | If the rpc-service is running behind a basic authentication you can easily set the Authorization header: 433 | 434 | ```go 435 | func main() { 436 | rpcClient := jsonrpc.NewClientWithOpts("http://my-rpc-service:8080/rpc", &jsonrpc.RPCClientOpts{ 437 | CustomHeaders: map[string]string{ 438 | "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("myUser"+":"+"mySecret")), 439 | }, 440 | }) 441 | response, _ := rpcClient.Call(ctx, "addNumbers", 1, 2) // send with Authorization-Header 442 | } 443 | ``` 444 | 445 | ### Using oauth 446 | 447 | Using oauth is also easy, e.g. with clientID and clientSecret authentication 448 | 449 | ```go 450 | func main() { 451 | credentials := clientcredentials.Config{ 452 | ClientID: "myID", 453 | ClientSecret: "mySecret", 454 | TokenURL: "http://mytokenurl", 455 | } 456 | 457 | rpcClient := jsonrpc.NewClientWithOpts("http://my-rpc-service:8080/rpc", &jsonrpc.RPCClientOpts{ 458 | HTTPClient: credentials.Client(context.Background()), 459 | }) 460 | 461 | // requests now retrieve and use an oauth token 462 | } 463 | ``` 464 | 465 | ### Set a custom httpClient 466 | 467 | If you have some special needs on the http.Client of the standard go library, just provide your own one. 468 | For example to use a proxy when executing json-rpc calls: 469 | 470 | ```go 471 | func main() { 472 | proxyURL, _ := url.Parse("http://proxy:8080") 473 | transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} 474 | 475 | httpClient := &http.Client{ 476 | Transport: transport, 477 | } 478 | 479 | rpcClient := jsonrpc.NewClientWithOpts("http://my-rpc-service:8080/rpc", &jsonrpc.RPCClientOpts{ 480 | HTTPClient: httpClient, 481 | }) 482 | 483 | // requests now use proxy 484 | } 485 | ``` 486 | 487 | ### Allow unknown fields in json-rpc response object 488 | 489 | By default, the client will return an error, if the response object contains fields, that are not defined in the response struct. 490 | You may change this behavior by setting the RPCClientOpts.AllowUnknownFields to true: 491 | 492 | ```go 493 | func main() { 494 | rpcClient := jsonrpc.NewClientWithOpts("http://my-rpc-service:8080/rpc", &jsonrpc.RPCClientOpts{ 495 | AllowUnknownFields: true, 496 | }) 497 | 498 | // unknown fields are now allowed in the response 499 | } 500 | ``` 501 | 502 | ### Change default RPCRequestID 503 | 504 | By default, the client will set the id of an RPCRequest to 0. 505 | This can be changed by setting the RPCClientOpts.DefaultRequestID to a custom value: 506 | 507 | ```go 508 | func main() { 509 | rpcClient := jsonrpc.NewClientWithOpts("http://my-rpc-service:8080/rpc", &jsonrpc.RPCClientOpts{ 510 | DefaultRequestID: 1, 511 | }) 512 | 513 | // requests now have default id 1 514 | } 515 | ``` 516 | 517 | You may also use NewRequestWithID() to set a custom id when creating a raw request. 518 | -------------------------------------------------------------------------------- /jsonrpc.go: -------------------------------------------------------------------------------- 1 | // Package jsonrpc provides a JSON-RPC 2.0 client that sends JSON-RPC requests and receives JSON-RPC responses using HTTP. 2 | package jsonrpc 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "reflect" 12 | "strconv" 13 | ) 14 | 15 | const ( 16 | jsonrpcVersion = "2.0" 17 | ) 18 | 19 | // RPCClient sends JSON-RPC requests over HTTP to the provided JSON-RPC backend. 20 | // 21 | // RPCClient is created using the factory function NewClient(). 22 | type RPCClient interface { 23 | // Call is used to send a JSON-RPC request to the server endpoint. 24 | // 25 | // The spec states, that params can only be an array or an object, no primitive values. 26 | // So there are a few simple rules to notice: 27 | // 28 | // 1. no params: params field is omitted. e.g. Call(ctx, "getinfo") 29 | // 30 | // 2. single params primitive value: value is wrapped in array. e.g. Call(ctx, "getByID", 1423) 31 | // 32 | // 3. single params value array or object: value is unchanged. e.g. Call(ctx, "storePerson", &Person{Name: "Alex"}) 33 | // 34 | // 4. multiple params values: always wrapped in array. e.g. Call(ctx, "setDetails", "Alex, 35, "Germany", true) 35 | // 36 | // Examples: 37 | // Call(ctx, "getinfo") -> {"method": "getinfo"} 38 | // Call(ctx, "getPersonId", 123) -> {"method": "getPersonId", "params": [123]} 39 | // Call(ctx, "setName", "Alex") -> {"method": "setName", "params": ["Alex"]} 40 | // Call(ctx, "setMale", true) -> {"method": "setMale", "params": [true]} 41 | // Call(ctx, "setNumbers", []int{1, 2, 3}) -> {"method": "setNumbers", "params": [1, 2, 3]} 42 | // Call(ctx, "setNumbers", 1, 2, 3) -> {"method": "setNumbers", "params": [1, 2, 3]} 43 | // Call(ctx, "savePerson", &Person{Name: "Alex", Age: 35}) -> {"method": "savePerson", "params": {"name": "Alex", "age": 35}} 44 | // Call(ctx, "setPersonDetails", "Alex", 35, "Germany") -> {"method": "setPersonDetails", "params": ["Alex", 35, "Germany"}} 45 | // 46 | // for more information, see the examples or the unit tests 47 | Call(ctx context.Context, method string, params ...interface{}) (*RPCResponse, error) 48 | 49 | // CallRaw is like Call() but without magic in the requests.Params field. 50 | // The RPCRequest object is sent exactly as you provide it. 51 | // See docs: NewRequest, RPCRequest, Params() 52 | // 53 | // It is recommended to first consider Call() and CallFor() 54 | CallRaw(ctx context.Context, request *RPCRequest) (*RPCResponse, error) 55 | 56 | // CallFor is a very handy function to send a JSON-RPC request to the server endpoint 57 | // and directly specify an object to store the response. 58 | // 59 | // out: will store the unmarshaled object, if request was successful. 60 | // should always be provided by references. can be nil even on success. 61 | // the behaviour is the same as expected from json.Unmarshal() 62 | // 63 | // method and params: see Call() function 64 | // 65 | // if the request was not successful (network, http error) or the rpc response returns an error, 66 | // an error is returned. if it was an JSON-RPC error it can be casted 67 | // to *RPCError. 68 | // 69 | CallFor(ctx context.Context, out interface{}, method string, params ...interface{}) error 70 | 71 | // CallBatch invokes a list of RPCRequests in a single batch request. 72 | // 73 | // Most convenient is to use the following form: 74 | // CallBatch(ctx, RPCRequests{ 75 | // NewRequest("myMethod1", 1, 2, 3), 76 | // NewRequest("myMethod2", "Test"), 77 | // }) 78 | // 79 | // You can create the []*RPCRequest array yourself, but it is not recommended and you should notice the following: 80 | // - field Params is sent as provided, so Params: 2 forms an invalid json (correct would be Params: []int{2}) 81 | // - you can use the helper function Params(1, 2, 3) to use the same format as in Call() 82 | // - field JSONRPC is overwritten and set to value: "2.0" 83 | // - field ID is overwritten and set incrementally and maps to the array position (e.g. requests[5].ID == 5) 84 | // 85 | // 86 | // Returns RPCResponses that is of type []*RPCResponse 87 | // - note that a list of RPCResponses can be received unordered so it can happen that: responses[i] != responses[i].ID 88 | // - RPCPersponses is enriched with helper functions e.g.: responses.HasError() returns true if one of the responses holds an RPCError 89 | CallBatch(ctx context.Context, requests RPCRequests) (RPCResponses, error) 90 | 91 | // CallBatchRaw invokes a list of RPCRequests in a single batch request. 92 | // It sends the RPCRequests parameter is it passed (no magic, no id autoincrement). 93 | // 94 | // Consider to use CallBatch() instead except you have some good reason not to. 95 | // 96 | // CallBatchRaw(ctx, RPCRequests{ 97 | // &RPCRequest{ 98 | // ID: 123, // this won't be replaced in CallBatchRaw 99 | // JSONRPC: "wrong", // this won't be replaced in CallBatchRaw 100 | // Method: "myMethod1", 101 | // Params: []int{1}, // there is no magic, be sure to only use array or object 102 | // }, 103 | // &RPCRequest{ 104 | // ID: 612, 105 | // JSONRPC: "2.0", 106 | // Method: "myMethod2", 107 | // Params: Params("Alex", 35, true), // you can use helper function Params() (see doc) 108 | // }, 109 | // }) 110 | // 111 | // Returns RPCResponses that is of type []*RPCResponse 112 | // - note that a list of RPCResponses can be received unordered 113 | // - the id's must be mapped against the id's you provided 114 | // - RPCPersponses is enriched with helper functions e.g.: responses.HasError() returns true if one of the responses holds an RPCError 115 | CallBatchRaw(ctx context.Context, requests RPCRequests) (RPCResponses, error) 116 | } 117 | 118 | // RPCRequest represents a JSON-RPC request object. 119 | // 120 | // Method: string containing the method to be invoked 121 | // 122 | // Params: can be nil. if not must be an json array or object 123 | // 124 | // ID: may always be set to 0 (default can be changed) for single requests. Should be unique for every request in one batch request. 125 | // 126 | // JSONRPC: must always be set to "2.0" for JSON-RPC version 2.0 127 | // 128 | // See: http://www.jsonrpc.org/specification#request_object 129 | // 130 | // Most of the time you shouldn't create the RPCRequest object yourself. 131 | // The following functions do that for you: 132 | // Call(), CallFor(), NewRequest() 133 | // 134 | // If you want to create it yourself (e.g. in batch or CallRaw()), consider using Params(). 135 | // Params() is a helper function that uses the same parameter syntax as Call(). 136 | // 137 | // e.g. to manually create an RPCRequest object: 138 | // 139 | // request := &RPCRequest{ 140 | // Method: "myMethod", 141 | // Params: Params("Alex", 35, true), 142 | // } 143 | // 144 | // If you know what you are doing you can omit the Params() call to avoid some reflection but potentially create incorrect rpc requests: 145 | // 146 | // request := &RPCRequest{ 147 | // Method: "myMethod", 148 | // Params: 2, <-- invalid since a single primitive value must be wrapped in an array --> no magic without Params() 149 | // } 150 | // 151 | // correct: 152 | // 153 | // request := &RPCRequest{ 154 | // Method: "myMethod", 155 | // Params: []int{2}, <-- invalid since a single primitive value must be wrapped in an array 156 | // } 157 | type RPCRequest struct { 158 | Method string `json:"method"` 159 | Params interface{} `json:"params,omitempty"` 160 | ID int `json:"id"` 161 | JSONRPC string `json:"jsonrpc"` 162 | } 163 | 164 | // NewRequest returns a new RPCRequest that can be created using the same convenient parameter syntax as Call() 165 | // 166 | // Default RPCRequest id is 0. If you want to use an id other than 0, use NewRequestWithID() or set the ID field of the returned RPCRequest manually. 167 | // 168 | // e.g. NewRequest("myMethod", "Alex", 35, true) 169 | func NewRequest(method string, params ...interface{}) *RPCRequest { 170 | request := &RPCRequest{ 171 | Method: method, 172 | Params: Params(params...), 173 | JSONRPC: jsonrpcVersion, 174 | } 175 | 176 | return request 177 | } 178 | 179 | // NewRequestWithID returns a new RPCRequest that can be created using the same convenient parameter syntax as Call() 180 | // 181 | // e.g. NewRequestWithID(123, "myMethod", "Alex", 35, true) 182 | func NewRequestWithID(id int, method string, params ...interface{}) *RPCRequest { 183 | request := &RPCRequest{ 184 | ID: id, 185 | Method: method, 186 | Params: Params(params...), 187 | JSONRPC: jsonrpcVersion, 188 | } 189 | 190 | return request 191 | } 192 | 193 | // RPCResponse represents a JSON-RPC response object. 194 | // 195 | // Result: holds the result of the rpc call if no error occurred, nil otherwise. can be nil even on success. 196 | // 197 | // Error: holds an RPCError object if an error occurred. must be nil on success. 198 | // 199 | // ID: may always be 0 for single requests. is unique for each request in a batch call (see CallBatch()) 200 | // 201 | // JSONRPC: must always be set to "2.0" for JSON-RPC version 2.0 202 | // 203 | // See: http://www.jsonrpc.org/specification#response_object 204 | type RPCResponse struct { 205 | JSONRPC string `json:"jsonrpc"` 206 | Result interface{} `json:"result,omitempty"` 207 | Error *RPCError `json:"error,omitempty"` 208 | ID int `json:"id"` 209 | } 210 | 211 | // RPCError represents a JSON-RPC error object if an RPC error occurred. 212 | // 213 | // Code holds the error code. 214 | // 215 | // Message holds a short error message. 216 | // 217 | // Data holds additional error data, may be nil. 218 | // 219 | // See: http://www.jsonrpc.org/specification#error_object 220 | type RPCError struct { 221 | Code int `json:"code"` 222 | Message string `json:"message"` 223 | Data interface{} `json:"data,omitempty"` 224 | } 225 | 226 | // Error function is provided to be used as error object. 227 | func (e *RPCError) Error() string { 228 | return strconv.Itoa(e.Code) + ": " + e.Message 229 | } 230 | 231 | // HTTPError represents a error that occurred on HTTP level. 232 | // 233 | // An error of type HTTPError is returned when a HTTP error occurred (status code) 234 | // and the body could not be parsed to a valid RPCResponse object that holds a RPCError. 235 | // 236 | // Otherwise a RPCResponse object is returned with a RPCError field that is not nil. 237 | type HTTPError struct { 238 | Code int 239 | err error 240 | } 241 | 242 | // Error function is provided to be used as error object. 243 | func (e *HTTPError) Error() string { 244 | return e.err.Error() 245 | } 246 | 247 | // HTTPClient interface is provided to be used instead of http.Client (e.g. to overload redirect/ retry policy) 248 | type HTTPClient interface { 249 | Do(req *http.Request) (*http.Response, error) 250 | } 251 | 252 | type rpcClient struct { 253 | endpoint string 254 | httpClient HTTPClient 255 | customHeaders map[string]string 256 | allowUnknownFields bool 257 | defaultRequestID int 258 | } 259 | 260 | // RPCClientOpts can be provided to NewClientWithOpts() to change configuration of RPCClient. 261 | // 262 | // HTTPClient: provide a custom http.Client (e.g. to set a proxy, or tls options) 263 | // 264 | // CustomHeaders: provide custom headers, e.g. to set BasicAuth 265 | // 266 | // AllowUnknownFields: allows the rpc response to contain fields that are not defined in the rpc response specification. 267 | type RPCClientOpts struct { 268 | HTTPClient HTTPClient 269 | CustomHeaders map[string]string 270 | AllowUnknownFields bool 271 | DefaultRequestID int 272 | } 273 | 274 | // RPCResponses is of type []*RPCResponse. 275 | // This type is used to provide helper functions on the result list. 276 | type RPCResponses []*RPCResponse 277 | 278 | // AsMap returns the responses as map with response id as key. 279 | func (res RPCResponses) AsMap() map[int]*RPCResponse { 280 | resMap := make(map[int]*RPCResponse, 0) 281 | for _, r := range res { 282 | resMap[r.ID] = r 283 | } 284 | 285 | return resMap 286 | } 287 | 288 | // GetByID returns the response object of the given id, nil if it does not exist. 289 | func (res RPCResponses) GetByID(id int) *RPCResponse { 290 | for _, r := range res { 291 | if r.ID == id { 292 | return r 293 | } 294 | } 295 | 296 | return nil 297 | } 298 | 299 | // HasError returns true if one of the response objects has Error field != nil. 300 | func (res RPCResponses) HasError() bool { 301 | for _, res := range res { 302 | if res.Error != nil { 303 | return true 304 | } 305 | } 306 | return false 307 | } 308 | 309 | // RPCRequests is of type []*RPCRequest. 310 | // This type is used to provide helper functions on the request list. 311 | type RPCRequests []*RPCRequest 312 | 313 | // NewClient returns a new RPCClient instance with default configuration. 314 | // 315 | // endpoint: JSON-RPC service URL to which JSON-RPC requests are sent. 316 | func NewClient(endpoint string) RPCClient { 317 | return NewClientWithOpts(endpoint, nil) 318 | } 319 | 320 | // NewClientWithOpts returns a new RPCClient instance with custom configuration. 321 | // 322 | // endpoint: JSON-RPC service URL to which JSON-RPC requests are sent. 323 | // 324 | // opts: RPCClientOpts is used to provide custom configuration. 325 | func NewClientWithOpts(endpoint string, opts *RPCClientOpts) RPCClient { 326 | rpcClient := &rpcClient{ 327 | endpoint: endpoint, 328 | httpClient: &http.Client{}, 329 | customHeaders: make(map[string]string), 330 | } 331 | 332 | if opts == nil { 333 | return rpcClient 334 | } 335 | 336 | if opts.HTTPClient != nil { 337 | rpcClient.httpClient = opts.HTTPClient 338 | } 339 | 340 | if opts.CustomHeaders != nil { 341 | for k, v := range opts.CustomHeaders { 342 | rpcClient.customHeaders[k] = v 343 | } 344 | } 345 | 346 | if opts.AllowUnknownFields { 347 | rpcClient.allowUnknownFields = true 348 | } 349 | 350 | rpcClient.defaultRequestID = opts.DefaultRequestID 351 | 352 | return rpcClient 353 | } 354 | 355 | func (client *rpcClient) Call(ctx context.Context, method string, params ...interface{}) (*RPCResponse, error) { 356 | 357 | request := &RPCRequest{ 358 | ID: client.defaultRequestID, 359 | Method: method, 360 | Params: Params(params...), 361 | JSONRPC: jsonrpcVersion, 362 | } 363 | 364 | return client.doCall(ctx, request) 365 | } 366 | 367 | func (client *rpcClient) CallRaw(ctx context.Context, request *RPCRequest) (*RPCResponse, error) { 368 | 369 | return client.doCall(ctx, request) 370 | } 371 | 372 | func (client *rpcClient) CallFor(ctx context.Context, out interface{}, method string, params ...interface{}) error { 373 | rpcResponse, err := client.Call(ctx, method, params...) 374 | if err != nil { 375 | return err 376 | } 377 | 378 | if rpcResponse.Error != nil { 379 | return rpcResponse.Error 380 | } 381 | 382 | return rpcResponse.GetObject(out) 383 | } 384 | 385 | func (client *rpcClient) CallBatch(ctx context.Context, requests RPCRequests) (RPCResponses, error) { 386 | if len(requests) == 0 { 387 | return nil, errors.New("empty request list") 388 | } 389 | 390 | for i, req := range requests { 391 | req.ID = i 392 | req.JSONRPC = jsonrpcVersion 393 | } 394 | 395 | return client.doBatchCall(ctx, requests) 396 | } 397 | 398 | func (client *rpcClient) CallBatchRaw(ctx context.Context, requests RPCRequests) (RPCResponses, error) { 399 | if len(requests) == 0 { 400 | return nil, errors.New("empty request list") 401 | } 402 | 403 | return client.doBatchCall(ctx, requests) 404 | } 405 | 406 | func (client *rpcClient) newRequest(ctx context.Context, req interface{}) (*http.Request, error) { 407 | 408 | body, err := json.Marshal(req) 409 | if err != nil { 410 | return nil, err 411 | } 412 | 413 | request, err := http.NewRequestWithContext(ctx, "POST", client.endpoint, bytes.NewReader(body)) 414 | if err != nil { 415 | return nil, err 416 | } 417 | 418 | request.Header.Set("Content-Type", "application/json") 419 | request.Header.Set("Accept", "application/json") 420 | 421 | // set default headers first, so that even content type and accept can be overwritten 422 | for k, v := range client.customHeaders { 423 | // check if header is "Host" since this will be set on the request struct itself 424 | if k == "Host" { 425 | request.Host = v 426 | } else { 427 | request.Header.Set(k, v) 428 | } 429 | } 430 | 431 | return request, nil 432 | } 433 | 434 | func (client *rpcClient) doCall(ctx context.Context, RPCRequest *RPCRequest) (*RPCResponse, error) { 435 | 436 | httpRequest, err := client.newRequest(ctx, RPCRequest) 437 | if err != nil { 438 | return nil, fmt.Errorf("rpc call %v() on %v: %w", RPCRequest.Method, client.endpoint, err) 439 | } 440 | httpResponse, err := client.httpClient.Do(httpRequest) 441 | if err != nil { 442 | return nil, fmt.Errorf("rpc call %v() on %v: %w", RPCRequest.Method, httpRequest.URL.Redacted(), err) 443 | } 444 | defer httpResponse.Body.Close() 445 | 446 | var rpcResponse *RPCResponse 447 | decoder := json.NewDecoder(httpResponse.Body) 448 | if !client.allowUnknownFields { 449 | decoder.DisallowUnknownFields() 450 | } 451 | decoder.UseNumber() 452 | err = decoder.Decode(&rpcResponse) 453 | 454 | // parsing error 455 | if err != nil { 456 | // if we have some http error, return it 457 | if httpResponse.StatusCode >= 400 { 458 | return nil, &HTTPError{ 459 | Code: httpResponse.StatusCode, 460 | err: fmt.Errorf("rpc call %v() on %v status code: %v. could not decode body to rpc response: %w", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode, err), 461 | } 462 | } 463 | return nil, fmt.Errorf("rpc call %v() on %v status code: %v. could not decode body to rpc response: %w", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode, err) 464 | } 465 | 466 | // response body empty 467 | if rpcResponse == nil { 468 | // if we have some http error, return it 469 | if httpResponse.StatusCode >= 400 { 470 | return nil, &HTTPError{ 471 | Code: httpResponse.StatusCode, 472 | err: fmt.Errorf("rpc call %v() on %v status code: %v. rpc response missing", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode), 473 | } 474 | } 475 | return nil, fmt.Errorf("rpc call %v() on %v status code: %v. rpc response missing", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode) 476 | } 477 | 478 | // if we have a response body, but also a http error situation, return both 479 | if httpResponse.StatusCode >= 400 { 480 | if rpcResponse.Error != nil { 481 | return rpcResponse, &HTTPError{ 482 | Code: httpResponse.StatusCode, 483 | err: fmt.Errorf("rpc call %v() on %v status code: %v. rpc response error: %v", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode, rpcResponse.Error), 484 | } 485 | } 486 | return rpcResponse, &HTTPError{ 487 | Code: httpResponse.StatusCode, 488 | err: fmt.Errorf("rpc call %v() on %v status code: %v. no rpc error available", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode), 489 | } 490 | } 491 | 492 | return rpcResponse, nil 493 | } 494 | 495 | func (client *rpcClient) doBatchCall(ctx context.Context, rpcRequest []*RPCRequest) ([]*RPCResponse, error) { 496 | httpRequest, err := client.newRequest(ctx, rpcRequest) 497 | if err != nil { 498 | return nil, fmt.Errorf("rpc batch call on %v: %w", client.endpoint, err) 499 | } 500 | httpResponse, err := client.httpClient.Do(httpRequest) 501 | if err != nil { 502 | return nil, fmt.Errorf("rpc batch call on %v: %w", httpRequest.URL.Redacted(), err) 503 | } 504 | defer httpResponse.Body.Close() 505 | 506 | var rpcResponses RPCResponses 507 | decoder := json.NewDecoder(httpResponse.Body) 508 | if !client.allowUnknownFields { 509 | decoder.DisallowUnknownFields() 510 | } 511 | decoder.UseNumber() 512 | err = decoder.Decode(&rpcResponses) 513 | 514 | // parsing error 515 | if err != nil { 516 | // if we have some http error, return it 517 | if httpResponse.StatusCode >= 400 { 518 | return nil, &HTTPError{ 519 | Code: httpResponse.StatusCode, 520 | err: fmt.Errorf("rpc batch call on %v status code: %v. could not decode body to rpc response: %w", httpRequest.URL.Redacted(), httpResponse.StatusCode, err), 521 | } 522 | } 523 | return nil, fmt.Errorf("rpc batch call on %v status code: %v. could not decode body to rpc response: %w", httpRequest.URL.Redacted(), httpResponse.StatusCode, err) 524 | } 525 | 526 | // response body empty 527 | if rpcResponses == nil || len(rpcResponses) == 0 { 528 | // if we have some http error, return it 529 | if httpResponse.StatusCode >= 400 { 530 | return nil, &HTTPError{ 531 | Code: httpResponse.StatusCode, 532 | err: fmt.Errorf("rpc batch call on %v status code: %v. rpc response missing", httpRequest.URL.Redacted(), httpResponse.StatusCode), 533 | } 534 | } 535 | return nil, fmt.Errorf("rpc batch call on %v status code: %v. rpc response missing", httpRequest.URL.Redacted(), httpResponse.StatusCode) 536 | } 537 | 538 | // if we have a response body, but also a http error, return both 539 | if httpResponse.StatusCode >= 400 { 540 | return rpcResponses, &HTTPError{ 541 | Code: httpResponse.StatusCode, 542 | err: fmt.Errorf("rpc batch call on %v status code: %v. check rpc responses for potential rpc error", httpRequest.URL.Redacted(), httpResponse.StatusCode), 543 | } 544 | } 545 | 546 | return rpcResponses, nil 547 | } 548 | 549 | // Params is a helper function that uses the same parameter syntax as Call(). 550 | // But you should consider to always use NewRequest() instead. 551 | // 552 | // e.g. to manually create an RPCRequest object: 553 | // 554 | // request := &RPCRequest{ 555 | // Method: "myMethod", 556 | // Params: Params("Alex", 35, true), 557 | // } 558 | // 559 | // same with new request: 560 | // request := NewRequest("myMethod", "Alex", 35, true) 561 | // 562 | // If you know what you are doing you can omit the Params() call but potentially create incorrect rpc requests: 563 | // 564 | // request := &RPCRequest{ 565 | // Method: "myMethod", 566 | // Params: 2, <-- invalid since a single primitive value must be wrapped in an array --> no magic without Params() 567 | // } 568 | // 569 | // correct: 570 | // 571 | // request := &RPCRequest{ 572 | // Method: "myMethod", 573 | // Params: []int{2}, <-- valid since a single primitive value must be wrapped in an array 574 | // } 575 | func Params(params ...interface{}) interface{} { 576 | var finalParams interface{} 577 | 578 | // if params was nil skip this and p stays nil 579 | if params != nil { 580 | switch len(params) { 581 | case 0: // no parameters were provided, do nothing so finalParam is nil and will be omitted 582 | case 1: // one param was provided, use it directly as is, or wrap primitive types in array 583 | if params[0] != nil { 584 | var typeOf reflect.Type 585 | 586 | // traverse until nil or not a pointer type 587 | for typeOf = reflect.TypeOf(params[0]); typeOf != nil && typeOf.Kind() == reflect.Ptr; typeOf = typeOf.Elem() { 588 | } 589 | 590 | if typeOf != nil { 591 | // now check if we can directly marshal the type or if it must be wrapped in an array 592 | switch typeOf.Kind() { 593 | // for these types we just do nothing, since value of p is already unwrapped from the array params 594 | case reflect.Struct: 595 | finalParams = params[0] 596 | case reflect.Array: 597 | finalParams = params[0] 598 | case reflect.Slice: 599 | // Handle nil slices by converting them to empty arrays for JSON-RPC compliance 600 | if reflect.ValueOf(params[0]).IsNil() { 601 | finalParams = []interface{}{} 602 | } else { 603 | finalParams = params[0] 604 | } 605 | case reflect.Interface: 606 | finalParams = params[0] 607 | case reflect.Map: 608 | // Handle nil maps by converting them to empty objects for JSON-RPC compliance 609 | if reflect.ValueOf(params[0]).IsNil() { 610 | finalParams = map[string]interface{}{} 611 | } else { 612 | finalParams = params[0] 613 | } 614 | default: // everything else must stay in an array (int, string, etc) 615 | finalParams = params 616 | } 617 | } 618 | } else { 619 | finalParams = params 620 | } 621 | default: // if more than one parameter was provided it should be treated as an array 622 | finalParams = params 623 | } 624 | } 625 | 626 | return finalParams 627 | } 628 | 629 | // GetInt converts the rpc response to an int64 and returns it. 630 | // 631 | // If result was not an integer an error is returned. 632 | func (RPCResponse *RPCResponse) GetInt() (int64, error) { 633 | val, ok := RPCResponse.Result.(json.Number) 634 | if !ok { 635 | return 0, fmt.Errorf("could not parse int64 from %s", RPCResponse.Result) 636 | } 637 | 638 | i, err := val.Int64() 639 | if err != nil { 640 | return 0, err 641 | } 642 | 643 | return i, nil 644 | } 645 | 646 | // GetFloat converts the rpc response to float64 and returns it. 647 | // 648 | // If result was not an float64 an error is returned. 649 | func (RPCResponse *RPCResponse) GetFloat() (float64, error) { 650 | val, ok := RPCResponse.Result.(json.Number) 651 | if !ok { 652 | return 0, fmt.Errorf("could not parse float64 from %s", RPCResponse.Result) 653 | } 654 | 655 | f, err := val.Float64() 656 | if err != nil { 657 | return 0, err 658 | } 659 | 660 | return f, nil 661 | } 662 | 663 | // GetBool converts the rpc response to a bool and returns it. 664 | // 665 | // If result was not a bool an error is returned. 666 | func (RPCResponse *RPCResponse) GetBool() (bool, error) { 667 | val, ok := RPCResponse.Result.(bool) 668 | if !ok { 669 | return false, fmt.Errorf("could not parse bool from %s", RPCResponse.Result) 670 | } 671 | 672 | return val, nil 673 | } 674 | 675 | // GetString converts the rpc response to a string and returns it. 676 | // 677 | // If result was not a string an error is returned. 678 | func (RPCResponse *RPCResponse) GetString() (string, error) { 679 | val, ok := RPCResponse.Result.(string) 680 | if !ok { 681 | return "", fmt.Errorf("could not parse string from %s", RPCResponse.Result) 682 | } 683 | 684 | return val, nil 685 | } 686 | 687 | // GetObject converts the rpc response to an arbitrary type. 688 | // 689 | // The function works as you would expect it from json.Unmarshal() 690 | func (RPCResponse *RPCResponse) GetObject(toType interface{}) error { 691 | js, err := json.Marshal(RPCResponse.Result) 692 | if err != nil { 693 | return err 694 | } 695 | 696 | err = json.Unmarshal(js, toType) 697 | if err != nil { 698 | return err 699 | } 700 | 701 | return nil 702 | } 703 | -------------------------------------------------------------------------------- /jsonrpc_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | // needed to retrieve requests that arrived at httpServer for further investigation 16 | var requestChan = make(chan *RequestData, 1) 17 | 18 | // the request datastructure that can be retrieved for test assertions 19 | type RequestData struct { 20 | request *http.Request 21 | body string 22 | } 23 | 24 | // set the response body the httpServer should return for the next request 25 | var responseBody = "" 26 | 27 | var httpStatusCode = http.StatusOK 28 | 29 | var httpServer *httptest.Server 30 | 31 | // start the test-http server and stop it when tests are finished 32 | func TestMain(m *testing.M) { 33 | httpServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | data, _ := ioutil.ReadAll(r.Body) 35 | defer r.Body.Close() 36 | // put request and body to channel for the client to investigate them 37 | requestChan <- &RequestData{r, string(data)} 38 | 39 | w.WriteHeader(httpStatusCode) 40 | fmt.Fprintf(w, responseBody) 41 | })) 42 | defer httpServer.Close() 43 | 44 | os.Exit(m.Run()) 45 | } 46 | 47 | func TestSimpleRpcCallHeaderCorrect(t *testing.T) { 48 | check := assert.New(t) 49 | 50 | rpcClient := NewClient(httpServer.URL) 51 | rpcClient.Call(context.Background(), "add", 1, 2) 52 | 53 | req := (<-requestChan).request 54 | 55 | check.Equal("POST", req.Method) 56 | check.Equal("application/json", req.Header.Get("Content-Type")) 57 | check.Equal("application/json", req.Header.Get("Accept")) 58 | } 59 | 60 | // test if the structure of a rpc request is built correctly by validating the data that arrived at the test server 61 | func TestRpcClient_Call(t *testing.T) { 62 | check := assert.New(t) 63 | 64 | rpcClient := NewClient(httpServer.URL) 65 | 66 | person := Person{ 67 | Name: "Alex", 68 | Age: 35, 69 | Country: "Germany", 70 | } 71 | 72 | drink := Drink{ 73 | Name: "Cuba Libre", 74 | Ingredients: []string{"rum", "cola"}, 75 | } 76 | 77 | rpcClient.Call(context.Background(), "missingParam") 78 | check.Equal(`{"method":"missingParam","id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 79 | 80 | rpcClient.Call(context.Background(), "nullParam", nil) 81 | check.Equal(`{"method":"nullParam","params":[null],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 82 | 83 | rpcClient.Call(context.Background(), "nullParams", nil, nil) 84 | check.Equal(`{"method":"nullParams","params":[null,null],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 85 | 86 | rpcClient.Call(context.Background(), "emptyParams", []interface{}{}) 87 | check.Equal(`{"method":"emptyParams","params":[],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 88 | 89 | rpcClient.Call(context.Background(), "emptyAnyParams", []string{}) 90 | check.Equal(`{"method":"emptyAnyParams","params":[],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 91 | 92 | rpcClient.Call(context.Background(), "emptyObject", struct{}{}) 93 | check.Equal(`{"method":"emptyObject","params":{},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 94 | 95 | rpcClient.Call(context.Background(), "emptyObjectList", []struct{}{{}, {}}) 96 | check.Equal(`{"method":"emptyObjectList","params":[{},{}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 97 | 98 | rpcClient.Call(context.Background(), "boolParam", true) 99 | check.Equal(`{"method":"boolParam","params":[true],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 100 | 101 | rpcClient.Call(context.Background(), "boolParams", true, false, true) 102 | check.Equal(`{"method":"boolParams","params":[true,false,true],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 103 | 104 | rpcClient.Call(context.Background(), "stringParam", "Alex") 105 | check.Equal(`{"method":"stringParam","params":["Alex"],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 106 | 107 | rpcClient.Call(context.Background(), "stringParams", "JSON", "RPC") 108 | check.Equal(`{"method":"stringParams","params":["JSON","RPC"],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 109 | 110 | rpcClient.Call(context.Background(), "numberParam", 123) 111 | check.Equal(`{"method":"numberParam","params":[123],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 112 | 113 | rpcClient.Call(context.Background(), "numberParams", 123, 321) 114 | check.Equal(`{"method":"numberParams","params":[123,321],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 115 | 116 | rpcClient.Call(context.Background(), "floatParam", 1.23) 117 | check.Equal(`{"method":"floatParam","params":[1.23],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 118 | 119 | rpcClient.Call(context.Background(), "floatParams", 1.23, 3.21) 120 | check.Equal(`{"method":"floatParams","params":[1.23,3.21],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 121 | 122 | rpcClient.Call(context.Background(), "manyParams", "Alex", 35, true, nil, 2.34) 123 | check.Equal(`{"method":"manyParams","params":["Alex",35,true,null,2.34],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 124 | 125 | rpcClient.Call(context.Background(), "emptyMissingPublicFieldObject", struct{ name string }{name: "Alex"}) 126 | check.Equal(`{"method":"emptyMissingPublicFieldObject","params":{},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 127 | 128 | rpcClient.Call(context.Background(), "singleStruct", person) 129 | check.Equal(`{"method":"singleStruct","params":{"name":"Alex","age":35,"country":"Germany"},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 130 | 131 | rpcClient.Call(context.Background(), "singlePointerToStruct", &person) 132 | check.Equal(`{"method":"singlePointerToStruct","params":{"name":"Alex","age":35,"country":"Germany"},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 133 | 134 | pp := &person 135 | rpcClient.Call(context.Background(), "doublePointerStruct", &pp) 136 | check.Equal(`{"method":"doublePointerStruct","params":{"name":"Alex","age":35,"country":"Germany"},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 137 | 138 | rpcClient.Call(context.Background(), "multipleStructs", person, &drink) 139 | check.Equal(`{"method":"multipleStructs","params":[{"name":"Alex","age":35,"country":"Germany"},{"name":"Cuba Libre","ingredients":["rum","cola"]}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 140 | 141 | rpcClient.Call(context.Background(), "singleStructInArray", []interface{}{person}) 142 | check.Equal(`{"method":"singleStructInArray","params":[{"name":"Alex","age":35,"country":"Germany"}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 143 | 144 | rpcClient.Call(context.Background(), "namedParameters", map[string]interface{}{ 145 | "name": "Alex", 146 | "age": 35, 147 | }) 148 | check.Equal(`{"method":"namedParameters","params":{"age":35,"name":"Alex"},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 149 | 150 | rpcClient.Call(context.Background(), "anonymousStructNoTags", struct { 151 | Name string 152 | Age int 153 | }{"Alex", 33}) 154 | check.Equal(`{"method":"anonymousStructNoTags","params":{"Name":"Alex","Age":33},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 155 | 156 | rpcClient.Call(context.Background(), "anonymousStructWithTags", struct { 157 | Name string `json:"name"` 158 | Age int `json:"age"` 159 | }{"Alex", 33}) 160 | check.Equal(`{"method":"anonymousStructWithTags","params":{"name":"Alex","age":33},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 161 | 162 | rpcClient.Call(context.Background(), "structWithNullField", struct { 163 | Name string `json:"name"` 164 | Address *string `json:"address"` 165 | }{"Alex", nil}) 166 | check.Equal(`{"method":"structWithNullField","params":{"name":"Alex","address":null},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 167 | 168 | rpcClient.Call(context.Background(), "nestedStruct", 169 | Planet{ 170 | Name: "Mars", 171 | Properties: Properties{ 172 | Distance: 54600000, 173 | Color: "red", 174 | }, 175 | }) 176 | check.Equal(`{"method":"nestedStruct","params":{"name":"Mars","properties":{"distance":54600000,"color":"red"}},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 177 | 178 | // test nil slice handling for JSON-RPC compliance 179 | var nilSlice []int = nil 180 | rpcClient.Call(context.Background(), "nilSliceParam", nilSlice) 181 | check.Equal(`{"method":"nilSliceParam","params":[],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 182 | 183 | // test nil map handling for JSON-RPC compliance 184 | var nilMap map[string]interface{} = nil 185 | rpcClient.Call(context.Background(), "nilMapParam", nilMap) 186 | check.Equal(`{"method":"nilMapParam","params":{},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 187 | 188 | // test empty slice 189 | emptySlice := []int{} 190 | rpcClient.Call(context.Background(), "emptySliceParam", emptySlice) 191 | check.Equal(`{"method":"emptySliceParam","params":[],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 192 | 193 | // test empty map 194 | emptyMap := map[string]interface{}{} 195 | rpcClient.Call(context.Background(), "emptyMapParam", emptyMap) 196 | check.Equal(`{"method":"emptyMapParam","params":{},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 197 | 198 | // test nil slice of strings 199 | var nilStringSlice []string = nil 200 | rpcClient.Call(context.Background(), "nilStringSliceParam", nilStringSlice) 201 | check.Equal(`{"method":"nilStringSliceParam","params":[],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 202 | 203 | // test nil map with string keys 204 | var nilStringMap map[string]string = nil 205 | rpcClient.Call(context.Background(), "nilStringMapParam", nilStringMap) 206 | check.Equal(`{"method":"nilStringMapParam","params":{},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) 207 | } 208 | 209 | func TestRpcClient_CallBatch(t *testing.T) { 210 | check := assert.New(t) 211 | 212 | rpcClient := NewClient(httpServer.URL) 213 | 214 | person := Person{ 215 | Name: "Alex", 216 | Age: 35, 217 | Country: "Germany", 218 | } 219 | 220 | drink := Drink{ 221 | Name: "Cuba Libre", 222 | Ingredients: []string{"rum", "cola"}, 223 | } 224 | 225 | // invalid parameters are possible by manually defining *RPCRequest 226 | rpcClient.CallBatch(context.Background(), RPCRequests{ 227 | { 228 | Method: "singleRequest", 229 | Params: 3, // invalid, should be []int{3} 230 | }, 231 | }) 232 | check.Equal(`[{"method":"singleRequest","params":3,"id":0,"jsonrpc":"2.0"}]`, (<-requestChan).body) 233 | 234 | // better use Params() unless you know what you are doing 235 | rpcClient.CallBatch(context.Background(), RPCRequests{ 236 | { 237 | Method: "singleRequest", 238 | Params: Params(3), // always valid json rpc 239 | }, 240 | }) 241 | check.Equal(`[{"method":"singleRequest","params":[3],"id":0,"jsonrpc":"2.0"}]`, (<-requestChan).body) 242 | 243 | // even better, use NewRequest() 244 | rpcClient.CallBatch(context.Background(), RPCRequests{ 245 | NewRequest("multipleRequests1", 1), 246 | NewRequest("multipleRequests2", 2), 247 | NewRequest("multipleRequests3", 3), 248 | }) 249 | check.Equal(`[{"method":"multipleRequests1","params":[1],"id":0,"jsonrpc":"2.0"},{"method":"multipleRequests2","params":[2],"id":1,"jsonrpc":"2.0"},{"method":"multipleRequests3","params":[3],"id":2,"jsonrpc":"2.0"}]`, (<-requestChan).body) 250 | 251 | // test a huge batch request 252 | requests := RPCRequests{ 253 | NewRequest("nullParam", nil), 254 | NewRequest("nullParams", nil, nil), 255 | NewRequest("emptyParams", []interface{}{}), 256 | NewRequest("emptyAnyParams", []string{}), 257 | NewRequest("emptyObject", struct{}{}), 258 | NewRequest("emptyObjectList", []struct{}{{}, {}}), 259 | NewRequest("boolParam", true), 260 | NewRequest("boolParams", true, false, true), 261 | NewRequest("stringParam", "Alex"), 262 | NewRequest("stringParams", "JSON", "RPC"), 263 | NewRequest("numberParam", 123), 264 | NewRequest("numberParams", 123, 321), 265 | NewRequest("floatParam", 1.23), 266 | NewRequest("floatParams", 1.23, 3.21), 267 | NewRequest("manyParams", "Alex", 35, true, nil, 2.34), 268 | NewRequest("emptyMissingPublicFieldObject", struct{ name string }{name: "Alex"}), 269 | NewRequest("singleStruct", person), 270 | NewRequest("singlePointerToStruct", &person), 271 | NewRequest("multipleStructs", person, &drink), 272 | NewRequest("singleStructInArray", []interface{}{person}), 273 | NewRequest("namedParameters", map[string]interface{}{ 274 | "name": "Alex", 275 | "age": 35, 276 | }), 277 | NewRequest("anonymousStructNoTags", struct { 278 | Name string 279 | Age int 280 | }{"Alex", 33}), 281 | NewRequest("anonymousStructWithTags", struct { 282 | Name string `json:"name"` 283 | Age int `json:"age"` 284 | }{"Alex", 33}), 285 | NewRequest("structWithNullField", struct { 286 | Name string `json:"name"` 287 | Address *string `json:"address"` 288 | }{"Alex", nil}), 289 | } 290 | rpcClient.CallBatch(context.Background(), requests) 291 | 292 | check.Equal(`[{"method":"nullParam","params":[null],"id":0,"jsonrpc":"2.0"},`+ 293 | `{"method":"nullParams","params":[null,null],"id":1,"jsonrpc":"2.0"},`+ 294 | `{"method":"emptyParams","params":[],"id":2,"jsonrpc":"2.0"},`+ 295 | `{"method":"emptyAnyParams","params":[],"id":3,"jsonrpc":"2.0"},`+ 296 | `{"method":"emptyObject","params":{},"id":4,"jsonrpc":"2.0"},`+ 297 | `{"method":"emptyObjectList","params":[{},{}],"id":5,"jsonrpc":"2.0"},`+ 298 | `{"method":"boolParam","params":[true],"id":6,"jsonrpc":"2.0"},`+ 299 | `{"method":"boolParams","params":[true,false,true],"id":7,"jsonrpc":"2.0"},`+ 300 | `{"method":"stringParam","params":["Alex"],"id":8,"jsonrpc":"2.0"},`+ 301 | `{"method":"stringParams","params":["JSON","RPC"],"id":9,"jsonrpc":"2.0"},`+ 302 | `{"method":"numberParam","params":[123],"id":10,"jsonrpc":"2.0"},`+ 303 | `{"method":"numberParams","params":[123,321],"id":11,"jsonrpc":"2.0"},`+ 304 | `{"method":"floatParam","params":[1.23],"id":12,"jsonrpc":"2.0"},`+ 305 | `{"method":"floatParams","params":[1.23,3.21],"id":13,"jsonrpc":"2.0"},`+ 306 | `{"method":"manyParams","params":["Alex",35,true,null,2.34],"id":14,"jsonrpc":"2.0"},`+ 307 | `{"method":"emptyMissingPublicFieldObject","params":{},"id":15,"jsonrpc":"2.0"},`+ 308 | `{"method":"singleStruct","params":{"name":"Alex","age":35,"country":"Germany"},"id":16,"jsonrpc":"2.0"},`+ 309 | `{"method":"singlePointerToStruct","params":{"name":"Alex","age":35,"country":"Germany"},"id":17,"jsonrpc":"2.0"},`+ 310 | `{"method":"multipleStructs","params":[{"name":"Alex","age":35,"country":"Germany"},{"name":"Cuba Libre","ingredients":["rum","cola"]}],"id":18,"jsonrpc":"2.0"},`+ 311 | `{"method":"singleStructInArray","params":[{"name":"Alex","age":35,"country":"Germany"}],"id":19,"jsonrpc":"2.0"},`+ 312 | `{"method":"namedParameters","params":{"age":35,"name":"Alex"},"id":20,"jsonrpc":"2.0"},`+ 313 | `{"method":"anonymousStructNoTags","params":{"Name":"Alex","Age":33},"id":21,"jsonrpc":"2.0"},`+ 314 | `{"method":"anonymousStructWithTags","params":{"name":"Alex","age":33},"id":22,"jsonrpc":"2.0"},`+ 315 | `{"method":"structWithNullField","params":{"name":"Alex","address":null},"id":23,"jsonrpc":"2.0"}]`, (<-requestChan).body) 316 | 317 | // create batch manually 318 | requests = []*RPCRequest{ 319 | { 320 | Method: "myMethod1", 321 | Params: []int{1}, 322 | ID: 123, // will be forced to requests[i].ID == i unless you use CallBatchRaw 323 | JSONRPC: "7.0", // will be forced to "2.0" unless you use CallBatchRaw 324 | }, 325 | { 326 | Method: "myMethod2", 327 | Params: &person, 328 | ID: 321, // will be forced to requests[i].ID == i unless you use CallBatchRaw 329 | JSONRPC: "wrong", // will be forced to "2.0" unless you use CallBatchRaw 330 | }, 331 | } 332 | rpcClient.CallBatch(context.Background(), requests) 333 | 334 | check.Equal(`[{"method":"myMethod1","params":[1],"id":0,"jsonrpc":"2.0"},`+ 335 | `{"method":"myMethod2","params":{"name":"Alex","age":35,"country":"Germany"},"id":1,"jsonrpc":"2.0"}]`, (<-requestChan).body) 336 | 337 | // use raw batch 338 | requests = []*RPCRequest{ 339 | { 340 | Method: "myMethod1", 341 | Params: []int{1}, 342 | ID: 123, 343 | JSONRPC: "7.0", 344 | }, 345 | { 346 | Method: "myMethod2", 347 | Params: &person, 348 | ID: 321, 349 | JSONRPC: "wrong", 350 | }, 351 | } 352 | rpcClient.CallBatchRaw(context.Background(), requests) 353 | 354 | check.Equal(`[{"method":"myMethod1","params":[1],"id":123,"jsonrpc":"7.0"},`+ 355 | `{"method":"myMethod2","params":{"name":"Alex","age":35,"country":"Germany"},"id":321,"jsonrpc":"wrong"}]`, (<-requestChan).body) 356 | } 357 | 358 | // test if the result of a rpc request is parsed correctly and if errors are thrown correctly 359 | func TestRpcJsonResponseStruct(t *testing.T) { 360 | check := assert.New(t) 361 | 362 | rpcClient := NewClient(httpServer.URL) 363 | 364 | // empty return body is an error 365 | responseBody = `` 366 | res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) 367 | <-requestChan 368 | check.NotNil(err) 369 | check.Nil(res) 370 | 371 | // not a json body is an error 372 | responseBody = `{ "not": "a", "json": "object"` 373 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 374 | <-requestChan 375 | check.NotNil(err) 376 | check.Nil(res) 377 | 378 | // field "anotherField" not allowed in rpc response is an error 379 | responseBody = `{ "anotherField": "norpc"}` 380 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 381 | <-requestChan 382 | check.NotNil(err) 383 | check.Nil(res) 384 | 385 | // result null is ok 386 | responseBody = `{"result": null}` 387 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 388 | <-requestChan 389 | check.Nil(err) 390 | check.Nil(res.Result) 391 | check.Nil(res.Error) 392 | 393 | // error null is ok 394 | responseBody = `{"error": null}` 395 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 396 | <-requestChan 397 | check.Nil(err) 398 | check.Nil(res.Result) 399 | check.Nil(res.Error) 400 | 401 | // result and error null is ok 402 | responseBody = `{"result": null, "error": null}` 403 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 404 | <-requestChan 405 | check.Nil(err) 406 | check.Nil(res.Result) 407 | check.Nil(res.Error) 408 | 409 | // result string is ok 410 | responseBody = `{"result": "ok"}` 411 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 412 | <-requestChan 413 | check.Nil(err) 414 | check.Equal("ok", res.Result) 415 | 416 | // result with error null is ok 417 | responseBody = `{"result": "ok", "error": null}` 418 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 419 | <-requestChan 420 | check.Nil(err) 421 | check.Equal("ok", res.Result) 422 | 423 | // error with result null is ok 424 | responseBody = `{"error": {"code": 123, "message": "something wrong"}, "result": null}` 425 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 426 | <-requestChan 427 | check.Nil(err) 428 | check.Nil(res.Result) 429 | check.Equal(123, res.Error.Code) 430 | check.Equal("something wrong", res.Error.Message) 431 | 432 | // error with code and message is ok 433 | responseBody = `{ "error": {"code": 123, "message": "something wrong"}}` 434 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 435 | <-requestChan 436 | check.Nil(err) 437 | check.Nil(res.Result) 438 | check.Equal(123, res.Error.Code) 439 | check.Equal("something wrong", res.Error.Message) 440 | 441 | // check results 442 | 443 | // should return int correctly 444 | responseBody = `{ "result": 1 }` 445 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 446 | <-requestChan 447 | check.Nil(err) 448 | check.Nil(res.Error) 449 | i, err := res.GetInt() 450 | check.Nil(err) 451 | check.Equal(int64(1), i) 452 | 453 | // error on not int 454 | i = 3 455 | responseBody = `{ "result": "notAnInt" }` 456 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 457 | <-requestChan 458 | check.Nil(err) 459 | check.Nil(res.Error) 460 | i, err = res.GetInt() 461 | check.NotNil(err) 462 | check.Equal(int64(0), i) 463 | 464 | // error on not int but float 465 | i = 3 466 | responseBody = `{ "result": 1.234 }` 467 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 468 | <-requestChan 469 | check.Nil(err) 470 | check.Nil(res.Error) 471 | i, err = res.GetInt() 472 | check.NotNil(err) 473 | check.Equal(int64(0), i) 474 | 475 | // error on result null 476 | i = 3 477 | responseBody = `{ "result": null }` 478 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 479 | <-requestChan 480 | check.Nil(err) 481 | check.Nil(res.Error) 482 | i, err = res.GetInt() 483 | check.NotNil(err) 484 | check.Equal(int64(0), i) 485 | 486 | b := false 487 | responseBody = `{ "result": true }` 488 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 489 | <-requestChan 490 | check.Nil(err) 491 | check.Nil(res.Error) 492 | b, err = res.GetBool() 493 | check.Nil(err) 494 | check.Equal(true, b) 495 | 496 | b = true 497 | responseBody = `{ "result": 123 }` 498 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 499 | <-requestChan 500 | check.Nil(err) 501 | check.Nil(res.Error) 502 | b, err = res.GetBool() 503 | check.NotNil(err) 504 | check.Equal(false, b) 505 | 506 | responseBody = `{ "result": "string" }` 507 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 508 | <-requestChan 509 | check.Nil(err) 510 | check.Nil(res.Error) 511 | str, err := res.GetString() 512 | check.Nil(err) 513 | check.Equal("string", str) 514 | 515 | responseBody = `{ "result": 1.234 }` 516 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 517 | <-requestChan 518 | check.Nil(err) 519 | check.Nil(res.Error) 520 | str, err = res.GetString() 521 | check.NotNil(err) 522 | check.Equal("", str) 523 | 524 | responseBody = `{ "result": 1.234 }` 525 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 526 | <-requestChan 527 | check.Nil(err) 528 | check.Nil(res.Error) 529 | f, err := res.GetFloat() 530 | check.Nil(err) 531 | check.Equal(1.234, f) 532 | 533 | responseBody = `{ "result": "notfloat" }` 534 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 535 | <-requestChan 536 | check.Nil(err) 537 | check.Nil(res.Error) 538 | f, err = res.GetFloat() 539 | check.NotNil(err) 540 | check.Equal(0.0, f) 541 | 542 | var p *Person 543 | responseBody = `{ "result": {"name": "Alex", "age": 35, "anotherField": "something"} }` 544 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 545 | <-requestChan 546 | check.Nil(err) 547 | check.Nil(res.Error) 548 | err = res.GetObject(&p) 549 | check.Nil(err) 550 | check.Equal("Alex", p.Name) 551 | check.Equal(35, p.Age) 552 | check.Equal("", p.Country) 553 | 554 | // TODO: How to check if result could be parsed or if it is default? 555 | p = nil 556 | responseBody = `{ "result": {"anotherField": "something"} }` 557 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 558 | <-requestChan 559 | check.Nil(err) 560 | check.Nil(res.Error) 561 | err = res.GetObject(&p) 562 | check.Nil(err) 563 | check.NotNil(p) 564 | 565 | var pp *PointerFieldPerson 566 | responseBody = `{ "result": {"anotherField": "something", "country": "Germany"} }` 567 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 568 | <-requestChan 569 | check.Nil(err) 570 | check.Nil(res.Error) 571 | err = res.GetObject(&pp) 572 | check.Nil(err) 573 | check.Nil(pp.Name) 574 | check.Nil(pp.Age) 575 | check.Equal("Germany", *pp.Country) 576 | 577 | p = nil 578 | responseBody = `{ "result": null }` 579 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 580 | <-requestChan 581 | check.Nil(err) 582 | check.Nil(res.Error) 583 | err = res.GetObject(&p) 584 | check.Nil(err) 585 | check.Nil(p) 586 | 587 | // passing nil is an error 588 | p = nil 589 | responseBody = `{ "result": null }` 590 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 591 | <-requestChan 592 | check.Nil(err) 593 | check.Nil(res.Error) 594 | err = res.GetObject(p) 595 | check.NotNil(err) 596 | check.Nil(p) 597 | 598 | p2 := &Person{ 599 | Name: "Alex", 600 | } 601 | responseBody = `{ "result": null }` 602 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 603 | <-requestChan 604 | check.Nil(err) 605 | check.Nil(res.Error) 606 | err = res.GetObject(&p2) 607 | check.Nil(err) 608 | check.Nil(p2) 609 | 610 | p2 = &Person{ 611 | Name: "Alex", 612 | } 613 | responseBody = `{ "result": {"age": 35} }` 614 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 615 | <-requestChan 616 | check.Nil(err) 617 | check.Nil(res.Error) 618 | err = res.GetObject(p2) 619 | check.Nil(err) 620 | check.Equal("Alex", p2.Name) 621 | check.Equal(35, p2.Age) 622 | 623 | // prefilled struct is kept on no result 624 | p3 := Person{ 625 | Name: "Alex", 626 | } 627 | responseBody = `{ "result": null }` 628 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 629 | <-requestChan 630 | check.Nil(err) 631 | check.Nil(res.Error) 632 | err = res.GetObject(&p3) 633 | check.Nil(err) 634 | check.Equal("Alex", p3.Name) 635 | 636 | // prefilled struct is extended / overwritten 637 | p3 = Person{ 638 | Name: "Alex", 639 | Age: 123, 640 | } 641 | responseBody = `{ "result": {"age": 35, "country": "Germany"} }` 642 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 643 | <-requestChan 644 | check.Nil(err) 645 | check.Nil(res.Error) 646 | err = res.GetObject(&p3) 647 | check.Nil(err) 648 | check.Equal("Alex", p3.Name) 649 | check.Equal(35, p3.Age) 650 | check.Equal("Germany", p3.Country) 651 | 652 | // nil is an error 653 | responseBody = `{ "result": {"age": 35} }` 654 | res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) 655 | <-requestChan 656 | check.Nil(err) 657 | check.Nil(res.Error) 658 | err = res.GetObject(nil) 659 | check.NotNil(err) 660 | } 661 | 662 | func TestRpcClientOptions(t *testing.T) { 663 | check := assert.New(t) 664 | 665 | t.Run("allowUnknownFields false should return error on unknown field", func(t *testing.T) { 666 | rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{AllowUnknownFields: false}) 667 | 668 | // unknown field should cause error 669 | responseBody = `{ "result": 1, "unknown_field": 2 }` 670 | res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) 671 | <-requestChan 672 | check.NotNil(err) 673 | check.Nil(res) 674 | }) 675 | 676 | t.Run("allowUnknownFields true should not return error on unknown field", func(t *testing.T) { 677 | rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{AllowUnknownFields: true}) 678 | 679 | // unknown field should not cause error now 680 | responseBody = `{ "result": 1, "unknown_field": 2 }` 681 | res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) 682 | <-requestChan 683 | check.Nil(err) 684 | check.NotNil(res) 685 | }) 686 | 687 | t.Run("customheaders should be added to request", func(t *testing.T) { 688 | rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{ 689 | CustomHeaders: map[string]string{ 690 | "X-Custom-Header": "custom-value", 691 | "X-Custom-Header2": "custom-value2", 692 | }, 693 | }) 694 | 695 | responseBody = `{"result": 1}` 696 | res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) 697 | reqObject := <-requestChan 698 | check.Nil(err) 699 | check.NotNil(res) 700 | check.Equal("custom-value", reqObject.request.Header.Get("X-Custom-Header")) 701 | check.Equal("custom-value2", reqObject.request.Header.Get("X-Custom-Header2")) 702 | }) 703 | 704 | t.Run("host header should be added to request", func(t *testing.T) { 705 | rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{ 706 | CustomHeaders: map[string]string{ 707 | "X-Custom-Header1": "custom-value1", 708 | "Host": "my-host.com", 709 | "X-Custom-Header2": "custom-value2", 710 | }, 711 | }) 712 | 713 | responseBody = `{"result": 1}` 714 | res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) 715 | reqObject := <-requestChan 716 | check.Nil(err) 717 | check.NotNil(res) 718 | check.Equal("custom-value1", reqObject.request.Header.Get("X-Custom-Header1")) 719 | check.Equal("my-host.com", reqObject.request.Host) 720 | check.Equal("custom-value2", reqObject.request.Header.Get("X-Custom-Header2")) 721 | }) 722 | 723 | t.Run("default rpcrequest id should be customized", func(t *testing.T) { 724 | rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{ 725 | DefaultRequestID: 123, 726 | }) 727 | 728 | rpcClient.Call(context.Background(), "myMethod", 1, 2, 3) 729 | check.Equal(`{"method":"myMethod","params":[1,2,3],"id":123,"jsonrpc":"2.0"}`, (<-requestChan).body) 730 | }) 731 | } 732 | 733 | func TestRpcBatchJsonResponseStruct(t *testing.T) { 734 | check := assert.New(t) 735 | 736 | rpcClient := NewClient(httpServer.URL) 737 | 738 | // empty return body is an error 739 | responseBody = `` 740 | res, err := rpcClient.CallBatch(context.Background(), RPCRequests{ 741 | NewRequest("something", 1, 2, 3), 742 | }) 743 | <-requestChan 744 | check.NotNil(err) 745 | check.Nil(res) 746 | 747 | // not a json body is an error 748 | responseBody = `{ "not": "a", "json": "object"` 749 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 750 | NewRequest("something", 1, 2, 3), 751 | }) 752 | <-requestChan 753 | check.NotNil(err) 754 | check.Nil(res) 755 | 756 | // field "anotherField" not allowed in rpc response is an error 757 | responseBody = `{ "anotherField": "norpc"}` 758 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 759 | NewRequest("something", 1, 2, 3), 760 | }) 761 | <-requestChan 762 | check.NotNil(err) 763 | check.Nil(res) 764 | 765 | // result must be wrapped in array on batch request 766 | responseBody = `{"result": null}` 767 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 768 | NewRequest("something", 1, 2, 3), 769 | }) 770 | <-requestChan 771 | check.NotNil(err.Error()) 772 | 773 | // result ok since in array 774 | responseBody = `[{"result": null}]` 775 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 776 | NewRequest("something", 1, 2, 3), 777 | }) 778 | <-requestChan 779 | check.Nil(err) 780 | check.Equal(1, len(res)) 781 | check.Nil(res[0].Result) 782 | 783 | // error null is ok 784 | responseBody = `[{"error": null}]` 785 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 786 | NewRequest("something", 1, 2, 3), 787 | }) 788 | <-requestChan 789 | check.Nil(err) 790 | check.Nil(res[0].Result) 791 | check.Nil(res[0].Error) 792 | 793 | // result and error null is ok 794 | responseBody = `[{"result": null, "error": null}]` 795 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 796 | NewRequest("something", 1, 2, 3), 797 | }) 798 | <-requestChan 799 | check.Nil(err) 800 | check.Nil(res[0].Result) 801 | check.Nil(res[0].Error) 802 | 803 | // result string is ok 804 | responseBody = `[{"result": "ok","id":0}]` 805 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 806 | NewRequest("something", 1, 2, 3), 807 | }) 808 | <-requestChan 809 | check.Nil(err) 810 | check.Equal("ok", res[0].Result) 811 | check.Equal(0, res[0].ID) 812 | 813 | // result with error null is ok 814 | responseBody = `[{"result": "ok", "error": null}]` 815 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 816 | NewRequest("something", 1, 2, 3), 817 | }) 818 | <-requestChan 819 | check.Nil(err) 820 | check.Equal("ok", res[0].Result) 821 | 822 | // error with result null is ok 823 | responseBody = `[{"error": {"code": 123, "message": "something wrong"}, "result": null}]` 824 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 825 | NewRequest("something", 1, 2, 3), 826 | }) 827 | <-requestChan 828 | check.Nil(err) 829 | check.Nil(res[0].Result) 830 | check.Equal(123, res[0].Error.Code) 831 | check.Equal("something wrong", res[0].Error.Message) 832 | 833 | // error with code and message is ok 834 | responseBody = `[{ "error": {"code": 123, "message": "something wrong"}}]` 835 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 836 | NewRequest("something", 1, 2, 3), 837 | }) 838 | <-requestChan 839 | check.Nil(err) 840 | check.Nil(res[0].Result) 841 | check.Equal(123, res[0].Error.Code) 842 | check.Equal("something wrong", res[0].Error.Message) 843 | 844 | // check results 845 | 846 | // should return int correctly 847 | responseBody = `[{ "result": 1 }]` 848 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 849 | NewRequest("something", 1, 2, 3), 850 | }) 851 | <-requestChan 852 | check.Nil(err) 853 | check.Nil(res[0].Error) 854 | i, err := res[0].GetInt() 855 | check.Nil(err) 856 | check.Equal(int64(1), i) 857 | 858 | // error on wrong type 859 | i = 3 860 | responseBody = `[{ "result": "notAnInt" }]` 861 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 862 | NewRequest("something", 1, 2, 3), 863 | }) 864 | <-requestChan 865 | check.Nil(err) 866 | check.Nil(res[0].Error) 867 | i, err = res[0].GetInt() 868 | check.NotNil(err) 869 | check.Equal(int64(0), i) 870 | 871 | var p *Person 872 | responseBody = `[{"id":0, "result": {"name": "Alex", "age": 35}}, {"id":2, "result": {"name": "Lena", "age": 2}}]` 873 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 874 | NewRequest("something", 1, 2, 3), 875 | }) 876 | 877 | <-requestChan 878 | check.Nil(err) 879 | 880 | check.Nil(res[0].Error) 881 | check.Equal(0, res[0].ID) 882 | 883 | check.Nil(res[1].Error) 884 | check.Equal(2, res[1].ID) 885 | 886 | err = res[0].GetObject(&p) 887 | check.Equal("Alex", p.Name) 888 | check.Equal(35, p.Age) 889 | 890 | err = res[1].GetObject(&p) 891 | check.Equal("Lena", p.Name) 892 | check.Equal(2, p.Age) 893 | 894 | // check if error occurred 895 | responseBody = `[{ "result": "someresult", "error": null}, { "result": null, "error": {"code": 123, "message": "something wrong"}}]` 896 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 897 | NewRequest("something", 1, 2, 3), 898 | }) 899 | <-requestChan 900 | check.Nil(err) 901 | check.True(res.HasError()) 902 | 903 | // check if error occurred 904 | responseBody = `[{ "result": null, "error": {"code": 123, "message": "something wrong"}}]` 905 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 906 | NewRequest("something", 1, 2, 3), 907 | }) 908 | <-requestChan 909 | check.Nil(err) 910 | check.True(res.HasError()) 911 | // check if error occurred 912 | responseBody = `[{ "result": null, "error": {"code": 123, "message": "something wrong"}}]` 913 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 914 | NewRequest("something", 1, 2, 3), 915 | }) 916 | <-requestChan 917 | check.Nil(err) 918 | check.True(res.HasError()) 919 | 920 | // check if response mapping works 921 | responseBody = `[{ "id":123,"result": 123},{ "id":1,"result": 1}]` 922 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 923 | NewRequest("something", 1, 2, 3), 924 | }) 925 | <-requestChan 926 | check.Nil(err) 927 | check.False(res.HasError()) 928 | resMap := res.AsMap() 929 | 930 | int1, _ := resMap[1].GetInt() 931 | int123, _ := resMap[123].GetInt() 932 | check.Equal(int64(1), int1) 933 | check.Equal(int64(123), int123) 934 | 935 | // check if getByID works 936 | int123, _ = res.GetByID(123).GetInt() 937 | check.Equal(int64(123), int123) 938 | 939 | // check if missing id returns nil 940 | missingIdRes := res.GetByID(124) 941 | check.Nil(missingIdRes) 942 | 943 | // check if error occurred 944 | responseBody = `[{ "result": null, "error": {"code": 123, "message": "something wrong"}}]` 945 | res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ 946 | NewRequest("something", 1, 2, 3), 947 | }) 948 | <-requestChan 949 | check.Nil(err) 950 | check.True(res.HasError()) 951 | } 952 | 953 | func TestRpcClient_CallFor(t *testing.T) { 954 | check := assert.New(t) 955 | 956 | rpcClient := NewClient(httpServer.URL) 957 | 958 | i := 0 959 | responseBody = `{"result":3,"id":0,"jsonrpc":"2.0"}` 960 | err := rpcClient.CallFor(context.Background(), &i, "something", 1, 2, 3) 961 | <-requestChan 962 | check.Nil(err) 963 | check.Equal(3, i) 964 | } 965 | 966 | func TestErrorHandling(t *testing.T) { 967 | check := assert.New(t) 968 | rpcClient := NewClient(httpServer.URL) 969 | 970 | oldStatusCode := httpStatusCode 971 | oldResponseBody := responseBody 972 | defer func() { 973 | httpStatusCode = oldStatusCode 974 | responseBody = oldResponseBody 975 | }() 976 | 977 | t.Run("check returned rpcerror", func(t *testing.T) { 978 | responseBody = `{"error":{"code":123,"message":"something wrong"}}` 979 | call, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) 980 | <-requestChan 981 | check.Nil(err) 982 | check.NotNil(call.Error) 983 | check.Equal("123: something wrong", call.Error.Error()) 984 | }) 985 | 986 | t.Run("check returned httperror", func(t *testing.T) { 987 | responseBody = `{"error":{"code":123,"message":"something wrong"}}` 988 | httpStatusCode = http.StatusInternalServerError 989 | call, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) 990 | <-requestChan 991 | check.NotNil(err) 992 | check.NotNil(call.Error) 993 | check.Equal("123: something wrong", call.Error.Error()) 994 | check.Contains(err.(*HTTPError).Error(), "status code: 500. rpc response error: 123: something wrong") 995 | }) 996 | } 997 | 998 | type Person struct { 999 | Name string `json:"name"` 1000 | Age int `json:"age"` 1001 | Country string `json:"country"` 1002 | } 1003 | 1004 | type PointerFieldPerson struct { 1005 | Name *string `json:"name"` 1006 | Age *int `json:"age"` 1007 | Country *string `json:"country"` 1008 | } 1009 | 1010 | type Drink struct { 1011 | Name string `json:"name"` 1012 | Ingredients []string `json:"ingredients"` 1013 | } 1014 | 1015 | type Planet struct { 1016 | Name string `json:"name"` 1017 | Properties Properties `json:"properties"` 1018 | } 1019 | 1020 | type Properties struct { 1021 | Distance int `json:"distance"` 1022 | Color string `json:"color"` 1023 | } 1024 | --------------------------------------------------------------------------------