-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
jhttp: add a Getter type to handle GET requests (#68)
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
1 parent
5926132
commit a6e1ee1
Showing
2 changed files
with
308 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |