Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

jhttp: add a Getter type to handle GET requests #68

Merged
merged 2 commits into from
Dec 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}