diff --git a/app/client/cli/servicer.go b/app/client/cli/servicer.go index 0ed35dff4..ee7012e31 100644 --- a/app/client/cli/servicer.go +++ b/app/client/cli/servicer.go @@ -243,16 +243,35 @@ func sendTrustlessRelay(ctx context.Context, servicerUrl string, relay *rpc.Rela return client.PostV1ClientRelayWithResponse(ctx, *relay) } -func buildRelay(payload string, appPrivateKey crypto.PrivateKey, session *rpc.Session, servicer *rpc.ProtocolActor) (*rpc.RelayRequest, error) { +// IMPROVE(#958) configure acceptable payload type for each service +// unmarshalPayload attempts to deserialize the provided relay payload into one of the supported types. +// +// It returns an error if none of the supported types is a valid match for the provided payload. +func unmarshalRelayPayload(payload string) (*rpc.RelayRequest, error) { // TECHDEBT: This is mostly COPIED from pocket-go: we should refactor pocket-go code and import this functionality from there instead. - relayPayload := rpc.Payload{ - // INCOMPLETE(#803): need to unmarshal into JSONRPC and other supported relay formats once proto-generated custom types are added. - Jsonrpc: "2.0", - Method: payload, - // INCOMPLETE: set Headers for HTTP relays + var jsonRpcPayload rpc.JSONRPCPayload + // INCOMPLETE: set Headers for HTTP relays + if err := json.Unmarshal([]byte(payload), &jsonRpcPayload); err == nil && jsonRpcPayload.Validate() == nil { + return &rpc.RelayRequest{Payload: &jsonRpcPayload}, nil + } + + // JSONRPC deserialization failed, assume the relay to be in REST format, i.e. any valid JSON is accepted. + var restPayload json.RawMessage + if err := json.Unmarshal([]byte(payload), &restPayload); err == nil { + bz := []byte(restPayload) + return &rpc.RelayRequest{Payload: &bz}, nil + } + + return nil, fmt.Errorf("error unmarshalling relay payload %s", payload) +} + +func buildRelay(payload string, appPrivateKey crypto.PrivateKey, session *rpc.Session, servicer *rpc.ProtocolActor) (*rpc.RelayRequest, error) { + relay, err := unmarshalRelayPayload(payload) + if err != nil { + return nil, fmt.Errorf("error unmarshalling relay payload %s", payload) } - relayMeta := rpc.RelayRequestMeta{ + relay.Meta = rpc.RelayRequestMeta{ BlockHeight: session.SessionHeight, // TODO: Make Chain Identifier type consistent in Session and Meta use Identifiable for Chain in Session (or string for Chain in Relay Meta) Chain: rpc.Identifiable{ @@ -262,10 +281,6 @@ func buildRelay(payload string, appPrivateKey crypto.PrivateKey, session *rpc.Se // TODO(#697): Geozone } - relay := &rpc.RelayRequest{ - Payload: relayPayload, - Meta: relayMeta, - } // TECHDEBT: Evaluate which fields we should and shouldn't marshall when signing the payload reqBytes, err := json.Marshal(relay) if err != nil { diff --git a/app/client/cli/servicer_test.go b/app/client/cli/servicer_test.go index cd84a1e87..f1a50df9e 100644 --- a/app/client/cli/servicer_test.go +++ b/app/client/cli/servicer_test.go @@ -71,6 +71,53 @@ func TestGetSessionFromCache(t *testing.T) { } } +func TestUnmarshalRelay(t *testing.T) { + restPayload := rpc.RESTPayload(`{"field1":"value1"}`) + + testCases := []struct { + name string + payload string + expected *rpc.RelayRequest + expectErr bool + }{ + { + name: "JSONRPC payload", + payload: `{"jsonrpc": "2.0", "id": "1", "method": "eth_blockNumber"}`, + expected: &rpc.RelayRequest{ + Payload: &rpc.JSONRPCPayload{ + Jsonrpc: "2.0", + Method: "eth_blockNumber", + Id: &rpc.JsonRpcId{Id: []byte("1")}, + }, + }, + }, + { + name: "REST payload", + payload: `{"field1":"value1"}`, + expected: &rpc.RelayRequest{ + Payload: &restPayload, + }, + }, + { + name: "Payload with invalid format is rejected", + payload: "foo", + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := unmarshalRelayPayload(tc.payload) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.EqualValues(t, *tc.expected, *got) + }) + } +} + func testSession(appAddr string, height int64) *rpc.Session { const numSessionBlocks = 4 diff --git a/rpc/handlers.go b/rpc/handlers.go index e222b3e66..d62d72442 100644 --- a/rpc/handlers.go +++ b/rpc/handlers.go @@ -2,6 +2,9 @@ package rpc import ( "encoding/hex" + "encoding/json" + "errors" + "fmt" "net/http" "github.com/labstack/echo/v4" @@ -9,6 +12,8 @@ import ( coreTypes "github.com/pokt-network/pocket/shared/core/types" ) +var errInvalidJsonRpc = errors.New("JSONRPC validation failed") + // CONSIDER: Remove all the V1 prefixes from the RPC module func (s *rpcServer) GetV1Health(ctx echo.Context) error { @@ -83,9 +88,7 @@ func (s *rpcServer) PostV1ClientGetSession(ctx echo.Context) error { } func (s *rpcServer) PostV1ClientRelay(ctx echo.Context) error { - utility := s.GetBus().GetUtilityModule() - _, err := utility.GetServicerModule() - + servicer, err := s.GetBus().GetUtilityModule().GetServicerModule() if err != nil { return ctx.String(http.StatusInternalServerError, "node is not a servicer") } @@ -112,10 +115,18 @@ func (s *rpcServer) PostV1ClientRelay(ctx echo.Context) error { Signature: body.Meta.Signature, } - relayRequest := buildJsonRPCRelayPayload(&body) + var relayRequest *coreTypes.Relay + switch p := body.Payload.(type) { + case JSONRPCPayload: + relayRequest = buildJsonRPCRelayPayload(&p) + case RESTPayload: + relayRequest = buildRestRelayPayload(&p) + default: + return ctx.String(http.StatusBadRequest, "unsupported relay type") + } relayRequest.Meta = relayMeta - relayResponse, err := utility.HandleRelay(relayRequest) + relayResponse, err := servicer.HandleRelay(relayRequest) if err != nil { return ctx.String(http.StatusInternalServerError, err.Error()) } @@ -217,25 +228,25 @@ func (s *rpcServer) GetV1P2pStakedActorsAddressBook(ctx echo.Context, params Get } // TECHDEBT: handle other relay payload types, e.g. JSON, GRPC, etc. -func buildJsonRPCRelayPayload(body *RelayRequest) *coreTypes.Relay { +func buildJsonRPCRelayPayload(src *JSONRPCPayload) *coreTypes.Relay { payload := &coreTypes.Relay_JsonRpcPayload{ JsonRpcPayload: &coreTypes.JSONRPCPayload{ - JsonRpc: body.Payload.Jsonrpc, - Method: body.Payload.Method, + JsonRpc: src.Jsonrpc, + Method: src.Method, }, } - if body.Payload.Id != nil { - payload.JsonRpcPayload.Id = []byte(*body.Payload.Id) + if src.Id != nil { + payload.JsonRpcPayload.Id = src.Id.Id } - if body.Payload.Parameters != nil { - payload.JsonRpcPayload.Parameters = *body.Payload.Parameters + if src.Parameters != nil { + payload.JsonRpcPayload.Parameters = *src.Parameters } - if body.Payload.Headers != nil { + if src.Headers != nil { headers := make(map[string]string) - for _, header := range *body.Payload.Headers { + for _, header := range *src.Headers { headers[header.Name] = header.Value } payload.JsonRpcPayload.Headers = headers @@ -245,3 +256,79 @@ func buildJsonRPCRelayPayload(body *RelayRequest) *coreTypes.Relay { RelayPayload: payload, } } + +// DISCUSS: Path and Method requirements of relays in REST format. +func buildRestRelayPayload(src *RESTPayload) *coreTypes.Relay { + return &coreTypes.Relay{ + RelayPayload: &coreTypes.Relay_RestPayload{ + RestPayload: &coreTypes.RESTPayload{ + Contents: *src, + }, + }, + } +} + +// UnmarshalJSON is the custom unmarshaller for RelayRequest type. This is needed because the payload could be JSONRPC or REST. +func (r *RelayRequest) UnmarshalJSON(data []byte) error { + type relayWithJsonRpcPayload struct { + Meta RelayRequestMeta `json:"meta"` + Payload JSONRPCPayload `json:"payload"` + } + var jsonRpcRelay relayWithJsonRpcPayload + if err := json.Unmarshal(data, &jsonRpcRelay); err == nil && jsonRpcRelay.Payload.Validate() == nil { + r.Meta = jsonRpcRelay.Meta + r.Payload = jsonRpcRelay.Payload + return nil + } + + type relayWithRestPayload struct { + Meta RelayRequestMeta `json:"meta"` + Payload RESTPayload `json:"payload"` + } + var restRelay relayWithRestPayload + if err := json.Unmarshal(data, &restRelay); err == nil { + r.Meta = restRelay.Meta + r.Payload = restRelay.Payload + return nil + } + + return fmt.Errorf("invalid relay: %s", string(data)) +} + +// Validate returns an error if the payload struct is not valid JSONRPC +func (p *JSONRPCPayload) Validate() error { + if p.Method == "" { + return fmt.Errorf("%w: missing method field", errInvalidJsonRpc) + } + + if p.Jsonrpc != "2.0" { + return fmt.Errorf("%w: invalid JSONRPC field value: %q", errInvalidJsonRpc, p.Jsonrpc) + } + + return nil +} + +// UnmarshalJSON is the custom unmarshaller for JsonRpcId type. It is needed because JSONRPC spec allows the "id" field to be nil, an integer, or a string. +// +// See the following link for more details: +// https://www.jsonrpc.org/specification#request_object +func (i *JsonRpcId) UnmarshalJSON(data []byte) error { + var v int64 + if err := json.Unmarshal(data, &v); err == nil { + i.Id = []byte(fmt.Sprintf("%d", v)) + return nil + } + + var s string + if err := json.Unmarshal(data, &s); err == nil { + i.Id = []byte(s) + return nil + } + + return fmt.Errorf("invalid JSONRPC ID value: %v", data) +} + +// MarshalJSON is the custom marshaller for JsonRpcId type. It is needed to flatten the struct as a single value, i.e. mask the "Id" field. +func (i *JsonRpcId) MarshalJSON() ([]byte, error) { + return i.Id, nil +} diff --git a/rpc/handlers_test.go b/rpc/handlers_test.go new file mode 100644 index 000000000..d9fdc9744 --- /dev/null +++ b/rpc/handlers_test.go @@ -0,0 +1,114 @@ +package rpc + +import ( + "bytes" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + + coreTypes "github.com/pokt-network/pocket/shared/core/types" + mockModules "github.com/pokt-network/pocket/shared/modules/mocks" +) + +type testRelayHandler func(relay *coreTypes.Relay) (*coreTypes.RelayResponse, error) + +func TestRPCServer_PostV1Client(t *testing.T) { + testCases := []struct { + name string + relay RelayRequest + handler testRelayHandler + expectedResponse string + }{ + { + name: "JSONRPC payload is processed correctly", + relay: RelayRequest{ + Payload: JSONRPCPayload{ + Jsonrpc: "2.0", + Method: "eth_blockNumber", + Id: &JsonRpcId{Id: []byte("1")}, + }, + }, + handler: func(relay *coreTypes.Relay) (*coreTypes.RelayResponse, error) { + payload := relay.GetJsonRpcPayload() + require.EqualValues(t, payload.Id, []byte("1")) + require.Equal(t, payload.JsonRpc, "2.0") + require.Equal(t, payload.Method, "eth_blockNumber") + return &coreTypes.RelayResponse{Payload: "JSONRPC Relay Response"}, nil + }, + expectedResponse: `{"payload":"JSONRPC Relay Response","servicer_signature":""}`, + }, + { + name: "REST payload is processed correctly", + relay: RelayRequest{ + Payload: RESTPayload(`{"field1":"value1"}`), + }, + handler: func(relay *coreTypes.Relay) (*coreTypes.RelayResponse, error) { + payload := relay.GetRestPayload() + require.Equal(t, &coreTypes.RESTPayload{Contents: []byte(`{"field1":"value1"}`)}, payload) + return &coreTypes.RelayResponse{Payload: "REST Relay Response"}, nil + }, + expectedResponse: `{"payload":"REST Relay Response","servicer_signature":""}`, + }, + { + name: "Invalid payload is rejected", + relay: RelayRequest{ + Payload: "foo", + }, + handler: func(_ *coreTypes.Relay) (*coreTypes.RelayResponse, error) { return nil, nil }, + expectedResponse: "bad request", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + bz, err := json.Marshal(testCase.relay) + require.NoError(t, err) + req := httptest.NewRequest("POST", "/v1/relay", bytes.NewReader(bz)) + req.Header.Add("Content-Type", "application/json") + + responseRecorder := httptest.NewRecorder() + ctx := echo.New().NewContext(req, responseRecorder) + + mockBus := mockBus(t, testCase.handler) + rpcServer := NewRPCServer(mockBus) + + err = rpcServer.PostV1ClientRelay(ctx) + require.NoError(t, err) + + resp := responseRecorder.Result() + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + + require.EqualValues(t, testCase.expectedResponse, strings.TrimRight(string(responseBody), "\n")) + }) + } +} + +// Create a mockBus with mock implementations of the utility module +func mockBus(t *testing.T, handler testRelayHandler) *mockModules.MockBus { + ctrl := gomock.NewController(t) + busMock := mockModules.NewMockBus(ctrl) + busMock.EXPECT().GetUtilityModule().Return(baseUtilityMock(ctrl, handler)).AnyTimes() + return busMock +} + +// Creates utility and servicer modules mocks with mock implementations of some basic functionality +func baseUtilityMock(ctrl *gomock.Controller, handler testRelayHandler) *mockModules.MockUtilityModule { + servicerMock := mockModules.NewMockServicerModule(ctrl) + servicerMock.EXPECT(). + HandleRelay(gomock.Any()). + DoAndReturn( + func(relay *coreTypes.Relay) (*coreTypes.RelayResponse, error) { + return handler(relay) + }).AnyTimes() + + utilityMock := mockModules.NewMockUtilityModule(ctrl) + utilityMock.EXPECT().GetServicerModule().Return(servicerMock, nil).AnyTimes() + return utilityMock +} diff --git a/rpc/v1/openapi.yaml b/rpc/v1/openapi.yaml index c068b4238..b588d9bff 100644 --- a/rpc/v1/openapi.yaml +++ b/rpc/v1/openapi.yaml @@ -1082,7 +1082,9 @@ components: - meta properties: payload: - $ref: "#/components/schemas/Payload" + oneOf: + - $ref: "#/components/schemas/JSONRPCPayload" + - $ref: "#/components/schemas/RESTPayload" meta: $ref: "#/components/schemas/RelayRequestMeta" SessionRequest: @@ -1704,15 +1706,14 @@ components: type: string address: type: string - Payload: + JSONRPCPayload: type: object required: - jsonrpc - method properties: id: - type: string - format: byte + $ref: "#/components/schemas/JsonRpcId" jsonrpc: type: string method: @@ -1722,6 +1723,16 @@ components: format: byte headers: $ref: "#/components/schemas/Headers" + JsonRpcId: + required: + - id + properties: + id: + type: string + format: byte + RESTPayload: + type: string + format: byte Pool: type: object required: diff --git a/shared/core/types/proto/relay.proto b/shared/core/types/proto/relay.proto index 6061a3a62..b59b2b471 100644 --- a/shared/core/types/proto/relay.proto +++ b/shared/core/types/proto/relay.proto @@ -19,7 +19,7 @@ message Relay { // INCOMPLETE: add REST relay payload fields message RESTPayload { - string contents = 1; + bytes contents = 1; string http_path = 2; RESTRequestType request_type = 3; } diff --git a/shared/core/types/relay.go b/shared/core/types/relay.go index 5d9f5b65f..93a641663 100644 --- a/shared/core/types/relay.go +++ b/shared/core/types/relay.go @@ -14,6 +14,7 @@ var ( errInvalidJSONRPC = errors.New("invalid value for JSONRPC field") errInvalidJSONRPCMissingMethod = errors.New("Method field not set") errInvalidRESTPayload = errors.New("invalid REST payload") + errInvalidRESTMethod = errors.New("invalid REST method") ) // IMPROVE: use a factory function to build test relays @@ -48,6 +49,16 @@ func (p *JSONRPCPayload) Validate() error { // Validate verifies that the payload is valid REST, i.e. valid JSON func (p *RESTPayload) Validate() error { + validMethods := map[RESTRequestType]struct{}{ + RESTRequestType_RESTRequestTypeGET: {}, + RESTRequestType_RESTRequestTypePUT: {}, + RESTRequestType_RESTRequestTypePOST: {}, + RESTRequestType_RESTRequestTypeDELETE: {}, + } + if _, ok := validMethods[p.RequestType]; !ok { + return fmt.Errorf("%w: invalid REST method: %d", errInvalidRESTMethod, p.RequestType) + } + var parsed json.RawMessage if err := json.Unmarshal([]byte(p.Contents), &parsed); err != nil { return fmt.Errorf("%w: %s: %s", errInvalidRESTPayload, p.Contents, err.Error()) diff --git a/shared/core/types/relay_test.go b/shared/core/types/relay_test.go index db34d8dec..20d5c4bf9 100644 --- a/shared/core/types/relay_test.go +++ b/shared/core/types/relay_test.go @@ -103,9 +103,14 @@ func TestRelay_ValidateREST(t *testing.T) { }, { name: "invalid REST payload: is not JSON-formatted", - payload: &RESTPayload{Contents: "foo"}, + payload: &RESTPayload{Contents: "foo", RequestType: RESTRequestType_RESTRequestTypeGET}, expected: errInvalidRESTPayload, }, + { + name: "invalid REST payload: invalid request type", + payload: &RESTPayload{Contents: `{"field1": "value1"}`, RequestType: RESTRequestType(99999)}, + expected: errInvalidRESTMethod, + }, } for _, testCase := range testCases { diff --git a/shared/modules/servicer_module.go b/shared/modules/servicer_module.go new file mode 100644 index 000000000..657a5003c --- /dev/null +++ b/shared/modules/servicer_module.go @@ -0,0 +1,16 @@ +package modules + +//go:generate mockgen -destination=./mocks/servicer_module_mock.go github.com/pokt-network/pocket/shared/modules ServicerModule + +import ( + coreTypes "github.com/pokt-network/pocket/shared/core/types" +) + +const ( + ServicerModuleName = "servicer" +) + +type ServicerModule interface { + Module + HandleRelay(*coreTypes.Relay) (*coreTypes.RelayResponse, error) +} diff --git a/shared/modules/utility_module.go b/shared/modules/utility_module.go index 720a4fa09..24e1c9779 100644 --- a/shared/modules/utility_module.go +++ b/shared/modules/utility_module.go @@ -62,11 +62,6 @@ type FishermanModule interface { Module } -type ServicerModule interface { - Module - HandleRelay(*coreTypes.Relay) (*coreTypes.RelayResponse, error) -} - type ValidatorModule interface { Module } diff --git a/utility/module.go b/utility/module.go index c8b027b9e..6ffffdb7f 100644 --- a/utility/module.go +++ b/utility/module.go @@ -142,10 +142,10 @@ func (u *utilityModule) GetActorModules() map[string]modules.Module { } func (u *utilityModule) GetServicerModule() (modules.ServicerModule, error) { - if u.actorModules[servicer.ServicerModuleName] == nil { + if u.actorModules[modules.ServicerModuleName] == nil { return nil, errors.New("servicer module not enabled") } - if m, ok := u.actorModules[servicer.ServicerModuleName].(modules.ServicerModule); ok { + if m, ok := u.actorModules[modules.ServicerModuleName].(modules.ServicerModule); ok { return m, nil } return nil, errors.New("failed to cast servicer module") diff --git a/utility/servicer/module.go b/utility/servicer/module.go index 18871633d..7573b3f2c 100644 --- a/utility/servicer/module.go +++ b/utility/servicer/module.go @@ -35,10 +35,6 @@ var ( _ modules.ServicerModule = &servicer{} ) -const ( - ServicerModuleName = "servicer" -) - // sessionTokens is used to cache the starting number of tokens available // during a specific session: it is used as the value for a map with keys being applications' public keys // TODO: What if we have a servicer managing more than one session from the same app at once? We may/may not need to resolve this in the future. @@ -121,7 +117,7 @@ func (s *servicer) Stop() error { } func (s *servicer) GetModuleName() string { - return ServicerModuleName + return modules.ServicerModuleName } // HandleRelay processes a relay after performing validation. @@ -458,11 +454,23 @@ func (s *servicer) executeJsonRPCRelay(meta *coreTypes.RelayMeta, payload *coreT // executeRESTRelay performs the relay for REST payloads, sending them to the chain's/service's URL. // INCOMPLETE(#860): RESTful service relays: basic checks and execution through HTTP calls. -func (s *servicer) executeRESTRelay(meta *coreTypes.RelayMeta, _ *coreTypes.RESTPayload) (*coreTypes.RelayResponse, error) { - if _, ok := s.config.Services[meta.RelayChain.Id]; !ok { +func (s *servicer) executeRESTRelay(meta *coreTypes.RelayMeta, payload *coreTypes.RESTPayload) (*coreTypes.RelayResponse, error) { + // DISCUSS: do we need to support Path and Method fields for REST relays now? + if meta == nil || meta.RelayChain == nil || meta.RelayChain.Id == "" { + return nil, fmt.Errorf("Relay for application %s does not specify relay chain", meta.ApplicationAddress) + } + + serviceConfig, ok := s.config.Services[meta.RelayChain.Id] + if !ok { return nil, fmt.Errorf("Chain %s not found in servicer configuration: %w", meta.RelayChain.Id, errValidateRelayMeta) } - return nil, nil + + relayBytes, err := codec.GetCodec().Marshal(payload) + if err != nil { + return nil, fmt.Errorf("Error marshalling payload %s: %w", payload.String(), err) + } + + return s.executeHTTPRelay(serviceConfig, relayBytes, nil) } // executeHTTPRequest performs the HTTP request that sends the relay to the chain's/service's URL. diff --git a/utility/servicer/module_test.go b/utility/servicer/module_test.go index 1e6f90e0c..00547ff97 100644 --- a/utility/servicer/module_test.go +++ b/utility/servicer/module_test.go @@ -181,6 +181,10 @@ func TestRelay_Execute(t *testing.T) { name: "JSONRPC Relay is executed", relay: testRelay(testEthGoerliRelay()), }, + { + name: "REST Relay is executed", + relay: testRelay(testRESTRelay()), + }, } for _, testCase := range testCases { @@ -279,6 +283,17 @@ func testEthGoerliRelay() relayEditor { } } +func testRESTRelay() relayEditor { + return func(relay *coreTypes.Relay) { + relay.Meta.RelayChain.Id = "RESTful-service" + relay.RelayPayload = &coreTypes.Relay_RestPayload{ + RestPayload: &coreTypes.RESTPayload{ + Contents: []byte(`{"field1": "value1"}`), + }, + } + } +} + func testRelay(editors ...relayEditor) *coreTypes.Relay { relay := &coreTypes.Relay{ Meta: &coreTypes.RelayMeta{ @@ -321,6 +336,7 @@ func testServicerConfig(editors ...configModifier) *configs.ServicerConfig { Services: map[string]*configs.ServiceConfig{ "POKT-UnitTestNet": testServiceConfig1, "ETH-Goerli": testServiceConfig1, + "RESTful-service": testServiceConfig1, }, }