// Package jsonrpc2 provides a minimal JSON-RPC 2.0 HTTP client. // // The client is designed to work with the jsonrpc2 server implementation. // It supports only single JSON-RPC 2.0 requests and notifications. // Batch requests are explicitly unsupported. // // All request lifetimes are controlled by context.Context. // // Author: NiuJiuRu // Email: niujiuru@qq.com // // See: https://www.jsonrpc.org/specification package jsonrpc2 import ( "bytes" "context" "encoding/json" "fmt" "io" "math" "net/http" "net/url" "sync/atomic" ) // ClientOption configures an RPCClient. type ClientOption func(*RPCClient) // WithHTTPClient sets a custom http.Client. func WithHTTPClient(hc *http.Client) ClientOption { return func(c *RPCClient) { if hc != nil { c.http = hc } } } // SetHeader sets a custom HTTP header for all requests. func SetHeader(key, value string) ClientOption { return func(c *RPCClient) { if key == "" { return } if c.headers == nil { c.headers = make(http.Header) } c.headers.Set(key, value) } } // RPCClient is a minimal JSON-RPC 2.0 HTTP client. type RPCClient struct { baseURL string http *http.Client headers http.Header seq atomic.Int64 } // NewRPCClient creates a new JSON-RPC 2.0 client. // // The client is minimal, safe for concurrent use, and designed to work // with standard JSON-RPC 2.0 HTTP servers. // // Example: // // // Create a client with default settings. // client := jsonrpc2.NewRPCClient("http://localhost:8080/rpc") // // // Create a client with a custom HTTP client and headers. // hc := &http.Client{ // Timeout: 5 * time.Second, // } // // client := jsonrpc2.NewRPCClient( // "http://localhost:8080/rpc", // jsonrpc2.WithHTTPClient(hc), // jsonrpc2.SetHeader("Authorization", "Bearer token"), // jsonrpc2.SetHeader("X-Request-ID", "req-001"), // jsonrpc2.SetHeader("User-Agent", "jsonrpc2-client/1.0"), // ) // // ctx := context.Background() // resp, err := client.Call(ctx, "add", []int{1, 2}) // // Options are applied in order. // If no http.Client is provided, http.DefaultClient is used. func NewRPCClient(baseURL string, opts ...ClientOption) (*RPCClient, error) { if baseURL == "" { return nil, fmt.Errorf("baseURL cannot be empty") } _, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("invalid baseURL: %v", baseURL) } c := &RPCClient{ baseURL: baseURL, http: http.DefaultClient, headers: http.Header{}, } for _, opt := range opts { opt(c) } return c, nil } // Call invokes a JSON-RPC method and returns the response. func (c *RPCClient) Call(ctx context.Context, method string, params any) (*Response, error) { id := c.nextID() req, err := BuildRequest(method, params, id) if err != nil { return nil, fmt.Errorf("build request: %w", err) } return c.do(ctx, req) } // Notify sends a notification (no response expected). func (c *RPCClient) Notify(ctx context.Context, method string, params any) error { req, err := BuildNotification(method, params) if err != nil { return fmt.Errorf("build notification: %w", err) } _, err = c.do(ctx, req) return err } func (c *RPCClient) nextID() int64 { for { id := c.seq.Add(1) if id > 0 && id <= math.MaxInt32 { return id } if c.seq.CompareAndSwap(id, 1) { return 1 } } } func (c *RPCClient) do(ctx context.Context, req *Request) (*Response, error) { body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } httpReq, err := http.NewRequestWithContext( ctx, http.MethodPost, c.baseURL, bytes.NewReader(body), ) if err != nil { return nil, fmt.Errorf("create request: %w", err) } httpReq.Header = c.headers.Clone() httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json") resp, err := c.http.Do(httpReq) if err != nil { return nil, fmt.Errorf("http request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } if req.IsNotification() { _, _ = io.Copy(io.Discard, resp.Body) return nil, nil } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } var rpcResp Response if err := json.Unmarshal(data, &rpcResp); err != nil { return nil, fmt.Errorf("unmarshal response: %w", err) } return &rpcResp, nil }