first commit
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
// Package jsonrpc implements a minimal JSON-RPC 2.0 client over a subprocess's
|
||||
// stdio (US-014/#116). It is the shared transport foundation reused by the MCP
|
||||
// client (#130/#131), the plugin system (#132/#133) and process-isolated
|
||||
// sub-agents (#135): each spawns an external executable and speaks line-delimited
|
||||
// JSON-RPC 2.0 over the child's stdin/stdout.
|
||||
//
|
||||
// The wire format follows the spec: every message carries "jsonrpc":"2.0". A
|
||||
// request has an id and expects a matching response; a notification omits the id
|
||||
// and expects none. Requests and responses are correlated by id, so concurrent
|
||||
// requests from different goroutines are safe — each waits only on its own reply.
|
||||
//
|
||||
// This file defines the message envelope and (de)serialization; transport.go
|
||||
// implements the subprocess client.
|
||||
package jsonrpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Version is the only JSON-RPC protocol version this package speaks.
|
||||
const Version = "2.0"
|
||||
|
||||
// ID is a JSON-RPC request identifier. The spec allows a string or a number;
|
||||
// this client only ever generates numeric ids, but ID round-trips whatever a
|
||||
// peer sends back so response correlation still works against servers that echo
|
||||
// string ids.
|
||||
type ID struct {
|
||||
num int64
|
||||
str string
|
||||
isStr bool
|
||||
}
|
||||
|
||||
// NumID returns a numeric request id.
|
||||
func NumID(n int64) ID { return ID{num: n} }
|
||||
|
||||
// String renders the id for use as a map key when correlating responses.
|
||||
func (id ID) String() string {
|
||||
if id.isStr {
|
||||
return "s:" + id.str
|
||||
}
|
||||
return fmt.Sprintf("n:%d", id.num)
|
||||
}
|
||||
|
||||
// MarshalJSON emits the id as its underlying JSON scalar (number or string).
|
||||
func (id ID) MarshalJSON() ([]byte, error) {
|
||||
if id.isStr {
|
||||
return json.Marshal(id.str)
|
||||
}
|
||||
return json.Marshal(id.num)
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts either a JSON number or string id.
|
||||
func (id *ID) UnmarshalJSON(data []byte) error {
|
||||
var n int64
|
||||
if err := json.Unmarshal(data, &n); err == nil {
|
||||
id.num, id.isStr, id.str = n, false, ""
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
id.str, id.isStr, id.num = s, true, 0
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("jsonrpc: id is neither number nor string: %s", data)
|
||||
}
|
||||
|
||||
// Request is an outgoing JSON-RPC request or notification. When ID is nil the
|
||||
// message is a notification (no response expected).
|
||||
type Request struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *ID `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Response is an incoming JSON-RPC response. Exactly one of Result / Error is
|
||||
// set on a well-formed reply.
|
||||
type Response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *ID `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Error is a JSON-RPC error object.
|
||||
type Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements the error interface so a peer error can be returned directly.
|
||||
func (e *Error) Error() string {
|
||||
return fmt.Sprintf("jsonrpc: server error %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// newRequest builds a request (id != nil) or notification (id == nil) with the
|
||||
// given params marshaled to JSON. A nil params value is omitted from the wire
|
||||
// message.
|
||||
func newRequest(id *ID, method string, params any) (*Request, error) {
|
||||
req := &Request{JSONRPC: Version, ID: id, Method: method}
|
||||
if params != nil {
|
||||
raw, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jsonrpc: marshal params for %q: %w", method, err)
|
||||
}
|
||||
req.Params = raw
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// This file implements the subprocess JSON-RPC client (US-014/#116). A Client
|
||||
// launches an external executable, writes requests to its stdin and reads
|
||||
// newline-delimited JSON-RPC messages from its stdout on a background reader
|
||||
// goroutine. Requests are correlated to responses by id through a pending-call
|
||||
// map, so Call is safe for concurrent use: each caller blocks only on its own
|
||||
// response channel until the reply arrives, the context is cancelled, or the
|
||||
// child exits.
|
||||
package jsonrpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// closeGrace bounds how long Close waits for a child to exit on its own after
|
||||
// stdin is closed, before force-killing it.
|
||||
const closeGrace = 5 * time.Second
|
||||
|
||||
// ErrClosed is returned by Call once the client has been closed or the child
|
||||
// process has exited.
|
||||
var ErrClosed = errors.New("jsonrpc: client closed")
|
||||
|
||||
// Client is a JSON-RPC 2.0 client bound to a subprocess over its stdio.
|
||||
type Client struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
|
||||
writeMu sync.Mutex // serializes writes to the child's stdin
|
||||
nextID atomic.Int64
|
||||
|
||||
mu sync.Mutex // guards pending and closed
|
||||
pending map[string]chan res // id -> waiter
|
||||
closed bool
|
||||
closeErr error
|
||||
|
||||
done chan struct{} // closed when the reader goroutine exits
|
||||
}
|
||||
|
||||
// res carries a decoded response (or a transport-level error) to a waiter.
|
||||
type res struct {
|
||||
resp *Response
|
||||
err error
|
||||
}
|
||||
|
||||
// Config describes the subprocess to launch.
|
||||
type Config struct {
|
||||
// Command is the executable path.
|
||||
Command string
|
||||
// Args are the process arguments (excluding the command itself).
|
||||
Args []string
|
||||
// Env is the child's environment (os/exec form: "KEY=value"). When nil the
|
||||
// child inherits the parent environment.
|
||||
Env []string
|
||||
// Dir is the child's working directory; empty means the parent's.
|
||||
Dir string
|
||||
// Stderr optionally receives the child's stderr (e.g. for logging). When nil
|
||||
// the child's stderr is discarded.
|
||||
Stderr io.Writer
|
||||
}
|
||||
|
||||
// NewClient starts the subprocess and begins reading its stdout. The caller must
|
||||
// Close the client to terminate the child and release resources.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if cfg.Command == "" {
|
||||
return nil, errors.New("jsonrpc: empty command")
|
||||
}
|
||||
cmd := exec.Command(cfg.Command, cfg.Args...)
|
||||
cmd.Env = cfg.Env
|
||||
cmd.Dir = cfg.Dir
|
||||
cmd.Stderr = cfg.Stderr
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jsonrpc: stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jsonrpc: stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("jsonrpc: start %q: %w", cfg.Command, err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
pending: make(map[string]chan res),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go c.readLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// readLoop reads newline-delimited JSON messages from the child's stdout and
|
||||
// dispatches each response to its waiter. It exits when stdout hits EOF/error,
|
||||
// failing all outstanding calls.
|
||||
func (c *Client) readLoop() {
|
||||
defer close(c.done)
|
||||
scanner := bufio.NewScanner(c.stdout)
|
||||
// MCP/plugin payloads (e.g. tool schemas) can be large; raise the line cap.
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var resp Response
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
// A line we can't parse as a response is skipped (it may be a
|
||||
// server->client request/notification, which this minimal client
|
||||
// does not handle).
|
||||
continue
|
||||
}
|
||||
if resp.ID == nil {
|
||||
continue // notification from server; nothing to correlate
|
||||
}
|
||||
c.deliver(resp.ID.String(), res{resp: &resp})
|
||||
}
|
||||
|
||||
err := scanner.Err()
|
||||
if err == nil {
|
||||
err = io.EOF
|
||||
}
|
||||
c.failAll(fmt.Errorf("jsonrpc: reader stopped: %w", err))
|
||||
}
|
||||
|
||||
// deliver hands a response to its waiter (if still registered).
|
||||
func (c *Client) deliver(id string, r res) {
|
||||
c.mu.Lock()
|
||||
ch, ok := c.pending[id]
|
||||
if ok {
|
||||
delete(c.pending, id)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if ok {
|
||||
ch <- r
|
||||
}
|
||||
}
|
||||
|
||||
// failAll completes every outstanding call with err and marks the client closed.
|
||||
func (c *Client) failAll(err error) {
|
||||
c.mu.Lock()
|
||||
if c.closeErr == nil {
|
||||
c.closeErr = err
|
||||
}
|
||||
c.closed = true
|
||||
pending := c.pending
|
||||
c.pending = make(map[string]chan res)
|
||||
c.mu.Unlock()
|
||||
for _, ch := range pending {
|
||||
ch <- res{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Call sends a request and waits for the matching response. It returns the
|
||||
// decoded Result on success, the server Error as error on an error response, or
|
||||
// a transport/context error. Call is safe for concurrent use.
|
||||
func (c *Client) Call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
id := NumID(c.nextID.Add(1))
|
||||
req, err := newRequest(&id, method, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan res, 1)
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil, c.closedErr()
|
||||
}
|
||||
c.pending[id.String()] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.write(req); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id.String())
|
||||
c.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id.String())
|
||||
c.mu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
case r := <-ch:
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
if r.resp.Error != nil {
|
||||
return nil, r.resp.Error
|
||||
}
|
||||
return r.resp.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Notify sends a notification (no id, no response expected).
|
||||
func (c *Client) Notify(method string, params any) error {
|
||||
req, err := newRequest(nil, method, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return c.closedErr()
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return c.write(req)
|
||||
}
|
||||
|
||||
// write serializes a message and writes it as one newline-terminated line.
|
||||
// Writes are serialized so concurrent callers don't interleave bytes on stdin.
|
||||
func (c *Client) write(msg any) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jsonrpc: marshal request: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if _, err := c.stdin.Write(data); err != nil {
|
||||
return fmt.Errorf("jsonrpc: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// closedErr reports why the client is closed, defaulting to ErrClosed.
|
||||
func (c *Client) closedErr() error {
|
||||
if c.closeErr != nil {
|
||||
return c.closeErr
|
||||
}
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
// Close closes the child's stdin (signalling graceful shutdown), waits briefly
|
||||
// for the process to exit, and kills it if it does not. It is idempotent.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
<-c.done
|
||||
return nil
|
||||
}
|
||||
c.closed = true
|
||||
if c.closeErr == nil {
|
||||
c.closeErr = ErrClosed
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Closing stdin lets a well-behaved child exit on its own.
|
||||
_ = c.stdin.Close()
|
||||
|
||||
// Wait for the child; kill if it doesn't stop promptly. The grace timer
|
||||
// bounds Close so a child that ignores stdin EOF and keeps stdout open
|
||||
// (a hung plugin/server) cannot block us forever.
|
||||
waitErr := make(chan error, 1)
|
||||
go func() { waitErr <- c.cmd.Wait() }()
|
||||
|
||||
grace := time.NewTimer(closeGrace)
|
||||
defer grace.Stop()
|
||||
|
||||
select {
|
||||
case <-waitErr:
|
||||
<-c.done
|
||||
return nil
|
||||
case <-c.done:
|
||||
// reader saw EOF; give Wait a brief moment, then force kill.
|
||||
case <-grace.C:
|
||||
// child hasn't exited within the grace period; force it.
|
||||
}
|
||||
|
||||
select {
|
||||
case <-waitErr:
|
||||
default:
|
||||
_ = c.cmd.Process.Kill()
|
||||
<-waitErr
|
||||
}
|
||||
<-c.done
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package jsonrpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestMain lets this test binary double as a mock JSON-RPC server subprocess.
|
||||
// When JSONRPC_TEST_SERVER is set the process runs the echo server and exits;
|
||||
// otherwise it runs the normal test suite. This is the standard Go pattern for
|
||||
// exercising a subprocess transport without shipping a separate helper binary.
|
||||
func TestMain(m *testing.M) {
|
||||
switch os.Getenv("JSONRPC_TEST_SERVER") {
|
||||
case "echo":
|
||||
runEchoServer()
|
||||
return
|
||||
case "silent":
|
||||
// Read and discard everything, never reply — used for timeout tests.
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
for sc.Scan() {
|
||||
}
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// runEchoServer replies to each request: method "echo" returns its params,
|
||||
// method "fail" returns a JSON-RPC error, notifications produce no reply.
|
||||
func runEchoServer() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
for scanner.Scan() {
|
||||
var req Request
|
||||
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
|
||||
continue
|
||||
}
|
||||
if req.ID == nil {
|
||||
continue // notification: no response
|
||||
}
|
||||
var resp Response
|
||||
resp.JSONRPC = Version
|
||||
resp.ID = req.ID
|
||||
switch req.Method {
|
||||
case "fail":
|
||||
resp.Error = &Error{Code: -32000, Message: "boom"}
|
||||
default:
|
||||
resp.Result = req.Params
|
||||
if resp.Result == nil {
|
||||
resp.Result = json.RawMessage(`null`)
|
||||
}
|
||||
}
|
||||
out, _ := json.Marshal(&resp)
|
||||
out = append(out, '\n')
|
||||
_, _ = w.Write(out)
|
||||
_ = w.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// newTestClient starts this test binary as a mock server in the given mode.
|
||||
func newTestClient(t *testing.T, mode string) *Client {
|
||||
t.Helper()
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Executable: %v", err)
|
||||
}
|
||||
c, err := NewClient(Config{
|
||||
Command: exe,
|
||||
Env: append(os.Environ(), "JSONRPC_TEST_SERVER="+mode),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
func TestCallEcho(t *testing.T) {
|
||||
c := newTestClient(t, "echo")
|
||||
ctx := context.Background()
|
||||
|
||||
raw, err := c.Call(ctx, "echo", map[string]any{"hello": "world"})
|
||||
if err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
if got["hello"] != "world" {
|
||||
t.Fatalf("got %v, want hello=world", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallServerError(t *testing.T) {
|
||||
c := newTestClient(t, "echo")
|
||||
_, err := c.Call(context.Background(), "fail", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var rpcErr *Error
|
||||
if !errors.As(err, &rpcErr) {
|
||||
t.Fatalf("expected *jsonrpc.Error, got %T: %v", err, err)
|
||||
}
|
||||
if rpcErr.Code != -32000 || !strings.Contains(rpcErr.Message, "boom") {
|
||||
t.Fatalf("unexpected error: %+v", rpcErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentCalls verifies responses correlate to the right caller when many
|
||||
// requests are in flight at once (id-based correlation).
|
||||
func TestConcurrentCalls(t *testing.T) {
|
||||
c := newTestClient(t, "echo")
|
||||
ctx := context.Background()
|
||||
|
||||
const n = 50
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
raw, err := c.Call(ctx, "echo", map[string]int{"n": i})
|
||||
if err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
var got map[string]int
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
errs[i] = err
|
||||
return
|
||||
}
|
||||
if got["n"] != i {
|
||||
errs[i] = fmt.Errorf("call %d got n=%d", i, got["n"])
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallContextTimeout verifies Call returns when the context is cancelled and
|
||||
// the server never replies.
|
||||
func TestCallContextTimeout(t *testing.T) {
|
||||
c := newTestClient(t, "silent")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
_, err := c.Call(ctx, "echo", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
if time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("Call blocked too long: %v", time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyNoResponse(t *testing.T) {
|
||||
c := newTestClient(t, "echo")
|
||||
if err := c.Notify("ping", map[string]string{"k": "v"}); err != nil {
|
||||
t.Fatalf("Notify: %v", err)
|
||||
}
|
||||
// A subsequent Call must still work (notification produced no stray reply).
|
||||
if _, err := c.Call(context.Background(), "echo", nil); err != nil {
|
||||
t.Fatalf("Call after Notify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallAfterClose(t *testing.T) {
|
||||
c := newTestClient(t, "echo")
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if _, err := c.Call(context.Background(), "echo", nil); err == nil {
|
||||
t.Fatal("expected error calling closed client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientEmptyCommand(t *testing.T) {
|
||||
if _, err := NewClient(Config{}); err == nil {
|
||||
t.Fatal("expected error for empty command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDRoundTrip(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"number", `123`},
|
||||
{"string", `"abc"`},
|
||||
} {
|
||||
var id ID
|
||||
if err := json.Unmarshal([]byte(tc.raw), &id); err != nil {
|
||||
t.Fatalf("%s: unmarshal: %v", tc.name, err)
|
||||
}
|
||||
out, err := json.Marshal(id)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: marshal: %v", tc.name, err)
|
||||
}
|
||||
if string(out) != tc.raw {
|
||||
t.Fatalf("%s: round-trip got %s want %s", tc.name, out, tc.raw)
|
||||
}
|
||||
}
|
||||
var bad ID
|
||||
if err := json.Unmarshal([]byte(`true`), &bad); err == nil {
|
||||
t.Fatal("expected error for boolean id")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user