Repository: creachadair/jrpc2
Pull request #68 (2021-12-24 20:44:48)

jhttp: add a Getter type to handle GET requests
The Getter type bridges HTTP GET requests to JSON-RPC.  Unlike a Bridge, the
method and parameters rely on the URL rather than the request body.  Results
are written back to the client without JSON-RPC request frames.

The default request parser treats query fields as plain strings; the caller can
plug in more specific parsing via options.


Patch:
diff --git a/jhttp/getter.go b/jhttp/getter.go
new file mode 100644
index 0000000..1c9c77d
--- /dev/null
+++ b/jhttp/getter.go
@@ -0,0 +1,157 @@
+// Copyright (C) 2021 Michael J. Fromberger. All Rights Reserved.
+
+package jhttp
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/creachadair/jrpc2"
+	"github.com/creachadair/jrpc2/code"
+	"github.com/creachadair/jrpc2/server"
+)
+
+// A Getter is a http.Handler that bridges GET requests to a JSON-RPC server.
+//
+// The JSON-RPC method name and parameters are decoded from the request URL.
+// The results from a successful call are encoded as JSON in the response body
+// with status 200 (OK). In case of error, the response body is a JSON-RPC
+// error object, and the HTTP status is one of the following:
+//
+//  Condition               HTTP Status
+//  ----------------------- -----------------------------------
+//  Parsing request         400 (Bad request)
+//  Method not found        404 (Not found)
+//  (other errors)          500 (Internal server error)
+//
+// By default, the URL path identifies the JSON-RPC method, and the URL query
+// parameters are converted into a JSON object for the parameters. Leading and
+// trailing slashes are stripped from the path, and query values are converted
+// into JSON strings.
+//
+// For example, the URL "http://host:port/path/to/method?foo=true&bar=okay"
+// decodes to the method name "path/to/method" and this parameter object:
+//
+//   {"foo": "true", "bar": "okay"}
+//
+// Set a ParseRequest hook in the GetterOptions to override this behaviour.
+type Getter struct {
+	local    server.Local
+	parseReq func(*http.Request) (string, interface{}, error)
+}
+
+// NewGetter constructs a new Getter that starts a server on mux and dispatches
+// HTTP requests to it. The server will run until the getter is closed.
+//
+// Note that a getter is not able to push calls or notifications from the
+// server back to the remote client even if enabled.
+func NewGetter(mux jrpc2.Assigner, opts *GetterOptions) Getter {
+	return Getter{
+		local: server.NewLocal(mux, &server.LocalOptions{
+			Client: opts.clientOptions(),
+			Server: opts.serverOptions(),
+		}),
+		parseReq: opts.parseRequest(),
+	}
+}
+
+// ServeHTTP implements the required method of http.Handler.
+func (g Getter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	method, params, err := g.parseHTTPRequest(req)
+	if err != nil {
+		writeJSON(w, http.StatusBadRequest, &jrpc2.Error{
+			Code:    code.ParseError,
+			Message: err.Error(),
+		})
+		return
+	}
+
+	ctx := context.WithValue(req.Context(), httpReqKey{}, req)
+	var result json.RawMessage
+	if err := g.local.Client.CallResult(ctx, method, params, &result); err != nil {
+		var status int
+		switch code.FromError(err) {
+		case code.MethodNotFound:
+			status = http.StatusNotFound
+		default:
+			status = http.StatusInternalServerError
+		}
+		writeJSON(w, status, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, result)
+}
+
+// Close closes the channel to the server, waits for the server to exit, and
+// reports its exit status.
+func (g Getter) Close() error { return g.local.Close() }
+
+func (g Getter) parseHTTPRequest(req *http.Request) (string, interface{}, error) {
+	if g.parseReq != nil {
+		return g.parseReq(req)
+	}
+	if err := req.ParseForm(); err != nil {
+		return "", nil, err
+	}
+	params := make(map[string]string)
+	for key := range req.Form {
+		params[key] = req.Form.Get(key)
+	}
+	return strings.Trim(req.URL.Path, "/"), params, nil
+}
+
+// GetterOptions are optional settings for a Getter. A nil pointer is ready for
+// use and provides default values as described.
+type GetterOptions struct {
+	// Options for the getter client (default nil).
+	Client *jrpc2.ClientOptions
+
+	// Options for the getter server (default nil).
+	Server *jrpc2.ServerOptions
+
+	// If set, this function is called to parse a method name and request
+	// parameters from an HTTP request. If this is not set, the default handler
+	// uses the URL path as the method name and the URL query as the method
+	// parameters.
+	ParseRequest func(*http.Request) (string, interface{}, error)
+}
+
+func (o *GetterOptions) clientOptions() *jrpc2.ClientOptions {
+	if o == nil {
+		return nil
+	}
+	return o.Client
+}
+
+func (o *GetterOptions) serverOptions() *jrpc2.ServerOptions {
+	if o == nil {
+		return nil
+	}
+	return o.Server
+}
+
+func (o *GetterOptions) parseRequest() func(*http.Request) (string, interface{}, error) {
+	if o == nil {
+		return nil
+	}
+	return o.ParseRequest
+}
+
+func writeJSON(w http.ResponseWriter, code int, obj interface{}) {
+	bits, err := json.Marshal(obj)
+	if err != nil {
+		// Fallback in case of marshaling error. This should not happen, but
+		// ensures the client gets a loggable reply from a broken server.
+		w.WriteHeader(http.StatusInternalServerError)
+		fmt.Fprintln(w, err.Error())
+		return
+	}
+	w.WriteHeader(code)
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Content-Length", strconv.Itoa(len(bits)))
+	w.Write(bits)
+}


Test patch:
diff --git a/jhttp/getter_test.go b/jhttp/getter_test.go
new file mode 100644
index 0000000..6046db8
--- /dev/null
+++ b/jhttp/getter_test.go
@@ -0,0 +1,151 @@
+// Copyright (C) 2021 Michael J. Fromberger. All Rights Reserved.
+
+package jhttp_test
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/creachadair/jrpc2"
+	"github.com/creachadair/jrpc2/handler"
+	"github.com/creachadair/jrpc2/jhttp"
+	"github.com/fortytw2/leaktest"
+)
+
+func TestGetter(t *testing.T) {
+	defer leaktest.Check(t)()
+
+	mux := handler.Map{
+		"concat": handler.NewPos(func(ctx context.Context, a, b string) string {
+			return a + b
+		}, "first", "second"),
+	}
+
+	g := jhttp.NewGetter(mux, &jhttp.GetterOptions{
+		Client: &jrpc2.ClientOptions{EncodeContext: checkContext},
+	})
+	defer checkClose(t, g)
+
+	hsrv := httptest.NewServer(g)
+	defer hsrv.Close()
+	url := func(pathQuery string) string {
+		return hsrv.URL + "/" + pathQuery
+	}
+
+	t.Run("OK", func(t *testing.T) {
+		got := mustGet(t, url("concat?second=world&first=hello"), http.StatusOK)
+		const want = `"helloworld"`
+		if got != want {
+			t.Errorf("Response body: got %#q, want %#q", got, want)
+		}
+	})
+	t.Run("NotFound", func(t *testing.T) {
+		got := mustGet(t, url("nonesuch"), http.StatusNotFound)
+		const want = `"code":-32601` // MethodNotFound
+		if !strings.Contains(got, want) {
+			t.Errorf("Response body: got %#q, want %#q", got, want)
+		}
+	})
+	t.Run("BadRequest", func(t *testing.T) {
+		// N.B. invalid query string
+		got := mustGet(t, url("concat?x%2"), http.StatusBadRequest)
+		const want = `"code":-32700` // ParseError
+		if !strings.Contains(got, want) {
+			t.Errorf("Response body: got %#q, want %#q", got, want)
+		}
+	})
+	t.Run("InternalError", func(t *testing.T) {
+		got := mustGet(t, url("concat?third=c"), http.StatusInternalServerError)
+		const want = `"code":-32602` // InvalidParams
+		if !strings.Contains(got, want) {
+			t.Errorf("Response body: got %#q, want %#q", got, want)
+		}
+	})
+}
+
+func TestGetter_parseRequest(t *testing.T) {
+	defer leaktest.Check(t)()
+
+	mux := handler.Map{
+		"format": handler.NewPos(func(ctx context.Context, a string, b int) string {
+			return fmt.Sprintf("%s-%d", a, b)
+		}, "tag", "value"),
+	}
+
+	g := jhttp.NewGetter(mux, &jhttp.GetterOptions{
+		ParseRequest: func(req *http.Request) (string, interface{}, error) {
+			if err := req.ParseForm(); err != nil {
+				return "", nil, err
+			}
+			params := make(map[string]interface{})
+			for key := range req.Form {
+				val := req.Form.Get(key)
+				v, err := strconv.Atoi(val)
+				if err == nil {
+					params[key] = v
+					continue
+				}
+				b, err := hex.DecodeString(val)
+				if err == nil {
+					params[key] = b
+					continue
+				}
+				params[key] = val
+			}
+			return strings.TrimPrefix(req.URL.Path, "/x/"), params, nil
+		},
+	})
+	defer checkClose(t, g)
+
+	hsrv := httptest.NewServer(g)
+	defer hsrv.Close()
+	url := func(pathQuery string) string {
+		return hsrv.URL + "/" + pathQuery
+	}
+
+	t.Run("OK", func(t *testing.T) {
+		got := mustGet(t, url("x/format?tag=foo&value=25"), http.StatusOK)
+		const want = `"foo-25"`
+		if got != want {
+			t.Errorf("Response body: got %#q, want %#q", got, want)
+		}
+	})
+	t.Run("NotFound", func(t *testing.T) {
+		// N.B. Missing path prefix.
+		got := mustGet(t, url("format"), http.StatusNotFound)
+		const want = `"code":-32601` // MethodNotFound
+		if !strings.Contains(got, want) {
+			t.Errorf("Response body: got %#q, want %#q", got, want)
+		}
+	})
+	t.Run("InternalError", func(t *testing.T) {
+		// N.B. Parameter type does not match on the server side.
+		got := mustGet(t, url("x/format?tag=foo&value=bar"), http.StatusInternalServerError)
+		const want = `"code":-32602` // InvalidParams
+		if !strings.Contains(got, want) {
+			t.Errorf("Response body: got %#q, want %#q", got, want)
+		}
+	})
+}
+
+func mustGet(t *testing.T, url string, code int) string {
+	t.Helper()
+	rsp, err := http.Get(url)
+	if err != nil {
+		t.Fatalf("GET request failed: %v", err)
+	} else if got := rsp.StatusCode; got != code {
+		t.Errorf("GET response code: got %v, want %v", got, code)
+	}
+	body, err := io.ReadAll(rsp.Body)
+	if err != nil {
+		t.Errorf("Reading GET body: %v", err)
+	}
+	return string(body)
+}