2021-11-08 16:39:17 +00:00

209 lines
6.6 KiB
Go

// Package jhttp implements a bridge from HTTP to JSON-RPC. This permits
// requests to be submitted to a JSON-RPC server using HTTP as a transport.
package jhttp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/creachadair/jrpc2"
"github.com/creachadair/jrpc2/server"
)
// A Bridge is a http.Handler that bridges requests to a JSON-RPC server.
//
// The body of the HTTP POST request must contain the complete JSON-RPC request
// message, encoded with Content-Type: application/json. Either a single
// request object or a list of request objects is supported.
//
// If the request completes, whether or not there is an error, the HTTP
// response is 200 (OK) for ordinary requests or 204 (No Response) for
// notifications, and the response body contains the JSON-RPC response.
//
// If the HTTP request method is not "POST", the bridge reports 405 (Method Not
// Allowed). If the Content-Type is not application/json, the bridge reports
// 415 (Unsupported Media Type).
//
// The bridge attaches the inbound HTTP request to the context passed to the
// client, allowing an EncodeContext callback to retrieve state from the HTTP
// headers. Use jhttp.HTTPRequest to retrieve the request from the context.
type Bridge struct {
local server.Local
checkType func(string) bool
}
// ServeHTTP implements the required method of http.Handler.
func (b Bridge) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !b.checkType(req.Header.Get("Content-Type")) {
w.WriteHeader(http.StatusUnsupportedMediaType)
return
}
if err := b.serveInternal(w, req); err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, err.Error())
}
}
func (b Bridge) serveInternal(w http.ResponseWriter, req *http.Request) error {
body, err := io.ReadAll(req.Body)
if err != nil {
return err
}
// The HTTP request requires a response, but the server will not reply if
// all the requests are notifications. Check whether we have any calls
// needing a response, and choose whether to wait for a reply based on that.
//
// Note that we are forgiving about a missing version marker in a request,
// since we can't tell at this point whether the server is willing to accept
// messages like that.
jreq, err := jrpc2.ParseRequests(body)
if err != nil && err != jrpc2.ErrInvalidVersion {
return err
}
// Because the bridge shares the JSON-RPC client between potentially many
// HTTP clients, we must virtualize the ID space for requests to preserve
// the HTTP client's assignment of IDs.
//
// To do this, we keep track of the inbound ID for each request so that we
// can map the responses back. This takes advantage of the fact that the
// *jrpc2.Client detangles batch order so that responses come back in the
// same order (modulo notifications) even if the server response did not
// preserve order.
// Generate request specifications for the client.
var inboundID []string // for requests
spec := make([]jrpc2.Spec, len(jreq)) // requests & notifications
for i, req := range jreq {
spec[i] = jrpc2.Spec{
Method: req.Method(),
Notify: req.IsNotification(),
}
if req.HasParams() {
var p json.RawMessage
req.UnmarshalParams(&p)
spec[i].Params = p
}
if !spec[i].Notify {
inboundID = append(inboundID, req.ID())
}
}
// Attach the HTTP request to the client context, so the encoder can see it.
ctx := context.WithValue(req.Context(), httpReqKey{}, req)
rsps, err := b.local.Client.Batch(ctx, spec)
if err != nil {
return err
}
// If all the requests were notifications, report success without responses.
if len(rsps) == 0 {
w.WriteHeader(http.StatusNoContent)
return nil
}
// Otherwise, map the responses back to their original IDs, and marshal the
// response back into the body.
for i, rsp := range rsps {
rsp.SetID(inboundID[i])
}
// If the original request was a single message, make sure we encode the
// response the same way.
var reply []byte
if len(rsps) == 1 && !bytes.HasPrefix(bytes.TrimSpace(body), []byte("[")) {
reply, err = json.Marshal(rsps[0])
} else {
reply, err = json.Marshal(rsps)
}
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(reply)))
w.Write(reply)
return nil
}
// Close closes the channel to the server, waits for the server to exit, and
// reports its exit status.
func (b Bridge) Close() error { return b.local.Close() }
// NewBridge constructs a new Bridge that starts a server on mux and dispatches
// HTTP requests to it. The server will run until the bridge is closed.
//
// Note that a bridge is not able to push calls or notifications from the
// server back to the remote client. The bridge client is shared by multiple
// active HTTP requests, and has no way to know which of the callers the push
// should be forwarded to. You can enable push on the bridge server and set
// hooks on the bridge client as usual, but the remote client will not see push
// messages from the server.
func NewBridge(mux jrpc2.Assigner, opts *BridgeOptions) Bridge {
return Bridge{
local: server.NewLocal(mux, &server.LocalOptions{
Client: opts.clientOptions(),
Server: opts.serverOptions(),
}),
checkType: opts.checkContentType(),
}
}
// BridgeOptions are optional settings for a Bridge. A nil pointer is ready for
// use and provides default values as described.
type BridgeOptions struct {
// Options for the bridge client (default nil).
Client *jrpc2.ClientOptions
// Options for the bridge server (default nil).
Server *jrpc2.ServerOptions
// If non-nil, this function is called to check whether the HTTP request's
// declared content-type is valid. If this function returns false, the
// request is rejected. If nil, the default check requires a content type of
// "application/json".
CheckContentType func(contentType string) bool
}
func (o *BridgeOptions) clientOptions() *jrpc2.ClientOptions {
if o == nil {
return nil
}
return o.Client
}
func (o *BridgeOptions) serverOptions() *jrpc2.ServerOptions {
if o == nil {
return nil
}
return o.Server
}
func (o *BridgeOptions) checkContentType() func(string) bool {
if o == nil || o.CheckContentType == nil {
return func(ctype string) bool { return ctype == "application/json" }
}
return o.CheckContentType
}
type httpReqKey struct{}
// HTTPRequest returns the HTTP request associated with ctx, or nil. The
// context passed to the JSON-RPC client by the Bridge will contain this value.
func HTTPRequest(ctx context.Context) *http.Request {
req, ok := ctx.Value(httpReqKey{}).(*http.Request)
if ok {
return req
}
return nil
}