-
Notifications
You must be signed in to change notification settings - Fork 1.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
web api trigger connector handler #14459
base: develop
Are you sure you want to change the base?
Changes from all commits
9a0ab6a
28b5a25
de300e0
f6d4543
c9f8c38
8218273
3573fd8
2231012
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,18 +1,267 @@ | ||
package webapi | ||
|
||
import ( | ||
"context" | ||
"crypto/ecdsa" | ||
"encoding/json" | ||
"fmt" | ||
"sync" | ||
"time" | ||
|
||
ethCommon "github.com/ethereum/go-ethereum/common" | ||
|
||
"github.com/smartcontractkit/chainlink-common/pkg/capabilities" | ||
"github.com/smartcontractkit/chainlink-common/pkg/services" | ||
"github.com/smartcontractkit/chainlink-common/pkg/types/core" | ||
"github.com/smartcontractkit/chainlink-common/pkg/values" | ||
"github.com/smartcontractkit/chainlink/v2/core/logger" | ||
"github.com/smartcontractkit/chainlink/v2/core/services/gateway/api" | ||
"github.com/smartcontractkit/chainlink/v2/core/services/gateway/connector" | ||
"github.com/smartcontractkit/chainlink/v2/core/services/job" | ||
"github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers/common" | ||
"github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers/workflow" | ||
) | ||
|
||
func NewTrigger(config string, registry core.CapabilitiesRegistry, connector connector.GatewayConnector, lggr logger.Logger) (job.ServiceCtx, error) { | ||
const defaultSendChannelBufferSize = 1000 | ||
|
||
const triggerType = "[email protected]" | ||
|
||
type TriggerConfig struct { | ||
AllowedSenders []ethCommon.Address `toml:"allowedSenders"` | ||
Allowedtopics []string `toml:"allowedTopics"` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this become a map also? map from triggerID to topics |
||
RateLimiter common.RateLimiterConfig `toml:"rateLimiter"` | ||
RequiredParams []string `toml:"requiredParams"` | ||
} | ||
|
||
type Response struct { | ||
Success bool `json:"success"` | ||
ErrorMessage string `json:"error_message,omitempty"` | ||
Status string `json:"ACCEPTED"` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we need both |
||
} | ||
|
||
// Handles connections to the webapi trigger | ||
type triggerConnectorHandler struct { | ||
services.StateMachine | ||
|
||
capabilities.CapabilityInfo | ||
config TriggerConfig | ||
connector connector.GatewayConnector | ||
lggr logger.Logger | ||
mu sync.Mutex | ||
// Will this have to get pulled into a store to have the topic and workflow ID? | ||
registeredWorkflows map[string]chan capabilities.TriggerResponse | ||
allowedSendersMap map[string]bool | ||
signerKey *ecdsa.PrivateKey | ||
rateLimiter *common.RateLimiter | ||
} | ||
|
||
var _ capabilities.TriggerCapability = (*triggerConnectorHandler)(nil) | ||
var _ services.Service = &triggerConnectorHandler{} | ||
|
||
// TODO: From Design doc, | ||
// Once connected to a Gateway, each connector handler periodically sends metadata messages containing aggregated | ||
// config for all registered workflow specs using web-trigger. | ||
|
||
func NewTrigger(config TriggerConfig, registry core.CapabilitiesRegistry, connector connector.GatewayConnector, signerKey *ecdsa.PrivateKey, lggr logger.Logger) (*triggerConnectorHandler, error) { | ||
// TODO (CAPPL-22, CAPPL-24): | ||
// - decode config | ||
// - create an implementation of the capability API and add it to the Registry | ||
// - create a handler and register it with Gateway Connector | ||
// - manage trigger subscriptions | ||
// - process incoming trigger events and related metadata | ||
return nil, nil | ||
|
||
rateLimiter, err := common.NewRateLimiter(config.RateLimiter) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. similar to below. |
||
if err != nil { | ||
return nil, err | ||
} | ||
allowedSendersMap := map[string]bool{} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe senders should be defined at workflow level. Should we create a map from triggerID to list of senders and populate that map using |
||
for _, k := range config.AllowedSenders { | ||
allowedSendersMap[k.String()] = true | ||
} | ||
|
||
handler := &triggerConnectorHandler{ | ||
allowedSendersMap: allowedSendersMap, | ||
config: config, | ||
connector: connector, | ||
signerKey: signerKey, | ||
rateLimiter: rateLimiter, | ||
lggr: lggr.Named("WorkflowConnectorHandler"), | ||
} | ||
|
||
return handler, nil | ||
} | ||
|
||
// https://gateway-us-1.chain.link/web-trigger | ||
// { | ||
// jsonrpc: "2.0", | ||
// id: "...", | ||
// method: "web-trigger", | ||
// params: { | ||
// signature: "...", | ||
// body: { | ||
// don_id: "workflow_123", | ||
// payload: { | ||
// trigger_id: "[email protected]", | ||
// trigger_event_id: "action_1234567890", | ||
// timestamp: 1234567890, | ||
// topics: ["daily_price_update"], | ||
// params: { | ||
// bid: "101", | ||
// ask: "102" | ||
// } | ||
// } | ||
// } | ||
// } | ||
// } | ||
|
||
// from Web API Trigger Doc | ||
// trigger_id - ID of the trigger corresponding to the capability ID | ||
// trigger_event_id - uniquely identifies generated event (scoped to trigger_id and sender) | ||
// timestamp - timestamp of the event (unix time), needs to be within certain freshness to be processed | ||
// topics - [OPTIONAL] list of topics (strings) to be started by this event (affects all topics if empty) | ||
// workflow_owners - [OPTIONAL] list of workflow owners allowed to receive this event (affects all workflows if empty) | ||
// params - key-value pairs that will be used as trigger output in the workflow Engine (translated to values.Map) | ||
|
||
type TriggerRequestPayload struct { | ||
TriggerId string `json:"trigger_id"` | ||
TriggerEventId string `json:"trigger_event_id"` | ||
Timestamp int64 `json:"timestamp"` | ||
Topics []string `json:"topics"` | ||
Params values.Map `json:"params"` | ||
} | ||
|
||
func (h *triggerConnectorHandler) HandleGatewayMessage(ctx context.Context, gatewayID string, msg *api.Message) { | ||
body := &msg.Body | ||
sender := ethCommon.HexToAddress(body.Sender) | ||
if !h.rateLimiter.Allow(body.Sender) { | ||
h.lggr.Errorw("request rate-limited") | ||
return | ||
} | ||
if !h.allowedSendersMap[sender.String()] { | ||
h.lggr.Errorw("Unauthorized Sender") | ||
return | ||
} | ||
h.lggr.Debugw("handling gateway request", "id", gatewayID, "method", body.Method, "sender", sender) | ||
var payload TriggerRequestPayload | ||
err := json.Unmarshal(body.Payload, &payload) | ||
if err != nil { | ||
return | ||
} | ||
|
||
switch body.Method { | ||
case workflow.MethodWebAPITrigger: | ||
h.lggr.Debugw("added MethodWebAPITrigger message", "payload", string(body.Payload)) | ||
// TODO: Is the staleness check supposed to be in the gateway? | ||
currentTime := time.Now() | ||
// TODO: check against h.config.MaxAllowedMessageAgeSec | ||
if currentTime.Unix()-3000 > payload.Timestamp { | ||
// TODO: fix up with error handling update in design doc | ||
response := Response{Success: false} | ||
h.sendResponse(ctx, gatewayID, body, response) | ||
} | ||
|
||
// is this right? | ||
// Sri did wrappedPayload, err := values.WrapMap(log.Data), does that work in this case? | ||
wrappedPayload, _ := values.WrapMap(payload) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IIUC, we would need to populate payloads with topics and params |
||
|
||
for _, trigger := range h.registeredWorkflows { | ||
|
||
// TODO: CAPPL-24 extract the topic and then match the subscriber to this messages triggers. | ||
// TODO: CAPPL-24 check the topic to see if the method is a duplicate and the trigger has been sent, ie PENDING | ||
// TODO: Question asked in Web API trigger about checking for completed Triggers to return COMPLETED | ||
// "TriggerEventID used internally by the Engine is a pair (sender, trigger_event_id). | ||
// This is to protect against a case where two different authorized senders use the same event ID in their messages. | ||
|
||
// TODO: how do we know PENDING state, that is node received the event but processing hasn't finished. | ||
TriggerEventID := body.Sender + payload.TriggerEventId | ||
tr := capabilities.TriggerResponse{ | ||
Event: capabilities.TriggerEvent{ | ||
TriggerType: triggerType, | ||
ID: TriggerEventID, | ||
Outputs: wrappedPayload, // must be *values.Map | ||
}, | ||
} | ||
trigger <- tr | ||
} | ||
|
||
// TODO: ACCEPTED, PENDING, COMPLETED | ||
response := Response{Success: true, Status: "ACCEPTED"} | ||
h.sendResponse(ctx, gatewayID, body, response) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we need to make this non-blocking? @bolekk had similar comment here: #14448 (comment) |
||
default: | ||
h.lggr.Errorw("unsupported method", "id", gatewayID, "method", body.Method) | ||
} | ||
} | ||
|
||
func (h *triggerConnectorHandler) RegisterTrigger(ctx context.Context, req capabilities.TriggerRegistrationRequest) (<-chan capabilities.TriggerResponse, error) { | ||
h.mu.Lock() | ||
defer h.mu.Unlock() | ||
_, ok := h.registeredWorkflows[req.TriggerID] | ||
if ok { | ||
return nil, fmt.Errorf("triggerId %s already registered", req.TriggerID) | ||
} | ||
|
||
callbackCh := make(chan capabilities.TriggerResponse, defaultSendChannelBufferSize) | ||
// TODO: CAPPL-24 how do we extract the topic and then define the trigger by that? | ||
// It's not TriggerID because TriggerID is concat of workflow ID and the trigger's index in the spec (what does that mean?) | ||
// I'm not sure if the workflow config comes in via the req.Config | ||
|
||
h.registeredWorkflows[req.TriggerID] = callbackCh | ||
|
||
return callbackCh, nil | ||
} | ||
|
||
func (h *triggerConnectorHandler) UnregisterTrigger(ctx context.Context, req capabilities.TriggerRegistrationRequest) error { | ||
h.mu.Lock() | ||
defer h.mu.Unlock() | ||
trigger, ok := h.registeredWorkflows[req.TriggerID] | ||
if ok { | ||
return fmt.Errorf("triggerId %s not registered", req.TriggerID) | ||
} | ||
|
||
// Close callback channel | ||
close(trigger) | ||
// Remove from triggers context | ||
delete(h.registeredWorkflows, req.TriggerID) | ||
return nil | ||
} | ||
|
||
func (h *triggerConnectorHandler) Start(ctx context.Context) error { | ||
return h.StartOnce("GatewayConnectorServiceWrapper", func() error { | ||
return h.connector.AddHandler([]string{"web_trigger"}, h) | ||
}) | ||
} | ||
func (h *triggerConnectorHandler) Close() error { | ||
return h.StopOnce("GatewayConnectorServiceWrapper", func() error { | ||
return nil | ||
}) | ||
} | ||
|
||
func (h *triggerConnectorHandler) HealthReport() map[string]error { | ||
return map[string]error{h.Name(): h.Healthy()} | ||
} | ||
|
||
func (h *triggerConnectorHandler) Name() string { | ||
return "WebAPITrigger" | ||
} | ||
|
||
func (h *triggerConnectorHandler) sendResponse(ctx context.Context, gatewayID string, requestBody *api.MessageBody, payload any) error { | ||
payloadJson, err := json.Marshal(payload) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lint error |
||
if err != nil { | ||
return err | ||
} | ||
|
||
msg := &api.Message{ | ||
Body: api.MessageBody{ | ||
MessageId: requestBody.MessageId, | ||
DonId: requestBody.DonId, | ||
Method: requestBody.Method, | ||
Receiver: requestBody.Sender, | ||
Payload: payloadJson, | ||
}, | ||
} | ||
|
||
// TODO remove this and signerKey once Jin's PR is in. | ||
if err = msg.Sign(h.signerKey); err != nil { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Jin is going to modify Connector to sign automatically. You can remove this (and also don't pass the private key at all). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. my changes, as of now, only introduces a new method that sends to first available gateway. In this case, the caller already knows the sender. I will modify my changes to account for this use case. |
||
return err | ||
} | ||
return h.connector.SendToGateway(ctx, gatewayID, msg) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can remove it (see other comment). Also in the future please be wary of passing private keys around. It's easy to accidentally log it and it will also cause more problems when Keystore is hardened. Gateway wraps it in a Signer interface so let's use that if it's ever needed.