Skip to content

Commit

Permalink
Add middlewares and validation support (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
vearutop authored Jun 27, 2021
1 parent 54dc028 commit c342ca3
Show file tree
Hide file tree
Showing 10 changed files with 488 additions and 23 deletions.
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,74 @@
![Comments](https://sloc.xyz/github/swaggest/jsonrpc/?category=comments)

Self-documented [JSON-RPC](https://www.jsonrpc.org/) 2.0 server in Go.

## Example

```go
package main

import (
"context"
"log"
"net/http"

"github.com/go-chi/chi/v5"
"github.com/swaggest/jsonrpc"
"github.com/swaggest/swgui"
"github.com/swaggest/swgui/v3cdn"
"github.com/swaggest/usecase"
)

func main() {
apiSchema := jsonrpc.OpenAPI{}
apiSchema.Reflector().SpecEns().Info.Title = "JSON-RPC Example"
apiSchema.Reflector().SpecEns().Info.Version = "v1.2.3"

apiSchema.Reflector().SpecEns().Info.WithDescription("This app showcases a trivial JSON-RPC API.")

h := &jsonrpc.Handler{}
h.OpenAPI = &apiSchema
h.Validator = &jsonrpc.JSONSchemaValidator{}
h.SkipResultValidation = true

type inp struct {
Name string `json:"name"`
}

type out struct {
Len int `json:"len"`
}

u := usecase.NewIOI(new(inp), new(out), func(ctx context.Context, input, output interface{}) error {
output.(*out).Len = len(input.(*inp).Name)

return nil
})
u.SetName("nameLength")

h.Add(u)

r := chi.NewRouter()

r.Mount("/rpc", h)

// Swagger UI endpoint at /docs.
r.Method(http.MethodGet, "/docs/openapi.json", h.OpenAPI)

r.Mount("/docs", v3cdn.NewHandlerWithConfig(swgui.Config{
Title: apiSchema.Reflector().Spec.Info.Title,
SwaggerJSON: "/docs/openapi.json",
BasePath: "/docs",
SettingsUI: jsonrpc.SwguiSettings(nil, "/rpc"),
}))

// Start server.
log.Println("http://localhost:8011/docs")

if err := http.ListenAndServe(":8011", r); err != nil {
log.Fatal(err)
}
}
```

![Documentation Page](./example/screen.png)
47 changes: 47 additions & 0 deletions error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package jsonrpc

import (
"sort"

"github.com/swaggest/usecase/status"
)

// ErrWithFields exposes structured context of error.
type ErrWithFields interface {
error
Fields() map[string]interface{}
}

// ErrWithAppCode exposes application error code.
type ErrWithAppCode interface {
error
AppErrCode() int
}

// ErrWithCanonicalStatus exposes canonical status code.
type ErrWithCanonicalStatus interface {
error
Status() status.Code
}

// ValidationErrors is a list of validation errors.
//
// Key is field position (e.g. "path:id" or "body"), value is a list of issues with the field.
type ValidationErrors map[string][]string

// Error returns error message.
func (re ValidationErrors) Error() string {
return "validation failed"
}

// Fields returns request errors by field location and name.
func (re ValidationErrors) Fields() map[string]interface{} {
res := make(map[string]interface{}, len(re))

for k, v := range re {
sort.Strings(v)
res[k] = v
}

return res
}
2 changes: 2 additions & 0 deletions example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ func main() {

h := &jsonrpc.Handler{}
h.OpenAPI = &apiSchema
h.Validator = &jsonrpc.JSONSchemaValidator{}
h.SkipResultValidation = true

type inp struct {
Name string `json:"name"`
Expand Down
Binary file added example/screen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ go 1.13
require (
github.com/bool64/dev v0.1.35
github.com/go-chi/chi/v5 v5.0.3
github.com/santhosh-tekuri/jsonschema/v2 v2.2.0
github.com/stretchr/testify v1.7.0
github.com/swaggest/openapi-go v0.2.10
github.com/swaggest/swgui v1.3.0
github.com/swaggest/usecase v0.1.5
github.com/swaggest/usecase v1.0.0
)
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ github.com/onsi/gomega v1.11.0 h1:+CqWgvj0OZycCaqclBD1pxKHAU+tOkHmQIWvDHq2aug=
github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQLTUg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/santhosh-tekuri/jsonschema/v2 v2.2.0 h1:72xCpK0g27Y1is2lreGNcZhIX3ZCtRpkHvvHrHD+5y4=
github.com/santhosh-tekuri/jsonschema/v2 v2.2.0/go.mod h1:yzJzKUGV4RbWqWIBBP4wSOBqavX5saE02yirLS0OTyg=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
Expand All @@ -74,8 +76,8 @@ github.com/swaggest/refl v0.1.7 h1:pK2nWacMS6MIgeEdRVfmNUAxKih6vHIUF59osZrxpmY=
github.com/swaggest/refl v0.1.7/go.mod h1:acYd5x8NNxivp+ZHdRZKJYz66n/qjo3Q9Sa/jAivljQ=
github.com/swaggest/swgui v1.3.0 h1:hPzaNd+HHdegOG5/8qpgDfdIgMDGyeKkLHG0lBBFTJw=
github.com/swaggest/swgui v1.3.0/go.mod h1:+9tXqmbyDcm5ek5N4KvNyVlTUDmRh1Oiy4pFEJr+sDg=
github.com/swaggest/usecase v0.1.5 h1:xMDWXnYGysVaF2f3ZnmDsn2FlZ8fd3FJD+O+8wl4aNQ=
github.com/swaggest/usecase v0.1.5/go.mod h1:uubX4ZbjQK1Bnl0xX9hOYpb/IUiSoVKk/yQImawbNMU=
github.com/swaggest/usecase v1.0.0 h1:dQzinraSOR7bLen5FuM3Rsp8o/sk1EVv1bayX4MhuWY=
github.com/swaggest/usecase v1.0.0/go.mod h1:uubX4ZbjQK1Bnl0xX9hOYpb/IUiSoVKk/yQImawbNMU=
github.com/vearutop/statigz v1.1.3/go.mod h1:GrH7TtlVmNG1kSQCCL1ZcxxBW2Au+Bw/5KxEJjaY6TY=
github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
Expand Down
106 changes: 90 additions & 16 deletions handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,20 @@ const ver = "2.0"

// Handler serves JSON-RPC 2.0 methods with HTTP.
type Handler struct {
OpenAPI *OpenAPI
OpenAPI *OpenAPI
Validator Validator
Middlewares []usecase.Middleware

SkipParamsValidation bool
SkipResultValidation bool

methods map[string]method
}

type method struct {
// failingUseCase allows to pass input decoding error through use case middlewares.
failingUseCase usecase.Interactor

useCase usecase.Interactor

inputBufferType reflect.Type
Expand Down Expand Up @@ -74,6 +82,8 @@ func (h *method) setupOutputBuffer() {
}
}

type errCtxKey struct{}

// Add registers use case interactor as JSON-RPC method.
func (h *Handler) Add(u usecase.Interactor) {
if h.methods == nil {
Expand All @@ -86,16 +96,24 @@ func (h *Handler) Add(u usecase.Interactor) {
panic("use case name is required")
}

var fu usecase.Interactor = usecase.Interact(func(ctx context.Context, input, output interface{}) error {
return ctx.Value(errCtxKey{}).(error)
})

u = usecase.Wrap(u, h.Middlewares...)
fu = usecase.Wrap(fu, h.Middlewares...)

m := method{
useCase: u,
useCase: u,
failingUseCase: fu,
}
m.setupInputBuffer()
m.setupOutputBuffer()

h.methods[withName.Name()] = m

if h.OpenAPI != nil {
err := h.OpenAPI.Collect(withName.Name(), u)
err := h.OpenAPI.Collect(withName.Name(), u, h.Validator)
if err != nil {
panic(fmt.Sprintf("failed to add to OpenAPI schema: %s", err.Error()))
}
Expand All @@ -120,9 +138,9 @@ type Response struct {

// Error describes JSON-RPC error structure.
type Error struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Data *interface{} `json:"data,omitempty"`
Code ErrorCode `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}

var errEmptyBody = errors.New("empty body")
Expand Down Expand Up @@ -244,6 +262,11 @@ func (h *Handler) serveBatch(ctx context.Context, w http.ResponseWriter, reqBody
}
}

type structuredErrorData struct {
Error string `json:"error"`
Context map[string]interface{} `json:"context"`
}

func (h *Handler) invoke(ctx context.Context, req Request, resp *Response) {
var input, output interface{}

Expand All @@ -259,12 +282,7 @@ func (h *Handler) invoke(ctx context.Context, req Request, resp *Response) {

if m.inputBufferType != nil {
input = reflect.New(m.inputBufferType).Interface()
if err := json.Unmarshal(req.Params, input); err != nil {
resp.Error = &Error{
Code: CodeInvalidParams,
Message: fmt.Sprintf("failed to unmarshal parameters: %s", err.Error()),
}

if !h.decode(ctx, m, req, resp, input) {
return
}
}
Expand All @@ -274,14 +292,15 @@ func (h *Handler) invoke(ctx context.Context, req Request, resp *Response) {
}

if err := m.useCase.Interact(ctx, input, output); err != nil {
resp.Error = &Error{
Code: CodeInternalError,
Message: err.Error(),
}
h.errResp(resp, "operation failed", CodeInternalError, err)

return
}

h.encode(ctx, m, req, resp, output)
}

func (h *Handler) encode(ctx context.Context, m method, req Request, resp *Response, output interface{}) {
data, err := json.Marshal(output)
if err != nil {
resp.Error = &Error{
Expand All @@ -292,9 +311,64 @@ func (h *Handler) invoke(ctx context.Context, req Request, resp *Response) {
return
}

if h.Validator != nil && !h.SkipResultValidation {
if err := h.Validator.ValidateResult(req.Method, data); err != nil {
if m.failingUseCase != nil {
err = m.failingUseCase.Interact(context.WithValue(ctx, errCtxKey{}, err), nil, nil)
}

h.errResp(resp, "invalid result", CodeInternalError, err)

return
}
}

resp.Result = data
}

func (h *Handler) decode(ctx context.Context, m method, req Request, resp *Response, input interface{}) bool {
if err := json.Unmarshal(req.Params, input); err != nil {
if m.failingUseCase != nil {
err = m.failingUseCase.Interact(context.WithValue(ctx, errCtxKey{}, err), nil, nil)
}

h.errResp(resp, "failed to unmarshal parameters", CodeInvalidParams, err)

return false
}

if h.Validator != nil && !h.SkipParamsValidation {
if err := h.Validator.ValidateParams(req.Method, req.Params); err != nil {
if m.failingUseCase != nil {
err = m.failingUseCase.Interact(context.WithValue(ctx, errCtxKey{}, err), nil, nil)
}

h.errResp(resp, "invalid parameters", CodeInvalidParams, err)

return false
}
}

return true
}

func (h *Handler) errResp(resp *Response, msg string, code ErrorCode, err error) {
resp.Error = &Error{
Code: code,
Message: msg,
}

var se ErrWithFields
if errors.As(err, &se) {
resp.Error.Data = structuredErrorData{
Error: se.Error(),
Context: se.Fields(),
}
} else if err != nil {
resp.Error.Data = err.Error()
}
}

func (h *Handler) fail(w http.ResponseWriter, err error, code ErrorCode) {
resp := Response{
JSONRPC: ver,
Expand Down
Loading

0 comments on commit c342ca3

Please sign in to comment.