diff --git a/call_opt.go b/call_opt.go index 73fe9c2..851baa5 100644 --- a/call_opt.go +++ b/call_opt.go @@ -19,6 +19,15 @@ func Meta(meta interface{}) CallOption { }) } +// ExtraField returns a call option which attaches the given name/value pair to +// the JSON-RPC 2.0 request. This can be used to add arbitrary extensions to +// JSON RPC 2.0. +func ExtraField(name string, value interface{}) CallOption { + return callOptionFunc(func(r *Request) error { + return r.SetExtraField(name, value) + }) +} + // PickID returns a call option which sets the ID on a request. Care must be // taken to ensure there are no conflicts with any previously picked ID, nor // with the default sequence ID. diff --git a/call_opt_test.go b/call_opt_test.go index 82b05ca..37dc556 100644 --- a/call_opt_test.go +++ b/call_opt_test.go @@ -2,6 +2,7 @@ package jsonrpc2_test import ( "context" + "encoding/json" "fmt" "testing" @@ -90,3 +91,49 @@ func TestStringID(t *testing.T) { t.Fatal(err) } } + +func TestExtraField(t *testing.T) { + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + a, b := inMemoryPeerConns() + defer a.Close() + defer b.Close() + + handler := handlerFunc(func(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) { + replyWithError := func(msg string) { + respErr := &jsonrpc2.Error{Code: jsonrpc2.CodeInvalidRequest, Message: msg} + if err := conn.ReplyWithError(ctx, req.ID, respErr); err != nil { + t.Error(err) + } + } + var sessionId *json.RawMessage + for _, field := range req.ExtraFields { + if field.Name != "sessionId" { + continue + } + sessionId = field.Value + } + if sessionId == nil { + replyWithError("sessionId must be set") + return + } + if string(*sessionId) != `"session"` { + replyWithError("sessionId has the wrong value") + return + } + if err := conn.Reply(ctx, req.ID, "ok"); err != nil { + t.Error(err) + } + }) + connA := jsonrpc2.NewConn(ctx, jsonrpc2.NewBufferedStream(a, jsonrpc2.VSCodeObjectCodec{}), handler) + connB := jsonrpc2.NewConn(ctx, jsonrpc2.NewBufferedStream(b, jsonrpc2.VSCodeObjectCodec{}), noopHandler{}) + defer connA.Close() + defer connB.Close() + + var res string + if err := connB.Call(ctx, "f", nil, &res, jsonrpc2.ExtraField("sessionId", "session")); err != nil { + t.Fatal(err) + } +} diff --git a/jsonrpc2.go b/jsonrpc2.go index 005b65c..0fdb675 100644 --- a/jsonrpc2.go +++ b/jsonrpc2.go @@ -30,6 +30,12 @@ type JSONRPC2 interface { Close() error } +// RequestField is a top-level field that can be added to the JSON-RPC request. +type RequestField struct { + Name string + Value *json.RawMessage +} + // Request represents a JSON-RPC request or // notification. See // http://www.jsonrpc.org/specification#request_object and @@ -45,25 +51,32 @@ type Request struct { // NOTE: It is not part of spec. However, it is useful for propogating // tracing context, etc. Meta *json.RawMessage `json:"meta,omitempty"` + + // ExtraFields optionally adds fields to the root of the JSON-RPC request. + // + // NOTE: It is not part of the spec, but there are other protocols based on + // JSON-RPC 2 that require it. + ExtraFields []RequestField `json:"-"` } // MarshalJSON implements json.Marshaler and adds the "jsonrpc":"2.0" // property. func (r Request) MarshalJSON() ([]byte, error) { - r2 := struct { - Method string `json:"method"` - Params *json.RawMessage `json:"params,omitempty"` - ID *ID `json:"id,omitempty"` - Meta *json.RawMessage `json:"meta,omitempty"` - JSONRPC string `json:"jsonrpc"` - }{ - Method: r.Method, - Params: r.Params, - Meta: r.Meta, - JSONRPC: "2.0", + r2 := map[string]interface{}{ + "jsonrpc": "2.0", + "method": r.Method, + } + for _, field := range r.ExtraFields { + r2[field.Name] = field.Value } if !r.Notif { - r2.ID = &r.ID + r2["id"] = &r.ID + } + if r.Params != nil { + r2["params"] = r.Params + } + if r.Meta != nil { + r2["meta"] = r.Meta } return json.Marshal(r2) } @@ -76,6 +89,8 @@ func (r *Request) UnmarshalJSON(data []byte) error { Meta *json.RawMessage `json:"meta,omitempty"` ID *ID `json:"id"` } + // This is used to get the extra fields, which are not type-safe. + r3 := make(map[string]*json.RawMessage) // Detect if the "params" field is JSON "null" or just not present // by seeing if the field gets overwritten to nil. @@ -84,6 +99,9 @@ func (r *Request) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &r2); err != nil { return err } + if err := json.Unmarshal(data, &r3); err != nil { + return err + } r.Method = r2.Method switch { case r2.Params == nil: @@ -101,6 +119,19 @@ func (r *Request) UnmarshalJSON(data []byte) error { r.ID = *r2.ID r.Notif = false } + + // Clear the extra fields before populating them again. + r.ExtraFields = nil + for name, value := range r3 { + switch name { + case "id", "jsonrpc", "meta", "method", "params": + continue + } + r.ExtraFields = append(r.ExtraFields, RequestField{ + Name: name, + Value: value, + }) + } return nil } @@ -126,6 +157,21 @@ func (r *Request) SetMeta(v interface{}) error { return nil } +// SetExtraField adds an entry to r.ExtraFields, so that it is added to the +// JSON representation of the request, as a way to add arbitrary extensions to +// JSON RPC 2.0. If JSON marshaling fails, it returns an error. +func (r *Request) SetExtraField(name string, v interface{}) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + r.ExtraFields = append(r.ExtraFields, RequestField{ + Name: name, + Value: (*json.RawMessage)(&b), + }) + return nil +} + // Response represents a JSON-RPC response. See // http://www.jsonrpc.org/specification#response_object. type Response struct { diff --git a/jsonrpc2_test.go b/jsonrpc2_test.go index c319a1b..f9dd950 100644 --- a/jsonrpc2_test.go +++ b/jsonrpc2_test.go @@ -24,7 +24,7 @@ func TestRequest_MarshalJSON_jsonrpc(t *testing.T) { if err != nil { t.Fatal(err) } - if want := `{"method":"","id":0,"jsonrpc":"2.0"}`; string(b) != want { + if want := `{"id":0,"jsonrpc":"2.0","method":""}`; string(b) != want { t.Errorf("got %q, want %q", b, want) } } diff --git a/object_test.go b/object_test.go index 2430e3b..686f4c9 100644 --- a/object_test.go +++ b/object_test.go @@ -39,22 +39,27 @@ func TestAnyMessage(t *testing.T) { func TestRequest_MarshalUnmarshalJSON(t *testing.T) { null := json.RawMessage("null") obj := json.RawMessage(`{"foo":"bar"}`) + requestFieldValue := json.RawMessage(`"session"`) tests := []struct { data []byte want Request }{ { - data: []byte(`{"method":"m","params":{"foo":"bar"},"id":123,"jsonrpc":"2.0"}`), + data: []byte(`{"id":123,"jsonrpc":"2.0","method":"m","params":{"foo":"bar"}}`), want: Request{ID: ID{Num: 123}, Method: "m", Params: &obj}, }, { - data: []byte(`{"method":"m","params":null,"id":123,"jsonrpc":"2.0"}`), + data: []byte(`{"id":123,"jsonrpc":"2.0","method":"m","params":null}`), want: Request{ID: ID{Num: 123}, Method: "m", Params: &null}, }, { - data: []byte(`{"method":"m","id":123,"jsonrpc":"2.0"}`), + data: []byte(`{"id":123,"jsonrpc":"2.0","method":"m"}`), want: Request{ID: ID{Num: 123}, Method: "m", Params: nil}, }, + { + data: []byte(`{"id":123,"jsonrpc":"2.0","method":"m","sessionId":"session"}`), + want: Request{ID: ID{Num: 123}, Method: "m", Params: nil, ExtraFields: []RequestField{{Name: "sessionId", Value: &requestFieldValue}}}, + }, } for _, test := range tests { var got Request