Repository: creachadair/jrpc2
Pull request #63 (2021-12-03 01:34:30)

handler: support array arguments in positional wrappers
Extend the generated wrapper for a positional function to accept parameters
encoded as a JSON array of the correct length. When decoding the params, if
names are provided and the value is an array of the correct length, rewrite the
array into an equivalent object with the names mapped.

This works because the positional names preserve the original order, and ensure
that we do not introduce any unknown fields.


Patch:
diff --git a/handler/handler.go b/handler/handler.go
index 5146d1d..b637741 100644
--- a/handler/handler.go
+++ b/handler/handler.go
@@ -116,6 +116,7 @@ type FuncInfo struct {
 	Result       reflect.Type // the non-error result type, or nil
 	ReportsError bool         // true if the function reports an error
 	strictFields bool         // enforce strict field checking
+	posNames     []string     // positional field names (requires strictFields)
 
 	fn interface{} // the original function value
 }
@@ -151,16 +152,20 @@ func (fi *FuncInfo) Wrap() Func {
 	}
 
 	// If strict field checking is desired, ensure arguments are wrapped.
+	arg := fi.Argument
 	wrapArg := func(v reflect.Value) interface{} { return v.Interface() }
-	if fi.strictFields && fi.Argument != nil && !fi.Argument.Implements(strictType) {
-		wrapArg = func(v reflect.Value) interface{} { return &strict{v.Interface()} }
+	if fi.strictFields && arg != nil && !arg.Implements(strictType) {
+		names := fi.posNames
+		wrapArg = func(v reflect.Value) interface{} {
+			return &strict{v: v.Interface(), posNames: names}
+		}
 	}
 
 	// Construct a function to unpack the parameters from the request message,
 	// based on the signature of the user's callback.
 	var newInput func(ctx reflect.Value, req *jrpc2.Request) ([]reflect.Value, error)
 
-	if fi.Argument == nil {
+	if arg == nil {
 		// Case 1: The function does not want any request parameters.
 		// Nothing needs to be decoded, but verify no parameters were passed.
 		newInput = func(ctx reflect.Value, req *jrpc2.Request) ([]reflect.Value, error) {
@@ -170,16 +175,16 @@ func (fi *FuncInfo) Wrap() Func {
 			return []reflect.Value{ctx}, nil
 		}
 
-	} else if fi.Argument == reqType {
+	} else if arg == reqType {
 		// Case 2: The function wants the underlying *jrpc2.Request value.
 		newInput = func(ctx reflect.Value, req *jrpc2.Request) ([]reflect.Value, error) {
 			return []reflect.Value{ctx, reflect.ValueOf(req)}, nil
 		}
 
-	} else if fi.Argument.Kind() == reflect.Ptr {
+	} else if arg.Kind() == reflect.Ptr {
 		// Case 3a: The function wants a pointer to its argument value.
 		newInput = func(ctx reflect.Value, req *jrpc2.Request) ([]reflect.Value, error) {
-			in := reflect.New(fi.Argument.Elem())
+			in := reflect.New(arg.Elem())
 			if err := req.UnmarshalParams(wrapArg(in)); err != nil {
 				return nil, jrpc2.Errorf(code.InvalidParams, "invalid parameters: %v", err)
 			}
@@ -188,7 +193,7 @@ func (fi *FuncInfo) Wrap() Func {
 	} else {
 		// Case 3b: The function wants a bare argument value.
 		newInput = func(ctx reflect.Value, req *jrpc2.Request) ([]reflect.Value, error) {
-			in := reflect.New(fi.Argument) // we still need a pointer to unmarshal
+			in := reflect.New(arg) // we still need a pointer to unmarshal
 			if err := req.UnmarshalParams(wrapArg(in)); err != nil {
 				return nil, jrpc2.Errorf(code.InvalidParams, "invalid parameters: %v", err)
 			}
@@ -385,11 +390,47 @@ func filterJSONError(tag, want string, err error) error {
 }
 
 // strict is a wrapper for an arbitrary value that enforces strict field
-// checking when unmarshaling from JSON.
-type strict struct{ v interface{} }
+// checking when unmarshaling from JSON, and handles translation of array
+// format into object format.
+type strict struct {
+	v        interface{}
+	posNames []string
+}
+
+// translate translates the raw JSON data into the correct format for
+// unmarshaling into s.v.
+//
+// If s.posNames is set and data encodes an array, the array is rewritten to an
+// equivalent object with field names assigned by the positional names.
+// Otherwise, data is returned as-is without error.
+func (s *strict) translate(data []byte) ([]byte, error) {
+	if len(s.posNames) == 0 || len(data) == 0 || data[0] != '[' {
+		return data, nil // no names, or not an array
+	}
+
+	// Decode the array wrapper and verify it has the correct length.
+	var arr []json.RawMessage
+	if err := json.Unmarshal(data, &arr); err != nil {
+		return nil, err
+	} else if len(arr) != len(s.posNames) {
+		return nil, jrpc2.Errorf(code.InvalidParams, "got %d parameters, want %d",
+			len(arr), len(s.posNames))
+	}
+
+	// Rewrite the array into an object.
+	obj := make(map[string]json.RawMessage, len(s.posNames))
+	for i, name := range s.posNames {
+		obj[name] = arr[i]
+	}
+	return json.Marshal(obj)
+}
 
 func (s *strict) UnmarshalJSON(data []byte) error {
-	dec := json.NewDecoder(bytes.NewReader(data))
+	actual, err := s.translate(data)
+	if err != nil {
+		return err
+	}
+	dec := json.NewDecoder(bytes.NewReader(actual))
 	dec.DisallowUnknownFields()
 	return dec.Decode(s.v)
 }
diff --git a/handler/positional.go b/handler/positional.go
index 516b74f..d4ad90e 100644
--- a/handler/positional.go
+++ b/handler/positional.go
@@ -56,6 +56,13 @@ func NewPos(fn interface{}, names ...string) Func {
 // field keys generate an error. The field names are not required to match the
 // parameter names declared by the function; it is the names assigned here that
 // determine which object keys are accepted.
+//
+// The wrapped function will also accept a JSON array with with (exactly) the
+// same number of elements as the positional parameters:
+//
+//   [17, 23]
+//
+// Unlike the object format, no arguments can be omitted in this format.
 func Positional(fn interface{}, names ...string) (*FuncInfo, error) {
 	if fn == nil {
 		return nil, errors.New("nil function")
@@ -85,6 +92,7 @@ func Positional(fn interface{}, names ...string) (*FuncInfo, error) {
 	fi, err := Check(makeCaller(ft, fv, atype))
 	if err == nil {
 		fi.strictFields = true
+		fi.posNames = names
 	}
 	return fi, err
 }


Test patch:
diff --git a/handler/handler_test.go b/handler/handler_test.go
index 2a6b2ba..e03ad81 100644
--- a/handler/handler_test.go
+++ b/handler/handler_test.go
@@ -184,14 +184,18 @@ func TestPositional_decode(t *testing.T) {
 		bad   bool
 	}{
 		{`{"jsonrpc":"2.0","id":1,"method":"add","params":{"first":5,"second":3}}`, 8, false},
-		{`{"jsonrpc":"2.0","id":2,"method":"add","params":{"first":5}}`, 5, false},
-		{`{"jsonrpc":"2.0","id":3,"method":"add","params":{"second":3}}`, 3, false},
-		{`{"jsonrpc":"2.0","id":4,"method":"add","params":{}}`, 0, false},
-		{`{"jsonrpc":"2.0","id":5,"method":"add","params":null}`, 0, false},
-		{`{"jsonrpc":"2.0","id":6,"method":"add"}`, 0, false},
-
-		{`{"jsonrpc":"2.0","id":6,"method":"add","params":["wrong", "type"]}`, 0, true},
-		{`{"jsonrpc":"2.0","id":6,"method":"add","params":{"unknown":"field"}}`, 0, true},
+		{`{"jsonrpc":"2.0","id":2,"method":"add","params":[5,3]}`, 8, false},
+		{`{"jsonrpc":"2.0","id":3,"method":"add","params":{"first":5}}`, 5, false},
+		{`{"jsonrpc":"2.0","id":4,"method":"add","params":{"second":3}}`, 3, false},
+		{`{"jsonrpc":"2.0","id":5,"method":"add","params":{}}`, 0, false},
+		{`{"jsonrpc":"2.0","id":6,"method":"add","params":null}`, 0, false},
+		{`{"jsonrpc":"2.0","id":7,"method":"add"}`, 0, false},
+
+		{`{"jsonrpc":"2.0","id":10,"method":"add","params":["wrong", "type"]}`, 0, true},
+		{`{"jsonrpc":"2.0","id":12,"method":"add","params":[15, "wrong-type"]}`, 0, true},
+		{`{"jsonrpc":"2.0","id":13,"method":"add","params":{"unknown":"field"}}`, 0, true},
+		{`{"jsonrpc":"2.0","id":14,"method":"add","params":[1]}`, 0, true},     // too few
+		{`{"jsonrpc":"2.0","id":15,"method":"add","params":[1,2,3]}`, 0, true}, // too many
 	}
 	for _, test := range tests {
 		req := mustParseRequest(t, test.input)
diff --git a/json_test.go b/json_test.go
index 7ae3f14..275e6d3 100644
--- a/json_test.go
+++ b/json_test.go
@@ -1,6 +1,3 @@
-//go:build oldbench
-// +build oldbench
-
 package jrpc2
 
 import (