feat(rpc): JSON-RPC 2.0 client transport and response types
Co-Authored-By: Charon <charon@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1fdd8d47d7
commit
03c11b3bbd
3 changed files with 395 additions and 0 deletions
153
rpc/client.go
Normal file
153
rpc/client.go
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
|
||||||
|
//
|
||||||
|
// Licensed under the European Union Public Licence (EUPL) version 1.2.
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
// Package rpc provides a typed client for the Lethean daemon JSON-RPC API.
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a Lethean daemon RPC client.
|
||||||
|
type Client struct {
|
||||||
|
url string // Base URL with /json_rpc path for JSON-RPC calls.
|
||||||
|
baseURL string // Base URL without path for legacy calls.
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a client for the daemon at the given URL.
|
||||||
|
// If the URL has no path, "/json_rpc" is appended automatically.
|
||||||
|
func NewClient(daemonURL string) *Client {
|
||||||
|
return NewClientWithHTTP(daemonURL, &http.Client{Timeout: 30 * time.Second})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientWithHTTP creates a client with a custom http.Client.
|
||||||
|
func NewClientWithHTTP(daemonURL string, httpClient *http.Client) *Client {
|
||||||
|
u, err := url.Parse(daemonURL)
|
||||||
|
if err != nil {
|
||||||
|
// Fall through with raw URL.
|
||||||
|
return &Client{url: daemonURL + "/json_rpc", baseURL: daemonURL, httpClient: httpClient}
|
||||||
|
}
|
||||||
|
baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
|
||||||
|
if u.Path == "" || u.Path == "/" {
|
||||||
|
u.Path = "/json_rpc"
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
url: u.String(),
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: httpClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCError represents a JSON-RPC error returned by the daemon.
|
||||||
|
type RPCError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RPCError) Error() string {
|
||||||
|
return fmt.Sprintf("rpc error %d: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON-RPC 2.0 envelope types.
|
||||||
|
type jsonRPCRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params any `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonRPCResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error *jsonRPCError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonRPCError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// call makes a JSON-RPC 2.0 call to /json_rpc.
|
||||||
|
func (c *Client) call(method string, params any, result any) error {
|
||||||
|
reqBody, err := json.Marshal(jsonRPCRequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: "0",
|
||||||
|
Method: method,
|
||||||
|
Params: params,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Post(c.url, "application/json", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post %s: %w", method, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("http %d from %s", resp.StatusCode, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rpcResp jsonRPCResponse
|
||||||
|
if err := json.Unmarshal(body, &rpcResp); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rpcResp.Error != nil {
|
||||||
|
return &RPCError{Code: rpcResp.Error.Code, Message: rpcResp.Error.Message}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && len(rpcResp.Result) > 0 {
|
||||||
|
if err := json.Unmarshal(rpcResp.Result, result); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal result: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacyCall makes a plain JSON POST to a legacy URI path (e.g. /getheight).
|
||||||
|
func (c *Client) legacyCall(path string, params any, result any) error {
|
||||||
|
reqBody, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := c.baseURL + path
|
||||||
|
resp, err := c.httpClient.Post(url, "application/json", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("http %d from %s", resp.StatusCode, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
if err := json.Unmarshal(body, result); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
164
rpc/client_test.go
Normal file
164
rpc/client_test.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
|
||||||
|
//
|
||||||
|
// Licensed under the European Union Public Licence (EUPL) version 1.2.
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClient_Good_JSONRPCCall(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify request format.
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method: got %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/json_rpc" {
|
||||||
|
t.Errorf("path: got %s, want /json_rpc", r.URL.Path)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var req jsonRPCRequest
|
||||||
|
json.Unmarshal(body, &req)
|
||||||
|
if req.JSONRPC != "2.0" {
|
||||||
|
t.Errorf("jsonrpc: got %q, want %q", req.JSONRPC, "2.0")
|
||||||
|
}
|
||||||
|
if req.Method != "getblockcount" {
|
||||||
|
t.Errorf("method: got %q, want %q", req.Method, "getblockcount")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a valid response.
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(jsonRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: json.RawMessage(`"0"`),
|
||||||
|
Result: json.RawMessage(`{"count":6300,"status":"OK"}`),
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL)
|
||||||
|
var result struct {
|
||||||
|
Count uint64 `json:"count"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
err := c.call("getblockcount", struct{}{}, &result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("call: %v", err)
|
||||||
|
}
|
||||||
|
if result.Count != 6300 {
|
||||||
|
t.Errorf("count: got %d, want 6300", result.Count)
|
||||||
|
}
|
||||||
|
if result.Status != "OK" {
|
||||||
|
t.Errorf("status: got %q, want %q", result.Status, "OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Good_LegacyCall(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/getheight" {
|
||||||
|
t.Errorf("path: got %s, want /getheight", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"height":6300,"status":"OK"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL)
|
||||||
|
var result struct {
|
||||||
|
Height uint64 `json:"height"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
err := c.legacyCall("/getheight", struct{}{}, &result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legacyCall: %v", err)
|
||||||
|
}
|
||||||
|
if result.Height != 6300 {
|
||||||
|
t.Errorf("height: got %d, want 6300", result.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Bad_RPCError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(jsonRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: json.RawMessage(`"0"`),
|
||||||
|
Error: &jsonRPCError{
|
||||||
|
Code: -2,
|
||||||
|
Message: "TOO_BIG_HEIGHT",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL)
|
||||||
|
var result struct{}
|
||||||
|
err := c.call("getblockheaderbyheight", struct{ Height uint64 }{999999999}, &result)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
rpcErr, ok := err.(*RPCError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *RPCError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if rpcErr.Code != -2 {
|
||||||
|
t.Errorf("code: got %d, want -2", rpcErr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Bad_ConnectionRefused(t *testing.T) {
|
||||||
|
c := NewClient("http://127.0.0.1:1") // Unlikely to be listening
|
||||||
|
var result struct{}
|
||||||
|
err := c.call("getblockcount", struct{}{}, &result)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected connection error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Good_URLAppendPath(t *testing.T) {
|
||||||
|
// NewClient should append /json_rpc if path is empty.
|
||||||
|
c := NewClient("http://localhost:46941")
|
||||||
|
if c.url != "http://localhost:46941/json_rpc" {
|
||||||
|
t.Errorf("url: got %q, want %q", c.url, "http://localhost:46941/json_rpc")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If path already present, leave it alone.
|
||||||
|
c2 := NewClient("http://localhost:46941/json_rpc")
|
||||||
|
if c2.url != "http://localhost:46941/json_rpc" {
|
||||||
|
t.Errorf("url: got %q, want %q", c2.url, "http://localhost:46941/json_rpc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Bad_InvalidJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`not json`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL)
|
||||||
|
var result struct{}
|
||||||
|
err := c.call("getblockcount", struct{}{}, &result)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Bad_HTTP500(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL)
|
||||||
|
var result struct{}
|
||||||
|
err := c.call("getblockcount", struct{}{}, &result)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for HTTP 500")
|
||||||
|
}
|
||||||
|
}
|
||||||
78
rpc/types.go
Normal file
78
rpc/types.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// Copyright (c) 2017-2026 Lethean (https://lt.hn)
|
||||||
|
//
|
||||||
|
// Licensed under the European Union Public Licence (EUPL) version 1.2.
|
||||||
|
// SPDX-License-Identifier: EUPL-1.2
|
||||||
|
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
// BlockHeader is a block header as returned by daemon RPC.
|
||||||
|
// Returned by getlastblockheader, getblockheaderbyheight, getblockheaderbyhash.
|
||||||
|
type BlockHeader struct {
|
||||||
|
MajorVersion uint8 `json:"major_version"`
|
||||||
|
MinorVersion uint8 `json:"minor_version"`
|
||||||
|
Timestamp uint64 `json:"timestamp"`
|
||||||
|
PrevHash string `json:"prev_hash"`
|
||||||
|
Nonce uint64 `json:"nonce"`
|
||||||
|
OrphanStatus bool `json:"orphan_status"`
|
||||||
|
Height uint64 `json:"height"`
|
||||||
|
Depth uint64 `json:"depth"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Difficulty string `json:"difficulty"`
|
||||||
|
Reward uint64 `json:"reward"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DaemonInfo is the daemon status as returned by getinfo.
|
||||||
|
type DaemonInfo struct {
|
||||||
|
Height uint64 `json:"height"`
|
||||||
|
TxCount uint64 `json:"tx_count"`
|
||||||
|
TxPoolSize uint64 `json:"tx_pool_size"`
|
||||||
|
AltBlocksCount uint64 `json:"alt_blocks_count"`
|
||||||
|
OutgoingConnectionsCount uint64 `json:"outgoing_connections_count"`
|
||||||
|
IncomingConnectionsCount uint64 `json:"incoming_connections_count"`
|
||||||
|
SynchronizedConnectionsCount uint64 `json:"synchronized_connections_count"`
|
||||||
|
DaemonNetworkState uint64 `json:"daemon_network_state"`
|
||||||
|
SynchronizationStartHeight uint64 `json:"synchronization_start_height"`
|
||||||
|
MaxNetSeenHeight uint64 `json:"max_net_seen_height"`
|
||||||
|
PowDifficulty uint64 `json:"pow_difficulty"`
|
||||||
|
PosDifficulty string `json:"pos_difficulty"`
|
||||||
|
BlockReward uint64 `json:"block_reward"`
|
||||||
|
DefaultFee uint64 `json:"default_fee"`
|
||||||
|
MinimumFee uint64 `json:"minimum_fee"`
|
||||||
|
LastBlockTimestamp uint64 `json:"last_block_timestamp"`
|
||||||
|
LastBlockHash string `json:"last_block_hash"`
|
||||||
|
AliasCount uint64 `json:"alias_count"`
|
||||||
|
TotalCoins string `json:"total_coins"`
|
||||||
|
PosAllowed bool `json:"pos_allowed"`
|
||||||
|
CurrentMaxAllowedBlockSize uint64 `json:"current_max_allowed_block_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockDetails is a full block with metadata as returned by get_blocks_details.
|
||||||
|
type BlockDetails struct {
|
||||||
|
Height uint64 `json:"height"`
|
||||||
|
Timestamp uint64 `json:"timestamp"`
|
||||||
|
ActualTimestamp uint64 `json:"actual_timestamp"`
|
||||||
|
BaseReward uint64 `json:"base_reward"`
|
||||||
|
SummaryReward uint64 `json:"summary_reward"`
|
||||||
|
TotalFee uint64 `json:"total_fee"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
PrevID string `json:"prev_id"`
|
||||||
|
Difficulty string `json:"difficulty"`
|
||||||
|
Type uint64 `json:"type"`
|
||||||
|
IsOrphan bool `json:"is_orphan"`
|
||||||
|
CumulativeSize uint64 `json:"block_cumulative_size"`
|
||||||
|
Blob string `json:"blob"`
|
||||||
|
ObjectInJSON string `json:"object_in_json"`
|
||||||
|
Transactions []TxInfo `json:"transactions_details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TxInfo is transaction metadata as returned by get_tx_details.
|
||||||
|
type TxInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BlobSize uint64 `json:"blob_size"`
|
||||||
|
Fee uint64 `json:"fee"`
|
||||||
|
Amount uint64 `json:"amount"`
|
||||||
|
Timestamp uint64 `json:"timestamp"`
|
||||||
|
KeeperBlock int64 `json:"keeper_block"`
|
||||||
|
Blob string `json:"blob"`
|
||||||
|
ObjectInJSON string `json:"object_in_json"`
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue