Skip to content

Commit

Permalink
jhttp: add a Getter type to handle GET requests (#68)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
creachadair authored Dec 24, 2021
1 parent 5926132 commit a6e1ee1
Show file tree
Hide file tree
Showing 2 changed files with 308 additions and 0 deletions.
157 changes: 157 additions & 0 deletions jhttp/getter.go
Original file line number Diff line number Diff line change
@@ -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)
}
151 changes: 151 additions & 0 deletions jhttp/getter_test.go
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit a6e1ee1

Please sign in to comment.