diff --git a/client.go b/client.go index 643dc1c..11e2e83 100644 --- a/client.go +++ b/client.go @@ -15,6 +15,19 @@ type Client struct { *rpc.Client } +// Multicall performs a multicall request. +// `calls` should be constructed with `NewMulticallArg` +// and `outs` must be a slice of pointers. +// If the response contains at least one fault, +// the first is returned as a `MulticallFault` error. +func (c Client) Multicall(calls []MulticallArg, outs ...interface{}) error { + if len(calls) != len(outs) { + return errors.New("lengths of calls and responses are not matching") + } + tmp := multicallOut{calls: calls, datas: outs} + return c.Call("system.multicall", calls, tmp) +} + // clientCodec is rpc.ClientCodec interface implementation. type clientCodec struct { // url presents url of xmlrpc service diff --git a/encoder.go b/encoder.go index 7ab271a..6a1cf95 100644 --- a/encoder.go +++ b/encoder.go @@ -14,8 +14,6 @@ import ( // Base64 represents value in base64 encoding type Base64 string -type encodeFunc func(reflect.Value) ([]byte, error) - func marshal(v interface{}) ([]byte, error) { if v == nil { return []byte{}, nil diff --git a/fixtures/multicall_error.xml b/fixtures/multicall_error.xml new file mode 100644 index 0000000..6bf13dd --- /dev/null +++ b/fixtures/multicall_error.xml @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + label + + Test + + + + orderby + + date + + + + ordertype + + ascending + + + + date + + 1565526014000 + + + + createddate + + 1565526019000 + + + + lastvisitdate + + 1565526459000 + + + + lastfileaddeddate + + 1565526368000 + + + + public + + 0 + + + + allowdownload + + 1 + + + + allowupload + + 0 + + + + allowprintorder + + 1 + + + + allowsendcomments + + 1 + + + + + + + + label + + test2 + + + + orderby + + date + + + + ordertype + + ascending + + + + date + + 1565526625000 + + + + createddate + + 1565526629000 + + + + lastvisitdate + + 1581931485000 + + + + lastfileaddeddate + + 1565526652000 + + + + public + + 0 + + + + allowdownload + + 1 + + + + allowupload + + 0 + + + + allowprintorder + + 1 + + + + allowsendcomments + + 1 + + + + + + + + + + + + + + faultCode + + 205 + + + + faultString + + Error (205) : Can't get properties for the session xxxx + + + + + + + + + + + \ No newline at end of file diff --git a/fixtures/multicall_ok.xml b/fixtures/multicall_ok.xml new file mode 100644 index 0000000..3262ad3 --- /dev/null +++ b/fixtures/multicall_ok.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + nbfiles + + 4 + + + + + + + + + + + + + + nbfiles + + 1 + + + + + + + + + + + + + \ No newline at end of file diff --git a/is_zero.go b/is_zero.go index 65276d0..b3aae8f 100644 --- a/is_zero.go +++ b/is_zero.go @@ -39,6 +39,6 @@ func isZero(v Value) bool { default: // This should never happens, but will act as a safeguard for // later, as a default value doesn't makes sense here. - panic(&ValueError{"reflect.Value.IsZero", v.Kind()}) + panic(&ValueError{Method: "reflect.Value.IsZero", Kind: v.Kind()}) } } diff --git a/multicall.go b/multicall.go new file mode 100644 index 0000000..4bfd1c8 --- /dev/null +++ b/multicall.go @@ -0,0 +1,144 @@ +package xmlrpc + +import ( + "bytes" + "encoding/xml" + "fmt" + "reflect" + "strconv" +) + +// Types used for unmarshalling + +// main fault should be already checked +type response struct { + Name xml.Name `xml:"methodResponse"` + Params []param `xml:"params>param"` +} + +type param struct { + Value value `xml:"value"` +} + +type member struct { + Name string `xml:"name"` + Value value `xml:"value"` +} + +type value struct { + Array []value `xml:"array>data>value"` // used for returns + Struct []member `xml:"struct>member"` // used for fault + String string `xml:"string"` // used for faults + Int string `xml:"int"` // used for faults + Raw []byte `xml:",innerxml"` +} + +// getFaultResponse converts faultValue to Fault. +func getFaultResponse(fault []member) FaultError { + var ( + code int + str string + ) + + for _, field := range fault { + if field.Name == "faultCode" { + code, _ = strconv.Atoi(field.Value.Int) + } else if field.Name == "faultString" { + str = field.Value.String + if str == "" { + str = string(field.Value.Raw) + } + } + } + + return FaultError{Code: code, String: str} +} + +// MulticallFault tracks the position of the fault. +type MulticallFault struct { + FaultError + Index int // 0 based + methodName string // for better message +} + +func (m MulticallFault) Error() string { + return fmt.Sprintf("fault in call %d (%s) : %s", m.Index, m.methodName, m.FaultError.Error()) +} + +func (r Response) unmarshalMulticall(out multicallOut) error { + switch ki := reflect.TypeOf(out.datas).Kind(); ki { + case reflect.Array, reflect.Slice: // OK + default: + return fmt.Errorf("destination for multicall must be Array or Slice, got %s", ki) + } + outSlice := reflect.ValueOf(out.datas) + + parts, err := splitMulticall(r) + if multicallErr, ok := err.(MulticallFault); ok { + multicallErr.methodName = out.calls[multicallErr.Index].MethodName + return multicallErr + } else if err != nil { + return err + } + + if outSlice.Len() != len(parts) { + return fmt.Errorf("invalid number of return destinations : response needs %d, got %d", len(parts), outSlice.Len()) + } + for i, xmlReturn := range parts { + // pointer to one call's destination + elem := outSlice.Index(i).Interface() + + // unmarshal expect a wrapping tag + xmlReturn = append(append([]byte(""), xmlReturn...), ""...) + if err := unmarshal(xmlReturn, elem); err != nil { + return fmt.Errorf("unmarshall number %d failed : %s", i, err.Error()) + } + } + return nil +} + +// returns xml encoded chunks, one for each multicall response +// if there is (at least) one fault, returns the first one +// as error +func splitMulticall(xmlraw []byte) ([][]byte, error) { + // Unmarshal raw XML into the temporal structure + var ret response + + dec := xml.NewDecoder(bytes.NewReader(xmlraw)) + if CharsetReader != nil { + dec.CharsetReader = CharsetReader + } + + if err := dec.Decode(&ret); err != nil { + return nil, err + } + if L := len(ret.Params); L != 1 { + return nil, fmt.Errorf("unexpected number of arguments : got %d", L) + } + // multicall returns one array of values + returns := ret.Params[0].Value.Array + + out := make([][]byte, len(returns)) + for i, oneReturn := range returns { + // multicall return are always wrapped in one-sized array + // otherwise, it's a fault + if len(oneReturn.Array) != 1 { + fault := getFaultResponse(oneReturn.Struct) + return nil, MulticallFault{Index: i, FaultError: fault} + } + // unwrap the value and store raw xml + // to further process + out[i] = oneReturn.Array[0].Raw + } + return out, nil +} + +// MulticallArg stores one call +type MulticallArg struct { + MethodName string `xmlrpc:"methodName"` + Params []interface{} `xmlrpc:"params"` // 1-sized list containing the real arguments +} + +func NewMulticallArg(method string, args interface{}) MulticallArg { + return MulticallArg{MethodName: method, Params: []interface{}{args}} +} diff --git a/multicall_test.go b/multicall_test.go new file mode 100644 index 0000000..45f0f24 --- /dev/null +++ b/multicall_test.go @@ -0,0 +1,75 @@ +package xmlrpc + +import ( + "io/ioutil" + "testing" +) + +func Test_splitMulticall(t *testing.T) { + b, err := ioutil.ReadFile("fixtures/multicall_error.xml") + if err != nil { + t.Fatal(err) + } + _, err = splitMulticall(b) + mf, ok := err.(MulticallFault) + if !ok { + t.Errorf("expected multicall fault, got %s", err) + } + if mf.Index != 1 { + t.Errorf("wrong position for fault %d", mf.Index) + } + + b, err = ioutil.ReadFile("fixtures/multicall_ok.xml") + if err != nil { + t.Fatal(err) + } + out, err := splitMulticall(b) + if err != nil { + t.Error(err) + } + if L := len(out); L != 2 { + t.Errorf("expected 2 answers, got %d", L) + } +} + +func TestUnmarshal(t *testing.T) { + b, err := ioutil.ReadFile("fixtures/multicall_ok.xml") + if err != nil { + t.Fatal(err) + } + calls := make([]MulticallArg, 2) + type data struct { + NbFiles int `xmlrpc:"nbfiles"` + } + var d1, d2 data + out := []interface{}{&d1, &d2} + err = Response(b).unmarshalMulticall(multicallOut{calls: calls, datas: out}) + if err != nil { + t.Error(err) + } + if nb1 := d1.NbFiles; nb1 != 4 { + t.Errorf("expected 4, got %d", nb1) + } + if nb2 := d2.NbFiles; nb2 != 1 { + t.Errorf("expected 4, got %d", nb2) + } + + outArray := [2]interface{}{&d1, &d2} + err = Response(b).unmarshalMulticall(multicallOut{calls: calls, datas: outArray}) + if err != nil { + t.Error(err) + } + if nb1 := d1.NbFiles; nb1 != 4 { + t.Errorf("expected 4, got %d", nb1) + } + if nb2 := d2.NbFiles; nb2 != 1 { + t.Errorf("expected 4, got %d", nb2) + } + + var outWrong string + err = Response(b).unmarshalMulticall(multicallOut{calls: calls, datas: &outWrong}) + if err == nil { + t.Error("expected error") + } + +} diff --git a/response.go b/response.go index 18e6d36..64a3f7d 100644 --- a/response.go +++ b/response.go @@ -33,10 +33,19 @@ func (r Response) Err() error { return fault } +// tmp storage for multicall responses +type multicallOut struct { + calls []MulticallArg // for error messages + datas interface{} // slice/array of pointers +} + func (r Response) Unmarshal(v interface{}) error { + if mc, isMulticall := v.(multicallOut); isMulticall { + return r.unmarshalMulticall(mc) + } + if err := unmarshal(r, v); err != nil { return err } - return nil }