├── .codeclimate.yml ├── .github ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── actions.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── codecov.yml ├── context.go ├── context_test.go ├── debug.go ├── debug_test.go ├── doc.go ├── error.go ├── error_test.go ├── example_test.go ├── go.mod ├── go.sum ├── handler.go ├── handler_test.go ├── jsonrpc.go ├── jsonrpc_test.go ├── method.go ├── method_test.go ├── unmarshal.go └── unmarshal_test.go /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | engines: 3 | gofmt: 4 | enabled: true 5 | govet: 6 | enabled: true 7 | golint: 8 | enabled: true 9 | ratings: 10 | paths: 11 | - "*.go" 12 | exclude_paths: 13 | - vendor/ 14 | - "*_test.go" 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: osamingo 4 | custom: https://paypal.me/osamingo 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## WHAT 2 | 3 | - 4 | 5 | ## WHY 6 | 7 | - 8 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | schedule: 9 | - cron: '10 10 * * 0' 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | - uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6.5.2 18 | with: 19 | version: v1.64.8 20 | test: 21 | name: Test 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | go: [ 'stable', 'oldstable' ] 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 29 | with: 30 | go-version: ${{ matrix.go }} 31 | cache: true 32 | - run: go test -race -covermode=atomic -coverprofile=coverage.txt ./... 33 | - uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | .idea 10 | vendor 11 | 12 | # Architecture specific extensions/prefixes 13 | *.[568vq] 14 | [568vq].out 15 | 16 | *.cgo1.go 17 | *.cgo2.c 18 | _cgo_defun.c 19 | _cgo_gotypes.go 20 | _cgo_export.* 21 | 22 | _testmain.go 23 | 24 | *.exe 25 | *.test 26 | *.prof 27 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | errcheck: 3 | check-type-assertions: true 4 | check-blank: true 5 | misspell: 6 | locale: US 7 | 8 | linters: 9 | enable-all: true 10 | disable: 11 | - varnamelen 12 | - wsl 13 | - exhaustruct 14 | - depguard 15 | 16 | issues: 17 | exclude-rules: 18 | - path: _test\.go 19 | text: "does not use range value in test Run" 20 | linters: 21 | - paralleltest 22 | - path: _test\.go 23 | linters: 24 | - lll 25 | - funlen 26 | - dupword 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Osamu TONOMORI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonrpc 2 | 3 | [![CI](https://github.com/osamingo/jsonrpc/actions/workflows/actions.yml/badge.svg)](https://github.com/osamingo/jsonrpc/actions/workflows/actions.yml) 4 | [![codecov](https://codecov.io/gh/osamingo/jsonrpc/branch/master/graph/badge.svg)](https://codecov.io/gh/osamingo/jsonrpc) 5 | [![Go Report Card](https://goreportcard.com/badge/osamingo/jsonrpc)](https://goreportcard.com/report/osamingo/jsonrpc) 6 | [![codebeat badge](https://codebeat.co/badges/cbd0290d-200b-4693-80dc-296d9447c35b)](https://codebeat.co/projects/github-com-osamingo-jsonrpc) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/e820b394cdbd47103165/maintainability)](https://codeclimate.com/github/osamingo/jsonrpc/maintainability) 8 | [![GoDoc](https://godoc.org/github.com/osamingo/jsonrpc?status.svg)](https://godoc.org/github.com/osamingo/jsonrpc/v2) 9 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/osamingo/jsonrpc/master/LICENSE) 10 | 11 | ## About 12 | 13 | - Simple, Poetic, Pithy. 14 | - Compliance with [JSON-RPC 2.0](http://www.jsonrpc.org/specification). 15 | 16 | ## Install 17 | 18 | ``` 19 | $ go get github.com/osamingo/jsonrpc/v2@latest 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```go 25 | package main 26 | 27 | import ( 28 | "context" 29 | "log" 30 | "net/http" 31 | 32 | "github.com/goccy/go-json" 33 | "github.com/osamingo/jsonrpc/v2" 34 | ) 35 | 36 | type ( 37 | EchoHandler struct{} 38 | EchoParams struct { 39 | Name string `json:"name"` 40 | } 41 | EchoResult struct { 42 | Message string `json:"message"` 43 | } 44 | 45 | PositionalHandler struct{} 46 | PositionalParams []int 47 | PositionalResult struct { 48 | Message []int `json:"message"` 49 | } 50 | ) 51 | 52 | func (h EchoHandler) ServeJSONRPC(c context.Context, params *json.RawMessage) (any, *jsonrpc.Error) { 53 | 54 | var p EchoParams 55 | if err := jsonrpc.Unmarshal(params, &p); err != nil { 56 | return nil, err 57 | } 58 | 59 | return EchoResult{ 60 | Message: "Hello, " + p.Name, 61 | }, nil 62 | } 63 | 64 | func (h PositionalHandler) ServeJSONRPC(c context.Context, params *json.RawMessage) (any, **jsonrpc.Error) { 65 | 66 | var p PositionalParams 67 | if err := jsonrpc.Unmarshal(params, &p); err != nil { 68 | return nil, err 69 | } 70 | 71 | return PositionalResult{ 72 | Message: p, 73 | }, nil 74 | } 75 | 76 | func main() { 77 | 78 | mr := jsonrpc.NewMethodRepository() 79 | 80 | if err := mr.RegisterMethod("Main.Echo", EchoHandler{}, EchoParams{}, EchoResult{}); err != nil { 81 | log.Fatalln(err) 82 | } 83 | 84 | if err := mr.RegisterMethod("Main.Positional", PositionalHandler{}, PositionalParams{}, PositionalResult{}); err != nil { 85 | log.Fatalln(err) 86 | } 87 | 88 | http.Handle("/jrpc", mr) 89 | http.HandleFunc("/jrpc/debug", mr.ServeDebug) 90 | 91 | if err := http.ListenAndServe(":8080", http.DefaultServeMux); err != nil { 92 | log.Fatalln(err) 93 | } 94 | } 95 | ``` 96 | 97 | #### Advanced 98 | 99 | ```go 100 | package main 101 | 102 | import ( 103 | "log" 104 | "net/http" 105 | 106 | "github.com/osamingo/jsonrpc/v2" 107 | ) 108 | 109 | type ( 110 | HandleParamsResulter interface { 111 | jsonrpc.Handler 112 | Name() string 113 | Params() any 114 | Result() any 115 | } 116 | Servicer interface { 117 | MethodName(HandleParamsResulter) string 118 | Handlers() []HandleParamsResulter 119 | } 120 | UserService struct { 121 | SignUpHandler HandleParamsResulter 122 | SignInHandler HandleParamsResulter 123 | } 124 | ) 125 | 126 | func (us *UserService) MethodName(h HandleParamsResulter) string { 127 | return "UserService." + h.Name() 128 | } 129 | 130 | func (us *UserService) Handlers() []HandleParamsResulter { 131 | return []HandleParamsResulter{us.SignUpHandler, us.SignInHandler} 132 | } 133 | 134 | func NewUserService() *UserService { 135 | return &UserService{ 136 | // Initialize handlers 137 | } 138 | } 139 | 140 | func main() { 141 | 142 | mr := jsonrpc.NewMethodRepository() 143 | 144 | for _, s := range []Servicer{NewUserService()} { 145 | for _, h := range s.Handlers() { 146 | mr.RegisterMethod(s.MethodName(h), h, h.Params(), h.Result()) 147 | } 148 | } 149 | 150 | http.Handle("/jrpc", mr) 151 | http.HandleFunc("/jrpc/debug", mr.ServeDebug) 152 | 153 | if err := http.ListenAndServe(":8080", http.DefaultServeMux); err != nil { 154 | log.Fatalln(err) 155 | } 156 | } 157 | ``` 158 | 159 | ### Result 160 | 161 | #### Invoke the Echo method 162 | 163 | ``` 164 | POST /jrpc HTTP/1.1 165 | Accept: application/json, */* 166 | Accept-Encoding: gzip, deflate 167 | Connection: keep-alive 168 | Content-Length: 82 169 | Content-Type: application/json 170 | Host: localhost:8080 171 | User-Agent: HTTPie/0.9.6 172 | 173 | { 174 | "jsonrpc": "2.0", 175 | "method": "Main.Echo", 176 | "params": { 177 | "name": "John Doe" 178 | }, 179 | "id": "243a718a-2ebb-4e32-8cc8-210c39e8a14b" 180 | } 181 | 182 | HTTP/1.1 200 OK 183 | Content-Length: 68 184 | Content-Type: application/json 185 | Date: Mon, 28 Nov 2016 13:48:13 GMT 186 | 187 | { 188 | "jsonrpc": "2.0", 189 | "result": { 190 | "message": "Hello, John Doe" 191 | }, 192 | "id": "243a718a-2ebb-4e32-8cc8-210c39e8a14b" 193 | } 194 | ``` 195 | 196 | #### Invoke the Positional method 197 | 198 | ``` 199 | POST /jrpc HTTP/1.1 200 | Accept: */* 201 | Content-Length: 133 202 | Content-Type: application/json 203 | Host: localhost:8080 204 | User-Agent: curl/7.61.1 205 | 206 | { 207 | "jsonrpc": "2.0", 208 | "method": "Main.Positional", 209 | "params": [3,1,1,3,5,3], 210 | "id": "243a718a-2ebb-4e32-8cc8-210c39e8a14b" 211 | } 212 | 213 | HTTP/1.1 200 OK 214 | Content-Length: 97 215 | Content-Type: application/json 216 | Date: Mon, 05 Nov 2018 11:23:35 GMT 217 | 218 | { 219 | "jsonrpc": "2.0", 220 | "result": { 221 | "message": [3,1,1,3,5,3] 222 | }, 223 | "id": "243a718a-2ebb-4e32-8cc8-210c39e8a14b" 224 | } 225 | 226 | ``` 227 | 228 | 229 | #### Access to debug handler 230 | 231 | ``` 232 | GET /jrpc/debug HTTP/1.1 233 | Accept: */* 234 | Accept-Encoding: gzip, deflate 235 | Connection: keep-alive 236 | Host: localhost:8080 237 | User-Agent: HTTPie/0.9.6 238 | 239 | HTTP/1.1 200 OK 240 | Content-Length: 408 241 | Content-Type: application/json 242 | Date: Mon, 28 Nov 2016 13:56:24 GMT 243 | 244 | [ 245 | { 246 | "handler": "EchoHandler", 247 | "name": "Main.Echo", 248 | "params": { 249 | "$ref": "#/definitions/EchoParams", 250 | "$schema": "http://json-schema.org/draft-04/schema#", 251 | "definitions": { 252 | "EchoParams": { 253 | "additionalProperties": false, 254 | "properties": { 255 | "name": { 256 | "type": "string" 257 | } 258 | }, 259 | "required": [ 260 | "name" 261 | ], 262 | "type": "object" 263 | } 264 | } 265 | }, 266 | "result": { 267 | "$ref": "#/definitions/EchoResult", 268 | "$schema": "http://json-schema.org/draft-04/schema#", 269 | "definitions": { 270 | "EchoResult": { 271 | "additionalProperties": false, 272 | "properties": { 273 | "message": { 274 | "type": "string" 275 | } 276 | }, 277 | "required": [ 278 | "message" 279 | ], 280 | "type": "object" 281 | } 282 | } 283 | } 284 | } 285 | ] 286 | ``` 287 | 288 | ## License 289 | 290 | Released under the [MIT License](https://github.com/osamingo/jsonrpc/blob/master/LICENSE). 291 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | branch: master 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | 9 | status: 10 | project: 11 | default: 12 | target: auto 13 | threshold: 90 14 | branches: null 15 | 16 | patch: 17 | default: 18 | target: auto 19 | branches: null 20 | 21 | changes: 22 | default: 23 | branches: null 24 | 25 | ignore: 26 | - .*/vendor/.* 27 | 28 | comment: off 29 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/goccy/go-json" 7 | ) 8 | 9 | type ( 10 | requestIDKey struct{} 11 | metadataIDKey struct{} 12 | methodNameKey struct{} 13 | ) 14 | 15 | // RequestID takes request id from context. 16 | func RequestID(c context.Context) *json.RawMessage { 17 | v, _ := c.Value(requestIDKey{}).(*json.RawMessage) //nolint: errcheck 18 | 19 | return v 20 | } 21 | 22 | // WithRequestID adds request id to context. 23 | func WithRequestID(c context.Context, id *json.RawMessage) context.Context { 24 | return context.WithValue(c, requestIDKey{}, id) 25 | } 26 | 27 | // GetMetadata takes jsonrpc metadata from context. 28 | func GetMetadata(c context.Context) Metadata { 29 | v, _ := c.Value(metadataIDKey{}).(Metadata) //nolint: errcheck 30 | 31 | return v 32 | } 33 | 34 | // WithMetadata adds jsonrpc metadata to context. 35 | func WithMetadata(c context.Context, md Metadata) context.Context { 36 | return context.WithValue(c, metadataIDKey{}, md) 37 | } 38 | 39 | // MethodName takes method name from context. 40 | func MethodName(c context.Context) string { 41 | v, _ := c.Value(methodNameKey{}).(string) //nolint: errcheck 42 | 43 | return v 44 | } 45 | 46 | // WithMethodName adds method name to context. 47 | func WithMethodName(c context.Context, name string) context.Context { 48 | return context.WithValue(c, methodNameKey{}, name) 49 | } 50 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/goccy/go-json" 7 | "github.com/osamingo/jsonrpc/v2" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRequestID(t *testing.T) { 12 | t.Parallel() 13 | 14 | id := json.RawMessage("1") 15 | c := jsonrpc.WithRequestID(t.Context(), &id) 16 | var pick *json.RawMessage 17 | require.NotPanics(t, func() { 18 | pick = jsonrpc.RequestID(c) 19 | }) 20 | require.Equal(t, &id, pick) 21 | } 22 | 23 | func TestMetadata(t *testing.T) { 24 | t.Parallel() 25 | 26 | md := jsonrpc.Metadata{Params: jsonrpc.Metadata{}} 27 | c := jsonrpc.WithMetadata(t.Context(), md) 28 | var pick jsonrpc.Metadata 29 | require.NotPanics(t, func() { 30 | pick = jsonrpc.GetMetadata(c) 31 | }) 32 | require.Equal(t, md, pick) 33 | } 34 | 35 | func TestMethodName(t *testing.T) { 36 | t.Parallel() 37 | 38 | c := jsonrpc.WithMethodName(t.Context(), t.Name()) 39 | var pick string 40 | require.NotPanics(t, func() { 41 | pick = jsonrpc.MethodName(c) 42 | }) 43 | require.Equal(t, t.Name(), pick) 44 | } 45 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | 7 | "github.com/goccy/go-json" 8 | "github.com/invopop/jsonschema" 9 | ) 10 | 11 | // A MethodReference is a reference of JSON-RPC method. 12 | type MethodReference struct { 13 | Name string `json:"name"` 14 | Handler string `json:"handler"` 15 | Params *jsonschema.Schema `json:"params,omitempty"` 16 | Result *jsonschema.Schema `json:"result,omitempty"` 17 | } 18 | 19 | // ServeDebug views registered method list. 20 | func (mr *MethodRepository) ServeDebug(w http.ResponseWriter, r *http.Request) { 21 | ms := mr.Methods() 22 | if len(ms) == 0 { 23 | w.WriteHeader(http.StatusNotFound) 24 | 25 | return 26 | } 27 | l := make([]*MethodReference, 0, len(ms)) 28 | for k, md := range ms { 29 | l = append(l, makeMethodReference(k, md)) 30 | } 31 | w.Header().Set(contentTypeKey, contentTypeValue) 32 | if err := json.NewEncoder(w).EncodeContext(r.Context(), l); err != nil { 33 | w.WriteHeader(http.StatusInternalServerError) 34 | 35 | return 36 | } 37 | } 38 | 39 | func makeMethodReference(k string, md Metadata) *MethodReference { 40 | mr := &MethodReference{ 41 | Name: k, 42 | } 43 | tv := reflect.TypeOf(md.Handler) 44 | if tv.Kind() == reflect.Ptr { 45 | tv = tv.Elem() 46 | } 47 | mr.Handler = tv.Name() 48 | if md.Params != nil { 49 | mr.Params = jsonschema.Reflect(md.Params) 50 | } 51 | if md.Result != nil { 52 | mr.Result = jsonschema.Reflect(md.Result) 53 | } 54 | 55 | return mr 56 | } 57 | -------------------------------------------------------------------------------- /debug_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/osamingo/jsonrpc/v2" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestDebugHandler(t *testing.T) { 14 | t.Parallel() 15 | 16 | mr := jsonrpc.NewMethodRepository() 17 | 18 | rec := httptest.NewRecorder() 19 | r, err := http.NewRequestWithContext(t.Context(), "", "", nil) 20 | require.NoError(t, err) 21 | 22 | mr.ServeDebug(rec, r) 23 | 24 | require.Equal(t, http.StatusNotFound, rec.Code) 25 | 26 | require.NoError(t, mr.RegisterMethod("Debug.Sample", SampleHandler(), struct { 27 | Name string `json:"name"` 28 | }{}, struct { 29 | Message string `json:"message,omitempty"` 30 | }{})) 31 | 32 | rec = httptest.NewRecorder() 33 | r, err = http.NewRequestWithContext(t.Context(), "", "", nil) 34 | require.NoError(t, err) 35 | 36 | mr.ServeDebug(rec, r) 37 | 38 | require.Equal(t, http.StatusOK, rec.Code) 39 | assert.NotEmpty(t, rec.Body.String()) 40 | } 41 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package jsonrpc helps JSON-RPC 2.0 implements. 3 | */ 4 | package jsonrpc 5 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import "fmt" 4 | 5 | const ( 6 | // ErrorCodeParse is parse error code. 7 | ErrorCodeParse ErrorCode = -32700 8 | // ErrorCodeInvalidRequest is invalid request error code. 9 | ErrorCodeInvalidRequest ErrorCode = -32600 10 | // ErrorCodeMethodNotFound is method not found error code. 11 | ErrorCodeMethodNotFound ErrorCode = -32601 12 | // ErrorCodeInvalidParams is invalid params error code. 13 | ErrorCodeInvalidParams ErrorCode = -32602 14 | // ErrorCodeInternal is internal error code. 15 | ErrorCodeInternal ErrorCode = -32603 16 | ) 17 | 18 | type ( 19 | // A ErrorCode by JSON-RPC 2.0. 20 | ErrorCode int 21 | 22 | // An Error is a wrapper for a JSON interface value. 23 | Error struct { 24 | Code ErrorCode `json:"code"` 25 | Message string `json:"message"` 26 | Data any `json:"data,omitempty"` 27 | } 28 | ) 29 | 30 | // Error implements error interface. 31 | func (e *Error) Error() string { 32 | return fmt.Sprintf("jsonrpc: code: %d, message: %s, data: %+v", e.Code, e.Message, e.Data) 33 | } 34 | 35 | // ErrParse returns parse error. 36 | func ErrParse() *Error { 37 | return &Error{ 38 | Code: ErrorCodeParse, 39 | Message: "Parse error", 40 | } 41 | } 42 | 43 | // ErrInvalidRequest returns invalid request error. 44 | func ErrInvalidRequest() *Error { 45 | return &Error{ 46 | Code: ErrorCodeInvalidRequest, 47 | Message: "Invalid Request", 48 | } 49 | } 50 | 51 | // ErrMethodNotFound returns method not found error. 52 | func ErrMethodNotFound() *Error { 53 | return &Error{ 54 | Code: ErrorCodeMethodNotFound, 55 | Message: "Method not found", 56 | } 57 | } 58 | 59 | // ErrInvalidParams returns invalid params error. 60 | func ErrInvalidParams() *Error { 61 | return &Error{ 62 | Code: ErrorCodeInvalidParams, 63 | Message: "Invalid params", 64 | } 65 | } 66 | 67 | // ErrInternal returns internal error. 68 | func ErrInternal() *Error { 69 | return &Error{ 70 | Code: ErrorCodeInternal, 71 | Message: "Internal error", 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/osamingo/jsonrpc/v2" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestError(t *testing.T) { 12 | t.Parallel() 13 | 14 | var err any = &jsonrpc.Error{} 15 | _, ok := err.(error) 16 | require.True(t, ok) 17 | } 18 | 19 | func TestError_Error(t *testing.T) { 20 | t.Parallel() 21 | 22 | err := &jsonrpc.Error{ 23 | Code: jsonrpc.ErrorCode(100), 24 | Message: "test", 25 | Data: map[string]string{ 26 | "test": "test", 27 | }, 28 | } 29 | 30 | assert.Equal(t, "jsonrpc: code: 100, message: test, data: map[test:test]", err.Error()) 31 | } 32 | 33 | func TestErrParse(t *testing.T) { 34 | t.Parallel() 35 | 36 | err := jsonrpc.ErrParse() 37 | require.Equal(t, jsonrpc.ErrorCodeParse, err.Code) 38 | } 39 | 40 | func TestErrInvalidRequest(t *testing.T) { 41 | t.Parallel() 42 | 43 | err := jsonrpc.ErrInvalidRequest() 44 | require.Equal(t, jsonrpc.ErrorCodeInvalidRequest, err.Code) 45 | } 46 | 47 | func TestErrMethodNotFound(t *testing.T) { 48 | t.Parallel() 49 | 50 | err := jsonrpc.ErrMethodNotFound() 51 | require.Equal(t, jsonrpc.ErrorCodeMethodNotFound, err.Code) 52 | } 53 | 54 | func TestErrInvalidParams(t *testing.T) { 55 | t.Parallel() 56 | 57 | err := jsonrpc.ErrInvalidParams() 58 | require.Equal(t, jsonrpc.ErrorCodeInvalidParams, err.Code) 59 | } 60 | 61 | func TestErrInternal(t *testing.T) { 62 | t.Parallel() 63 | 64 | err := jsonrpc.ErrInternal() 65 | require.Equal(t, jsonrpc.ErrorCodeInternal, err.Code) 66 | } 67 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | 12 | "github.com/goccy/go-json" 13 | "github.com/osamingo/jsonrpc/v2" 14 | ) 15 | 16 | type ( 17 | EchoHandler struct{} 18 | EchoParams struct { 19 | Name string `json:"name"` 20 | } 21 | EchoResult struct { 22 | Message string `json:"message"` 23 | } 24 | 25 | PositionalHandler struct{} 26 | PositionalParams []int 27 | PositionalResult struct { 28 | Message []int `json:"message"` 29 | } 30 | ) 31 | 32 | func (h EchoHandler) ServeJSONRPC(_ context.Context, params *json.RawMessage) (interface{}, *jsonrpc.Error) { 33 | var p EchoParams 34 | if err := jsonrpc.Unmarshal(params, &p); err != nil { 35 | return nil, err 36 | } 37 | 38 | return EchoResult{ 39 | Message: "Hello, " + p.Name, 40 | }, nil 41 | } 42 | 43 | func (h PositionalHandler) ServeJSONRPC(_ context.Context, params *json.RawMessage) (interface{}, *jsonrpc.Error) { 44 | var p PositionalParams 45 | if err := jsonrpc.Unmarshal(params, &p); err != nil { 46 | return nil, err 47 | } 48 | 49 | return PositionalResult{ 50 | Message: p, 51 | }, nil 52 | } 53 | 54 | func ExampleMethodRepository_ServeHTTP() { //nolint: nosnakecase 55 | mr := jsonrpc.NewMethodRepository() 56 | 57 | if err := mr.RegisterMethod("Main.Echo", EchoHandler{}, EchoParams{}, EchoResult{}); err != nil { 58 | log.Println(err) 59 | 60 | return 61 | } 62 | 63 | if err := mr.RegisterMethod("Main.Positional", PositionalHandler{}, PositionalParams{}, PositionalResult{}); err != nil { 64 | log.Println(err) 65 | 66 | return 67 | } 68 | 69 | http.Handle("/jrpc", mr) 70 | http.HandleFunc("/jrpc/debug", mr.ServeDebug) 71 | 72 | srv := httptest.NewServer(http.DefaultServeMux) 73 | defer srv.Close() 74 | 75 | contextType := "application/json" 76 | echoVal := `{ 77 | "jsonrpc": "2.0", 78 | "method": "Main.Echo", 79 | "params": { 80 | "name": "John Doe" 81 | }, 82 | "id": "243a718a-2ebb-4e32-8cc8-210c39e8a14b" 83 | }` 84 | 85 | req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/jrpc", bytes.NewBufferString(echoVal)) 86 | if err != nil { 87 | log.Println(err) 88 | 89 | return 90 | } 91 | 92 | req.Header.Add("Content-Type", contextType) 93 | resp, err := http.DefaultClient.Do(req) 94 | if err != nil { 95 | log.Println(err) 96 | 97 | return 98 | } 99 | defer resp.Body.Close() 100 | if _, err := io.Copy(os.Stdout, resp.Body); err != nil { 101 | log.Println(err) 102 | 103 | return 104 | } 105 | 106 | positionalVal := `{ 107 | "jsonrpc": "2.0", 108 | "method": "Main.Positional", 109 | "params": [3, 1, 1, 3, 5, 3], 110 | "id": "243a718a-2ebb-4e32-8cc8-210c39e8a14b" 111 | }` 112 | 113 | req, err = http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/jrpc", bytes.NewBufferString(positionalVal)) 114 | if err != nil { 115 | log.Println(err) 116 | 117 | return 118 | } 119 | 120 | req.Header.Add("Content-Type", contextType) 121 | resp, err = http.DefaultClient.Do(req) 122 | if err != nil { 123 | log.Println(err) 124 | 125 | return 126 | } 127 | defer resp.Body.Close() 128 | if _, err := io.Copy(os.Stdout, resp.Body); err != nil { 129 | log.Println(err) 130 | 131 | return 132 | } 133 | 134 | // Output: 135 | // {"jsonrpc":"2.0","result":{"message":"Hello, John Doe"},"id":"243a718a-2ebb-4e32-8cc8-210c39e8a14b"} 136 | // {"jsonrpc":"2.0","result":{"message":[3,1,1,3,5,3]},"id":"243a718a-2ebb-4e32-8cc8-210c39e8a14b"} 137 | } 138 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/osamingo/jsonrpc/v2 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/goccy/go-json v0.10.5 9 | github.com/invopop/jsonschema v0.13.0 10 | github.com/stretchr/testify v1.10.0 11 | ) 12 | 13 | require ( 14 | github.com/bahlo/generic-list-go v0.2.0 // indirect 15 | github.com/buger/jsonparser v1.1.1 // indirect 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/mailru/easyjson v0.9.0 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 20 | gopkg.in/yaml.v3 v3.0.1 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 2 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 3 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 4 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 8 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 9 | github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= 10 | github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 11 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 12 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 16 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 17 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 18 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 22 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/goccy/go-json" 9 | ) 10 | 11 | // Handler links a method of JSON-RPC request. 12 | type Handler interface { 13 | ServeJSONRPC(c context.Context, params *json.RawMessage) (result any, err *Error) 14 | } 15 | 16 | // HandlerFunc type is an adapter to allow the use of 17 | // ordinary functions as JSONRPC handlers. If f is a function 18 | // with the appropriate signature, HandlerFunc(f) is a 19 | // jsonrpc.Handler that calls f. 20 | type HandlerFunc func(c context.Context, params *json.RawMessage) (result any, err *Error) 21 | 22 | // ServeJSONRPC calls f(w, r). 23 | func (f HandlerFunc) ServeJSONRPC(c context.Context, params *json.RawMessage) (any, *Error) { 24 | return f(c, params) 25 | } 26 | 27 | // ServeHTTP provides basic JSON-RPC handling. 28 | func (mr *MethodRepository) ServeHTTP(w http.ResponseWriter, r *http.Request) { 29 | rs, batch, err := ParseRequest(r) 30 | if err != nil { 31 | err := SendResponse(w, []*Response{ 32 | { 33 | Version: Version, 34 | Error: err, 35 | }, 36 | }, false) 37 | if err != nil { 38 | fmt.Fprint(w, "Failed to encode error objects") 39 | w.WriteHeader(http.StatusInternalServerError) 40 | } 41 | 42 | return 43 | } 44 | 45 | resp := make([]*Response, len(rs)) 46 | for i := range rs { 47 | resp[i] = mr.InvokeMethod(r.Context(), rs[i]) 48 | } 49 | 50 | if err := SendResponse(w, resp, batch); err != nil { 51 | fmt.Fprint(w, "Failed to encode result objects") 52 | w.WriteHeader(http.StatusInternalServerError) 53 | } 54 | } 55 | 56 | // InvokeMethod invokes JSON-RPC method. 57 | func (mr *MethodRepository) InvokeMethod(c context.Context, r *Request) *Response { 58 | var md Metadata 59 | res := NewResponse(r) 60 | md, res.Error = mr.TakeMethodMetadata(r) 61 | if res.Error != nil { 62 | return res 63 | } 64 | 65 | wrappedContext := WithRequestID(c, r.ID) 66 | wrappedContext = WithMethodName(wrappedContext, r.Method) 67 | wrappedContext = WithMetadata(wrappedContext, md) 68 | res.Result, res.Error = md.Handler.ServeJSONRPC(wrappedContext, r.Params) 69 | if res.Error != nil { 70 | res.Result = nil 71 | } 72 | 73 | return res 74 | } 75 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/goccy/go-json" 11 | "github.com/osamingo/jsonrpc/v2" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestHandler(t *testing.T) { 17 | t.Parallel() 18 | 19 | mr := jsonrpc.NewMethodRepository() 20 | 21 | rec := httptest.NewRecorder() 22 | r, err := http.NewRequestWithContext(t.Context(), "", "", nil) 23 | require.NoError(t, err) 24 | 25 | mr.ServeHTTP(rec, r) 26 | 27 | res := jsonrpc.Response{} 28 | err = json.NewDecoder(rec.Body).Decode(&res) 29 | require.NoError(t, err) 30 | assert.NotNil(t, res.Error) 31 | 32 | rec = httptest.NewRecorder() 33 | r, err = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":"test","method":"hello","params":{}}`))) 34 | require.NoError(t, err) 35 | r.Header.Set("Content-Type", "application/json") 36 | 37 | mr.ServeHTTP(rec, r) 38 | res = jsonrpc.Response{} 39 | err = json.NewDecoder(rec.Body).Decode(&res) 40 | require.NoError(t, err) 41 | assert.NotNil(t, res.Error) 42 | 43 | h1 := jsonrpc.HandlerFunc(func(_ context.Context, _ *json.RawMessage) (any, *jsonrpc.Error) { 44 | return "hello", nil 45 | }) 46 | require.NoError(t, mr.RegisterMethod("hello", h1, nil, nil)) 47 | h2 := jsonrpc.HandlerFunc(func(_ context.Context, _ *json.RawMessage) (any, *jsonrpc.Error) { 48 | return nil, jsonrpc.ErrInternal() 49 | }) 50 | require.NoError(t, mr.RegisterMethod("bye", h2, nil, nil)) 51 | 52 | rec = httptest.NewRecorder() 53 | r, err = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":"test","method":"hello","params":{}}`))) 54 | require.NoError(t, err) 55 | r.Header.Set("Content-Type", "application/json") 56 | 57 | mr.ServeHTTP(rec, r) 58 | res = jsonrpc.Response{} 59 | err = json.NewDecoder(rec.Body).Decode(&res) 60 | require.NoError(t, err) 61 | assert.Nil(t, res.Error) 62 | assert.Equal(t, "hello", res.Result) 63 | 64 | rec = httptest.NewRecorder() 65 | r, err = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":"test","method":"bye","params":{}}`))) 66 | require.NoError(t, err) 67 | r.Header.Set("Content-Type", "application/json") 68 | 69 | mr.ServeHTTP(rec, r) 70 | res = jsonrpc.Response{} 71 | err = json.NewDecoder(rec.Body).Decode(&res) 72 | require.NoError(t, err) 73 | assert.NotNil(t, res.Error) 74 | } 75 | -------------------------------------------------------------------------------- /jsonrpc.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/goccy/go-json" 10 | ) 11 | 12 | const ( 13 | // Version is JSON-RPC 2.0. 14 | Version = "2.0" 15 | 16 | batchRequestKey = '[' 17 | contentTypeKey = "Content-Type" 18 | contentTypeValue = "application/json" 19 | ) 20 | 21 | type ( 22 | // A Request represents a JSON-RPC request received by the server. 23 | Request struct { 24 | Version string `json:"jsonrpc"` 25 | Method string `json:"method"` 26 | Params *json.RawMessage `json:"params"` 27 | ID *json.RawMessage `json:"id"` 28 | } 29 | 30 | // A Response represents a JSON-RPC response returned by the server. 31 | Response struct { 32 | Version string `json:"jsonrpc"` 33 | Result any `json:"result,omitempty"` 34 | Error *Error `json:"error,omitempty"` 35 | ID *json.RawMessage `json:"id,omitempty"` 36 | } 37 | ) 38 | 39 | // ParseRequest parses a HTTP request to JSON-RPC request. 40 | func ParseRequest(r *http.Request) ([]*Request, bool, *Error) { 41 | var rerr *Error 42 | 43 | if !strings.HasPrefix(r.Header.Get(contentTypeKey), contentTypeValue) { 44 | return nil, false, ErrInvalidRequest() 45 | } 46 | 47 | buf := bytes.NewBuffer(make([]byte, 0, r.ContentLength)) 48 | if _, err := buf.ReadFrom(r.Body); err != nil { 49 | return nil, false, ErrInvalidRequest() 50 | } 51 | defer func(r *http.Request) { 52 | err := r.Body.Close() 53 | if err != nil { 54 | rerr = ErrInternal() 55 | } 56 | }(r) 57 | 58 | if buf.Len() == 0 { 59 | return nil, false, ErrInvalidRequest() 60 | } 61 | 62 | f, _, err := buf.ReadRune() 63 | if err != nil { 64 | return nil, false, ErrInvalidRequest() 65 | } 66 | if err := buf.UnreadRune(); err != nil { 67 | return nil, false, ErrInvalidRequest() 68 | } 69 | 70 | var rs []*Request 71 | if f != batchRequestKey { 72 | var req *Request 73 | if err := json.NewDecoder(buf).Decode(&req); err != nil { 74 | return nil, false, ErrParse() 75 | } 76 | 77 | return append(rs, req), false, nil 78 | } 79 | 80 | if err := json.NewDecoder(buf).Decode(&rs); err != nil { 81 | return nil, false, ErrParse() 82 | } 83 | 84 | return rs, true, rerr 85 | } 86 | 87 | // NewResponse generates a JSON-RPC response. 88 | func NewResponse(r *Request) *Response { 89 | return &Response{ 90 | Version: r.Version, 91 | ID: r.ID, 92 | } 93 | } 94 | 95 | // SendResponse writes JSON-RPC response. 96 | func SendResponse(w http.ResponseWriter, resp []*Response, batch bool) error { 97 | w.Header().Set(contentTypeKey, contentTypeValue) 98 | if batch || len(resp) > 1 { 99 | if err := json.NewEncoder(w).Encode(resp); err != nil { 100 | return fmt.Errorf("jsonrpc: failed to encode: %w", err) 101 | } 102 | } else if len(resp) == 1 { 103 | if err := json.NewEncoder(w).Encode(resp[0]); err != nil { 104 | return fmt.Errorf("jsonrpc: failed to encode: %w", err) 105 | } 106 | } 107 | 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /jsonrpc_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc_test 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/goccy/go-json" 10 | "github.com/osamingo/jsonrpc/v2" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestParseRequest(t *testing.T) { 16 | t.Parallel() 17 | 18 | r, rerr := http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader(nil)) 19 | require.NoError(t, rerr) 20 | 21 | _, _, err := jsonrpc.ParseRequest(r) 22 | require.IsType(t, &jsonrpc.Error{}, err) 23 | assert.Equal(t, jsonrpc.ErrorCodeInvalidRequest, err.Code) 24 | 25 | r.Header.Set("Content-Type", "application/json") 26 | _, _, err = jsonrpc.ParseRequest(r) 27 | require.IsType(t, &jsonrpc.Error{}, err) 28 | assert.Equal(t, jsonrpc.ErrorCodeInvalidRequest, err.Code) 29 | 30 | r, rerr = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte(""))) 31 | require.NoError(t, rerr) 32 | 33 | r.Header.Set("Content-Type", "application/json") 34 | _, _, err = jsonrpc.ParseRequest(r) 35 | require.IsType(t, &jsonrpc.Error{}, err) 36 | assert.Equal(t, jsonrpc.ErrorCodeInvalidRequest, err.Code) 37 | 38 | r, rerr = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte("test"))) 39 | require.NoError(t, rerr) 40 | 41 | r.Header.Set("Content-Type", "application/json") 42 | _, _, err = jsonrpc.ParseRequest(r) 43 | require.IsType(t, &jsonrpc.Error{}, err) 44 | assert.Equal(t, jsonrpc.ErrorCodeParse, err.Code) 45 | 46 | r, rerr = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte("{}"))) 47 | require.NoError(t, rerr) 48 | 49 | r.Header.Set("Content-Type", "application/json") 50 | rs, batch, err := jsonrpc.ParseRequest(r) 51 | require.Nil(t, err) 52 | require.NotEmpty(t, rs) 53 | assert.False(t, batch) 54 | 55 | r, rerr = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte("["))) 56 | require.NoError(t, rerr) 57 | 58 | r.Header.Set("Content-Type", "application/json") 59 | _, _, err = jsonrpc.ParseRequest(r) 60 | require.IsType(t, &jsonrpc.Error{}, err) 61 | assert.Equal(t, jsonrpc.ErrorCodeParse, err.Code) 62 | 63 | r, rerr = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte("[test]"))) 64 | require.NoError(t, rerr) 65 | 66 | r.Header.Set("Content-Type", "application/json") 67 | _, _, err = jsonrpc.ParseRequest(r) 68 | require.IsType(t, &jsonrpc.Error{}, err) 69 | assert.Equal(t, jsonrpc.ErrorCodeParse, err.Code) 70 | 71 | r, rerr = http.NewRequestWithContext(t.Context(), "", "", bytes.NewReader([]byte("[{}]"))) 72 | require.NoError(t, rerr) 73 | 74 | r.Header.Set("Content-Type", "application/json") 75 | rs, batch, err = jsonrpc.ParseRequest(r) 76 | require.Nil(t, err) 77 | require.NotEmpty(t, rs) 78 | assert.True(t, batch) 79 | } 80 | 81 | func TestNewResponse(t *testing.T) { 82 | t.Parallel() 83 | 84 | id := json.RawMessage("test") 85 | r := jsonrpc.NewResponse(&jsonrpc.Request{ 86 | Version: "2.0", 87 | ID: &id, 88 | }) 89 | assert.Equal(t, "2.0", r.Version) 90 | assert.Equal(t, "test", string(*r.ID)) 91 | } 92 | 93 | func TestSendResponse(t *testing.T) { 94 | t.Parallel() 95 | 96 | rec := httptest.NewRecorder() 97 | err := jsonrpc.SendResponse(rec, []*jsonrpc.Response{}, false) 98 | require.NoError(t, err) 99 | assert.Empty(t, rec.Body.String()) 100 | 101 | id := json.RawMessage([]byte(`"test"`)) 102 | r := &jsonrpc.Response{ 103 | ID: &id, 104 | Version: "2.0", 105 | Result: struct { 106 | Name string `json:"name"` 107 | }{ 108 | Name: "john", 109 | }, 110 | } 111 | 112 | rec = httptest.NewRecorder() 113 | err = jsonrpc.SendResponse(rec, []*jsonrpc.Response{r}, false) 114 | require.NoError(t, err) 115 | assert.JSONEq(t, `{"jsonrpc":"2.0","result":{"name":"john"},"id":"test"} 116 | `, rec.Body.String()) 117 | 118 | rec = httptest.NewRecorder() 119 | err = jsonrpc.SendResponse(rec, []*jsonrpc.Response{r}, true) 120 | require.NoError(t, err) 121 | assert.JSONEq(t, `[{"jsonrpc":"2.0","result":{"name":"john"},"id":"test"}] 122 | `, rec.Body.String()) 123 | 124 | rec = httptest.NewRecorder() 125 | err = jsonrpc.SendResponse(rec, []*jsonrpc.Response{r, r}, false) 126 | require.NoError(t, err) 127 | assert.JSONEq(t, `[{"jsonrpc":"2.0","result":{"name":"john"},"id":"test"},{"jsonrpc":"2.0","result":{"name":"john"},"id":"test"}] 128 | `, rec.Body.String()) 129 | } 130 | -------------------------------------------------------------------------------- /method.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | type ( 9 | // A MethodRepository has JSON-RPC method functions. 10 | MethodRepository struct { 11 | m sync.RWMutex 12 | r map[string]Metadata 13 | } 14 | // Metadata has method meta data. 15 | Metadata struct { 16 | Handler Handler 17 | Params any 18 | Result any 19 | } 20 | ) 21 | 22 | // NewMethodRepository returns new MethodRepository. 23 | func NewMethodRepository() *MethodRepository { 24 | return &MethodRepository{ 25 | m: sync.RWMutex{}, 26 | r: map[string]Metadata{}, 27 | } 28 | } 29 | 30 | // TakeMethodMetadata takes metadata in MethodRepository for request. 31 | func (mr *MethodRepository) TakeMethodMetadata(r *Request) (Metadata, *Error) { 32 | if r.Method == "" || r.Version != Version { 33 | return Metadata{}, ErrInvalidParams() 34 | } 35 | 36 | mr.m.RLock() 37 | md, ok := mr.r[r.Method] 38 | mr.m.RUnlock() 39 | if !ok { 40 | return Metadata{}, ErrMethodNotFound() 41 | } 42 | 43 | return md, nil 44 | } 45 | 46 | // TakeMethod takes jsonrpc.Func in MethodRepository. 47 | func (mr *MethodRepository) TakeMethod(r *Request) (Handler, *Error) { //nolint: ireturn 48 | md, err := mr.TakeMethodMetadata(r) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return md.Handler, nil 54 | } 55 | 56 | // RegisterMethod registers jsonrpc.Func to MethodRepository. 57 | func (mr *MethodRepository) RegisterMethod(method string, h Handler, params, result any) error { 58 | if method == "" || h == nil { 59 | return errors.New("jsonrpc: method name and function should not be empty") //nolint: goerr113 60 | } 61 | mr.m.Lock() 62 | mr.r[method] = Metadata{ 63 | Handler: h, 64 | Params: params, 65 | Result: result, 66 | } 67 | mr.m.Unlock() 68 | 69 | return nil 70 | } 71 | 72 | // Methods returns registered methods. 73 | func (mr *MethodRepository) Methods() map[string]Metadata { 74 | mr.m.RLock() 75 | ml := make(map[string]Metadata, len(mr.r)) 76 | for k, md := range mr.r { 77 | ml[k] = md 78 | } 79 | mr.m.RUnlock() 80 | 81 | return ml 82 | } 83 | -------------------------------------------------------------------------------- /method_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/goccy/go-json" 8 | "github.com/osamingo/jsonrpc/v2" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestTakeMethod(t *testing.T) { 14 | t.Parallel() 15 | 16 | mr := jsonrpc.NewMethodRepository() 17 | 18 | r := &jsonrpc.Request{} 19 | _, err := mr.TakeMethod(r) 20 | require.IsType(t, &jsonrpc.Error{}, err) 21 | assert.Equal(t, jsonrpc.ErrorCodeInvalidParams, err.Code) 22 | 23 | r.Method = "test" 24 | _, err = mr.TakeMethod(r) 25 | require.IsType(t, &jsonrpc.Error{}, err) 26 | assert.Equal(t, jsonrpc.ErrorCodeInvalidParams, err.Code) 27 | 28 | r.Version = "2.0" 29 | _, err = mr.TakeMethod(r) 30 | require.IsType(t, &jsonrpc.Error{}, err) 31 | assert.Equal(t, jsonrpc.ErrorCodeMethodNotFound, err.Code) 32 | 33 | require.NoError(t, mr.RegisterMethod("test", SampleHandler(), nil, nil)) 34 | 35 | f, err := mr.TakeMethod(r) 36 | require.Nil(t, err) 37 | assert.NotEmpty(t, f) 38 | } 39 | 40 | func TestRegisterMethod(t *testing.T) { 41 | t.Parallel() 42 | 43 | mr := jsonrpc.NewMethodRepository() 44 | 45 | err := mr.RegisterMethod("", nil, nil, nil) 46 | require.Error(t, err) 47 | 48 | err = mr.RegisterMethod("test", nil, nil, nil) 49 | require.Error(t, err) 50 | 51 | err = mr.RegisterMethod("test", SampleHandler(), nil, nil) 52 | require.NoError(t, err) 53 | } 54 | 55 | func TestMethods(t *testing.T) { 56 | t.Parallel() 57 | 58 | mr := jsonrpc.NewMethodRepository() 59 | 60 | err := mr.RegisterMethod("JsonRpc.Sample", SampleHandler(), nil, nil) 61 | require.NoError(t, err) 62 | 63 | ml := mr.Methods() 64 | require.NotEmpty(t, ml) 65 | assert.NotEmpty(t, ml["JsonRpc.Sample"].Handler) 66 | } 67 | 68 | func SampleHandler() *jsonrpc.HandlerFunc { 69 | h := jsonrpc.HandlerFunc(func(_ context.Context, _ *json.RawMessage) (any, *jsonrpc.Error) { 70 | return (any)(nil), nil 71 | }) 72 | 73 | return &h 74 | } 75 | -------------------------------------------------------------------------------- /unmarshal.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import "github.com/goccy/go-json" 4 | 5 | // Unmarshal decodes JSON-RPC params. 6 | func Unmarshal(params *json.RawMessage, dst any) *Error { 7 | if params == nil { 8 | return ErrInvalidParams() 9 | } 10 | if err := json.Unmarshal(*params, dst); err != nil { 11 | return ErrInvalidParams() 12 | } 13 | 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /unmarshal_test.go: -------------------------------------------------------------------------------- 1 | package jsonrpc_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/goccy/go-json" 7 | "github.com/osamingo/jsonrpc/v2" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestUnmarshal(t *testing.T) { 13 | t.Parallel() 14 | 15 | err := jsonrpc.Unmarshal(nil, nil) 16 | require.IsType(t, &jsonrpc.Error{}, err) 17 | assert.Equal(t, jsonrpc.ErrorCodeInvalidParams, err.Code) 18 | 19 | src := json.RawMessage([]byte(`{"name":"john"}`)) 20 | 21 | err = jsonrpc.Unmarshal(&src, nil) 22 | require.IsType(t, &jsonrpc.Error{}, err) 23 | assert.Equal(t, jsonrpc.ErrorCodeInvalidParams, err.Code) 24 | 25 | dst := struct { 26 | Name string `json:"name"` 27 | }{} 28 | 29 | err = jsonrpc.Unmarshal(&src, &dst) 30 | require.Nil(t, err) 31 | assert.Equal(t, "john", dst.Name) 32 | } 33 | --------------------------------------------------------------------------------