Callbacks service docs updated

This commit is contained in:
Stephan D
2026-03-02 16:27:33 +01:00
parent 17e08ff26f
commit 2be76aa519
77 changed files with 803 additions and 764 deletions

View File

@@ -0,0 +1,33 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type callbackWriteResponse struct {
AccessToken TokenData `json:"accessToken"`
Callbacks []model.Callback `json:"callbacks"`
GeneratedSigningSecret string `json:"generatedSigningSecret,omitempty"`
}
func Callback(
logger mlogger.Logger,
callback *model.Callback,
accessToken *TokenData,
generatedSecret string,
created bool,
) http.HandlerFunc {
resp := callbackWriteResponse{
AccessToken: *accessToken,
Callbacks: []model.Callback{*callback},
GeneratedSigningSecret: generatedSecret,
}
if created {
return response.Created(logger, resp)
}
return response.Ok(logger, resp)
}

View File

@@ -42,9 +42,7 @@ func PrepareRefreshToken(
}
token := &model.RefreshToken{
AccountBoundBase: model.AccountBoundBase{
AccountRef: account.GetID(),
},
AccountRef: account.GetID(),
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: *session,
RefreshToken: refreshToken,

View File

@@ -0,0 +1,47 @@
package callbacksimp
import (
"context"
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *CallbacksAPI) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
organizationRef, err := a.Oph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to parse organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
}
var callback model.Callback
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
return response.BadPayload(a.Logger, a.Name(), err)
}
mutation, err := a.normalizeAndPrepare(r.Context(), &callback, organizationRef, "", true)
if err != nil {
return response.Auto(a.Logger, a.Name(), err)
}
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
if err := a.DB.Create(ctx, *account.GetID(), organizationRef, &callback); err != nil {
return nil, err
}
if err := a.applySigningSecretMutation(ctx, *account.GetID(), *callback.GetID(), mutation); err != nil {
return nil, err
}
return nil, nil
}); err != nil {
a.Logger.Warn("Failed to create callback transaction", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
return a.callbackResponse(&callback, accessToken, mutation.Generated, true)
}

View File

@@ -2,7 +2,7 @@ package callbacksimp
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
@@ -16,68 +16,10 @@ import (
"go.uber.org/zap"
)
type callbackWriteResponse struct {
AccessToken sresponse.TokenData `json:"accessToken"`
Callbacks []model.Callback `json:"callbacks"`
GeneratedSigningSecret string `json:"generatedSigningSecret,omitempty"`
}
func (a *CallbacksAPI) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
organizationRef, err := a.Oph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to parse organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
}
var callback model.Callback
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
return response.BadPayload(a.Logger, a.Name(), err)
}
generatedSecret, err := a.normalizeAndPrepare(r.Context(), &callback, organizationRef, true)
if err != nil {
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.DB.Create(r.Context(), *account.GetID(), organizationRef, &callback); err != nil {
a.Logger.Warn("Failed to create callback", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
return a.callbackResponse(&callback, accessToken, generatedSecret, true)
}
func (a *CallbacksAPI) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
var input model.Callback
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
return response.BadPayload(a.Logger, a.Name(), err)
}
callbackRef := *input.GetID()
if callbackRef.IsZero() {
return response.Auto(a.Logger, a.Name(), merrors.InvalidArgument("callback id is required", "id"))
}
var existing model.Callback
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &existing); err != nil {
a.Logger.Warn("Failed to fetch callback before update", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
mergeCallbackMutable(&existing, &input)
generatedSecret, err := a.normalizeAndPrepare(r.Context(), &existing, existing.OrganizationRef, true)
if err != nil {
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.DB.Update(r.Context(), *account.GetID(), &existing); err != nil {
a.Logger.Warn("Failed to update callback", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
return a.callbackResponse(&existing, accessToken, generatedSecret, false)
type signingSecretMutation struct {
SetSecretRef string
Clear bool
Generated string
}
func (a *CallbacksAPI) rotateSecret(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
@@ -102,10 +44,8 @@ func (a *CallbacksAPI) rotateSecret(r *http.Request, account *model.Account, acc
a.Logger.Warn("Failed to rotate callback signing secret", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
callback.RetryPolicy.SecretRef = secretRef
if err := a.DB.Update(r.Context(), *account.GetID(), &callback); err != nil {
a.Logger.Warn("Failed to persist rotated callback secret reference", zap.Error(err))
if err := a.db.SetSigningSecretRef(r.Context(), *account.GetID(), callbackRef, secretRef); err != nil {
a.Logger.Warn("Failed to persist rotated callback signing secret reference", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
@@ -116,29 +56,25 @@ func (a *CallbacksAPI) normalizeAndPrepare(
ctx context.Context,
callback *model.Callback,
organizationRef bson.ObjectID,
existingSecretRef string,
allowSecretGeneration bool,
) (string, error) {
) (signingSecretMutation, error) {
if callback == nil {
return "", merrors.InvalidArgument("callback payload is required")
return signingSecretMutation{}, merrors.InvalidArgument("callback payload is required")
}
if organizationRef.IsZero() {
return "", merrors.InvalidArgument("organization reference is required", "organizationRef")
return signingSecretMutation{}, merrors.InvalidArgument("organization reference is required", "organizationRef")
}
callback.SetOrganizationRef(organizationRef)
callback.Name = strings.TrimSpace(callback.Name)
callback.Description = trimDescription(callback.Description)
callback.ClientID = strings.TrimSpace(callback.ClientID)
if callback.ClientID == "" {
callback.ClientID = organizationRef.Hex()
}
callback.URL = strings.TrimSpace(callback.URL)
if callback.URL == "" {
return "", merrors.InvalidArgument("url is required", "url")
return signingSecretMutation{}, merrors.InvalidArgument("url is required", "url")
}
if err := validateCallbackURL(callback.URL); err != nil {
return "", err
return signingSecretMutation{}, err
}
if callback.Name == "" {
callback.Name = callback.URL
@@ -146,15 +82,15 @@ func (a *CallbacksAPI) normalizeAndPrepare(
status, err := normalizeStatus(callback.Status, a.config.DefaultStatus)
if err != nil {
return "", err
return signingSecretMutation{}, err
}
callback.Status = status
callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes)
callback.RetryPolicy.MinDelayMS = defaultInt(callback.RetryPolicy.MinDelayMS, defaultRetryMinDelayMS)
callback.RetryPolicy.MaxDelayMS = defaultInt(callback.RetryPolicy.MaxDelayMS, defaultRetryMaxDelayMS)
if callback.RetryPolicy.MaxDelayMS < callback.RetryPolicy.MinDelayMS {
callback.RetryPolicy.MaxDelayMS = callback.RetryPolicy.MinDelayMS
callback.RetryPolicy.Backoff.MinDelayMS = defaultInt(callback.RetryPolicy.Backoff.MinDelayMS, defaultRetryMinDelayMS)
callback.RetryPolicy.Backoff.MaxDelayMS = defaultInt(callback.RetryPolicy.Backoff.MaxDelayMS, defaultRetryMaxDelayMS)
if callback.RetryPolicy.Backoff.MaxDelayMS < callback.RetryPolicy.Backoff.MinDelayMS {
callback.RetryPolicy.Backoff.MaxDelayMS = callback.RetryPolicy.Backoff.MinDelayMS
}
callback.RetryPolicy.MaxAttempts = defaultInt(callback.RetryPolicy.MaxAttempts, defaultRetryMaxAttempts)
callback.RetryPolicy.RequestTimeoutMS = defaultInt(callback.RetryPolicy.RequestTimeoutMS, defaultRetryRequestTimeoutMS)
@@ -162,36 +98,55 @@ func (a *CallbacksAPI) normalizeAndPrepare(
mode, err := normalizeSigningMode(callback.RetryPolicy.SigningMode)
if err != nil {
return "", err
return signingSecretMutation{}, err
}
callback.RetryPolicy.SigningMode = mode
callback.RetryPolicy.SecretRef = strings.TrimSpace(callback.RetryPolicy.SecretRef)
existingSecretRef = strings.TrimSpace(existingSecretRef)
switch callback.RetryPolicy.SigningMode {
case model.CallbackSigningModeNone:
callback.RetryPolicy.SecretRef = ""
return "", nil
return signingSecretMutation{Clear: existingSecretRef != ""}, nil
case model.CallbackSigningModeHMACSHA256:
if callback.RetryPolicy.SecretRef != "" {
return "", nil
if existingSecretRef != "" {
return signingSecretMutation{SetSecretRef: existingSecretRef}, nil
}
if !allowSecretGeneration {
return "", merrors.InvalidArgument("secretRef is required for hmac_sha256 callbacks", "retryPolicy.secretRef")
return signingSecretMutation{}, merrors.InvalidArgument("signing secret is required for hmac_sha256 callbacks", "retryPolicy.signingMode")
}
if callback.GetID().IsZero() {
callback.SetID(bson.NewObjectID())
}
secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID())
if err != nil {
return "", err
return signingSecretMutation{}, err
}
callback.RetryPolicy.SecretRef = secretRef
return generatedSecret, nil
return signingSecretMutation{SetSecretRef: secretRef, Generated: generatedSecret}, nil
default:
return "", merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode")
return signingSecretMutation{}, merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode")
}
}
func (a *CallbacksAPI) applySigningSecretMutation(
ctx context.Context,
accountRef,
callbackRef bson.ObjectID,
mutation signingSecretMutation,
) error {
if callbackRef.IsZero() {
return merrors.InvalidArgument("callback reference is required", "callbackRef")
}
if strings.TrimSpace(mutation.SetSecretRef) != "" {
return a.db.SetSigningSecretRef(ctx, accountRef, callbackRef, mutation.SetSecretRef)
}
if mutation.Clear {
err := a.db.ClearSigningSecretRef(ctx, accountRef, callbackRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
return err
}
}
return nil
}
func (a *CallbacksAPI) callbackResponse(
callback *model.Callback,
accessToken *sresponse.TokenData,
@@ -202,15 +157,7 @@ func (a *CallbacksAPI) callbackResponse(
return response.Internal(a.Logger, a.Name(), merrors.Internal("failed to build callback response"))
}
resp := callbackWriteResponse{
AccessToken: *accessToken,
Callbacks: []model.Callback{*callback},
GeneratedSigningSecret: generatedSecret,
}
if created {
return response.Created(a.Logger, resp)
}
return response.Ok(a.Logger, resp)
return sresponse.Callback(a.Logger, callback, accessToken, generatedSecret, created)
}
func normalizeStatus(raw, fallback model.CallbackStatus) (model.CallbackStatus, error) {
@@ -286,16 +233,17 @@ func normalizeHeaders(headers map[string]string) map[string]string {
}
func mergeCallbackMutable(dst, src *model.Callback) {
dst.OrganizationRef = src.OrganizationRef
dst.Describable = src.Describable
dst.ClientID = src.ClientID
dst.Status = src.Status
dst.URL = src.URL
dst.EventTypes = append([]string(nil), src.EventTypes...)
dst.RetryPolicy = model.CallbackRetryPolicy{
MinDelayMS: src.RetryPolicy.MinDelayMS,
MaxDelayMS: src.RetryPolicy.MaxDelayMS,
Backoff: model.CallbackBackoff{
MinDelayMS: src.RetryPolicy.Backoff.MinDelayMS,
MaxDelayMS: src.RetryPolicy.Backoff.MaxDelayMS,
},
SigningMode: src.RetryPolicy.SigningMode,
SecretRef: src.RetryPolicy.SecretRef,
Headers: normalizeHeaders(src.RetryPolicy.Headers),
MaxAttempts: src.RetryPolicy.MaxAttempts,
RequestTimeoutMS: src.RetryPolicy.RequestTimeoutMS,

View File

@@ -81,7 +81,7 @@ func newSigningSecretManager(logger mlogger.Logger, cfg callbacksConfig) (signin
}
if isVaultConfigEmpty(cfg.Vault) {
manager.logger.Warn("Callbacks Vault config is not set; secret generation requires explicit secretRef in payloads")
manager.logger.Warn("Callbacks Vault config is not set; hmac signing secret generation is disabled")
ensureSigningSecretMetrics()
return manager, nil
}

View File

@@ -5,6 +5,7 @@ import (
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/callbacks"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
@@ -16,6 +17,7 @@ import (
type CallbacksAPI struct {
papitemplate.ProtectedAPI[model.Callback]
db callbacks.DB
tf transaction.Factory
secrets signingSecretManager
config callbacksConfig
}
@@ -35,6 +37,7 @@ func CreateAPI(apiCtx eapi.API) (*CallbacksAPI, error) {
res := &CallbacksAPI{
config: newCallbacksConfig(apiCtx.Config().Callbacks),
tf: apiCtx.DBFactory().TransactionFactory(),
}
p, err := papitemplate.CreateAPI(apiCtx, dbFactory, mservice.Organizations, mservice.Callbacks)

View File

@@ -0,0 +1,59 @@
package callbacksimp
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *CallbacksAPI) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
var input model.Callback
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
return response.BadPayload(a.Logger, a.Name(), err)
}
callbackRef := *input.GetID()
if callbackRef.IsZero() {
return response.Auto(a.Logger, a.Name(), merrors.InvalidArgument("callback ref is required", "id"))
}
var existing model.Callback
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &existing); err != nil {
a.Logger.Warn("Failed to fetch callback before update", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
existingSecretRef, err := a.db.GetSigningSecretRef(r.Context(), *account.GetID(), callbackRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
a.Logger.Warn("Failed to fetch callback signing secret metadata", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
mergeCallbackMutable(&existing, &input)
mutation, err := a.normalizeAndPrepare(r.Context(), &existing, existing.OrganizationRef, existingSecretRef, true)
if err != nil {
return response.Auto(a.Logger, a.Name(), err)
}
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
if err := a.DB.Update(ctx, *account.GetID(), &existing); err != nil {
return nil, err
}
if err := a.applySigningSecretMutation(ctx, *account.GetID(), callbackRef, mutation); err != nil {
return nil, err
}
return nil, nil
}); err != nil {
a.Logger.Warn("Failed to update callback transaction", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
return a.callbackResponse(&existing, accessToken, mutation.Generated, false)
}

View File

@@ -10,6 +10,7 @@ import (
"sync"
"time"
"github.com/tech/sendico/edge/callbacks/internal/model"
"github.com/tech/sendico/edge/callbacks/internal/signing"
"github.com/tech/sendico/edge/callbacks/internal/storage"
"github.com/tech/sendico/pkg/merrors"
@@ -154,7 +155,7 @@ func (s *service) runWorker(ctx context.Context, workerID string) {
}
}
func (s *service) handleTask(ctx context.Context, workerID string, task *storage.Task) {
func (s *service) handleTask(ctx context.Context, workerID string, task *model.Task) {
started := time.Now()
statusCode := 0
result := "failed"

View File

@@ -4,16 +4,18 @@ import (
"context"
"encoding/json"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Envelope is the canonical incoming event envelope.
type Envelope struct {
EventID string `json:"event_id"`
Type string `json:"type"`
ClientID string `json:"client_id"`
OccurredAt time.Time `json:"occurred_at"`
PublishedAt time.Time `json:"published_at,omitempty"`
Data json.RawMessage `json:"data"`
EventID string `json:"event_id"`
Type string `json:"type"`
OrganizationRef bson.ObjectID `json:"organization_ref"`
OccurredAt time.Time `json:"occurred_at"`
PublishedAt time.Time `json:"published_at,omitempty"`
Data json.RawMessage `json:"data"`
}
// Service parses incoming messages and builds outbound payload bytes.
@@ -24,10 +26,10 @@ type Service interface {
// Payload is the stable outbound JSON body.
type Payload struct {
EventID string `json:"event_id"`
Type string `json:"type"`
ClientID string `json:"client_id"`
OccurredAt string `json:"occurred_at"`
PublishedAt string `json:"published_at,omitempty"`
Data json.RawMessage `json:"data"`
EventID string `json:"event_id"`
Type string `json:"type"`
OrganizationRef bson.ObjectID `json:"organization_ref"`
OccurredAt string `json:"occurred_at"`
PublishedAt string `json:"published_at,omitempty"`
Data json.RawMessage `json:"data"`
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -39,8 +40,8 @@ func (s *parserService) Parse(data []byte) (*Envelope, error) {
if strings.TrimSpace(envelope.Type) == "" {
return nil, merrors.InvalidArgument("type is required", "type")
}
if strings.TrimSpace(envelope.ClientID) == "" {
return nil, merrors.InvalidArgument("client_id is required", "client_id")
if envelope.OrganizationRef == bson.NilObjectID {
return nil, merrors.InvalidArgument("organization_ref is required", "organization_ref")
}
if envelope.OccurredAt.IsZero() {
return nil, merrors.InvalidArgument("occurred_at is required", "occurred_at")
@@ -51,7 +52,6 @@ func (s *parserService) Parse(data []byte) (*Envelope, error) {
envelope.EventID = strings.TrimSpace(envelope.EventID)
envelope.Type = strings.TrimSpace(envelope.Type)
envelope.ClientID = strings.TrimSpace(envelope.ClientID)
envelope.OccurredAt = envelope.OccurredAt.UTC()
if !envelope.PublishedAt.IsZero() {
envelope.PublishedAt = envelope.PublishedAt.UTC()
@@ -66,11 +66,11 @@ func (s *parserService) BuildPayload(_ context.Context, envelope *Envelope) ([]b
}
payload := Payload{
EventID: envelope.EventID,
Type: envelope.Type,
ClientID: envelope.ClientID,
OccurredAt: envelope.OccurredAt.UTC().Format(time.RFC3339Nano),
Data: envelope.Data,
EventID: envelope.EventID,
Type: envelope.Type,
OrganizationRef: envelope.OrganizationRef,
OccurredAt: envelope.OccurredAt.UTC().Format(time.RFC3339Nano),
Data: envelope.Data,
}
if !envelope.PublishedAt.IsZero() {
payload.PublishedAt = envelope.PublishedAt.UTC().Format(time.RFC3339Nano)

View File

@@ -17,6 +17,7 @@ import (
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -178,7 +179,7 @@ func (s *service) handlePaymentStatusUpdated(ctx context.Context, msg *model.Pay
result = ingestResultEmptyPayload
return nil
}
if strings.TrimSpace(msg.EventID) == "" || strings.TrimSpace(msg.ClientID) == "" || msg.OccurredAt.IsZero() {
if strings.TrimSpace(msg.EventID) == "" || msg.Data.OrganizationRef == bson.NilObjectID || msg.OccurredAt.IsZero() {
result = ingestResultInvalidEvent
return nil
}
@@ -195,15 +196,15 @@ func (s *service) handlePaymentStatusUpdated(ctx context.Context, msg *model.Pay
}
parsed := &events.Envelope{
EventID: strings.TrimSpace(msg.EventID),
Type: eventType,
ClientID: strings.TrimSpace(msg.ClientID),
OccurredAt: msg.OccurredAt.UTC(),
PublishedAt: msg.PublishedAt.UTC(),
Data: data,
EventID: strings.TrimSpace(msg.EventID),
Type: eventType,
OrganizationRef: msg.Data.OrganizationRef,
OccurredAt: msg.OccurredAt.UTC(),
PublishedAt: msg.PublishedAt.UTC(),
Data: data,
}
inserted, err := s.deps.InboxRepo.TryInsert(ctx, parsed.EventID, parsed.ClientID, parsed.Type, time.Now().UTC())
inserted, err := s.deps.InboxRepo.TryInsert(ctx, parsed.EventID, parsed.Type, parsed.OrganizationRef, time.Now().UTC())
if err != nil {
result = ingestResultInboxError
return err
@@ -213,7 +214,7 @@ func (s *service) handlePaymentStatusUpdated(ctx context.Context, msg *model.Pay
return nil
}
endpoints, err := s.deps.Resolver.Resolve(ctx, parsed.ClientID, parsed.Type)
endpoints, err := s.deps.Resolver.Resolve(ctx, parsed.Type, parsed.OrganizationRef)
if err != nil {
result = ingestResultResolveError
return err

View File

@@ -0,0 +1,8 @@
package model
import pmodel "github.com/tech/sendico/pkg/model"
type CallbackInternal struct {
pmodel.Callback `bson:",inline" json:",inline"`
SecretRef string `bson:"secretRef"`
}

View File

@@ -0,0 +1,22 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
pmodel "github.com/tech/sendico/pkg/model"
)
// Endpoint describes one target callback endpoint.
type Endpoint struct {
storable.Base
pmodel.OrganizationBoundBase
URL string
SigningMode string
SecretRef string
Headers map[string]string
MaxAttempts int
MinDelay time.Duration
MaxDelay time.Duration
RequestTimeout time.Duration
}

View File

@@ -0,0 +1,37 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TaskStatus tracks delivery task lifecycle.
type TaskStatus string
const (
TaskStatusPending TaskStatus = "PENDING"
TaskStatusRetry TaskStatus = "RETRY"
TaskStatusDelivered TaskStatus = "DELIVERED"
TaskStatusFailed TaskStatus = "FAILED"
)
// Task is one callback delivery job.
type Task struct {
storable.Base
EventID string
EndpointRef bson.ObjectID
EndpointURL string
SigningMode string
SecretRef string
Headers map[string]string
Payload []byte
Attempt int
MaxAttempts int
MinDelay time.Duration
MaxDelay time.Duration
RequestTimeout time.Duration
Status TaskStatus
NextAttemptAt time.Time
}

View File

@@ -4,54 +4,12 @@ import (
"context"
"time"
"github.com/tech/sendico/edge/callbacks/internal/model"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TaskStatus tracks delivery task lifecycle.
type TaskStatus string
const (
TaskStatusPending TaskStatus = "PENDING"
TaskStatusRetry TaskStatus = "RETRY"
TaskStatusDelivered TaskStatus = "DELIVERED"
TaskStatusFailed TaskStatus = "FAILED"
)
// Endpoint describes one target callback endpoint.
type Endpoint struct {
ID bson.ObjectID
ClientID string
URL string
SigningMode string
SecretRef string
Headers map[string]string
MaxAttempts int
MinDelay time.Duration
MaxDelay time.Duration
RequestTimeout time.Duration
}
// Task is one callback delivery job.
type Task struct {
ID bson.ObjectID
EventID string
EndpointID bson.ObjectID
EndpointURL string
SigningMode string
SecretRef string
Headers map[string]string
Payload []byte
Attempt int
MaxAttempts int
MinDelay time.Duration
MaxDelay time.Duration
RequestTimeout time.Duration
Status TaskStatus
NextAttemptAt time.Time
}
// TaskDefaults are applied when creating tasks.
type TaskDefaults struct {
MaxAttempts int
@@ -69,18 +27,18 @@ type Options struct {
// InboxRepo controls event dedupe state.
type InboxRepo interface {
TryInsert(ctx context.Context, eventID, clientID, eventType string, at time.Time) (bool, error)
TryInsert(ctx context.Context, eventID, ceventType string, organizationRef bson.ObjectID, at time.Time) (bool, error)
}
// EndpointRepo resolves endpoints for events.
type EndpointRepo interface {
FindActiveByClientAndType(ctx context.Context, clientID, eventType string) ([]Endpoint, error)
FindActive(ctx context.Context, eventType string, organizationRef bson.ObjectID) ([]model.Endpoint, error)
}
// TaskRepo manages callback tasks.
type TaskRepo interface {
UpsertTasks(ctx context.Context, eventID string, endpoints []Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error
LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*Task, error)
UpsertTasks(ctx context.Context, eventID string, endpoints []model.Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error
LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*model.Task, error)
MarkDelivered(ctx context.Context, taskID bson.ObjectID, httpCode int, latency time.Duration, at time.Time) error
MarkRetry(ctx context.Context, taskID bson.ObjectID, attempt int, nextAttemptAt time.Time, lastError string, httpCode int, at time.Time) error
MarkFailed(ctx context.Context, taskID bson.ObjectID, attempt int, lastError string, httpCode int, at time.Time) error

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/tech/sendico/edge/callbacks/internal/model"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
@@ -13,6 +14,7 @@ import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
mutil "github.com/tech/sendico/pkg/mutil/db"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -39,10 +41,10 @@ type mongoRepository struct {
}
type inboxDoc struct {
storable.Base `bson:",inline"`
EventID string `bson:"event_id"`
ClientID string `bson:"client_id"`
EventType string `bson:"event_type"`
storable.Base `bson:",inline"`
pmodel.OrganizationBoundBase `bson:",inline"`
EventID string `bson:"event_id"`
EventType string `bson:"event_type"`
}
func (d *inboxDoc) Collection() string {
@@ -64,12 +66,12 @@ type deliveryPolicy struct {
}
type endpointDoc struct {
storable.Base `bson:",inline"`
deliveryPolicy `bson:"retry_policy"`
ClientID string `bson:"client_id"`
Status string `bson:"status"`
URL string `bson:"url"`
EventTypes []string `bson:"event_types"`
storable.Base `bson:",inline"`
pmodel.OrganizationBoundBase `bson:",inline"`
deliveryPolicy `bson:"retry_policy"`
Status string `bson:"status"`
URL string `bson:"url"`
EventTypes []string `bson:"event_types"`
}
func (d *endpointDoc) Collection() string {
@@ -79,18 +81,18 @@ func (d *endpointDoc) Collection() string {
type taskDoc struct {
storable.Base `bson:",inline"`
deliveryPolicy `bson:"retry_policy"`
EventID string `bson:"event_id"`
EndpointID bson.ObjectID `bson:"endpoint_id"`
EndpointURL string `bson:"endpoint_url"`
Payload []byte `bson:"payload"`
Status TaskStatus `bson:"status"`
Attempt int `bson:"attempt"`
LastError string `bson:"last_error,omitempty"`
LastHTTPCode int `bson:"last_http_code,omitempty"`
NextAttemptAt time.Time `bson:"next_attempt_at"`
LockedUntil *time.Time `bson:"locked_until,omitempty"`
WorkerID string `bson:"worker_id,omitempty"`
DeliveredAt *time.Time `bson:"delivered_at,omitempty"`
EventID string `bson:"event_id"`
EndpointRef bson.ObjectID `bson:"endpoint_ref"`
EndpointURL string `bson:"endpoint_url"`
Payload []byte `bson:"payload"`
Status model.TaskStatus `bson:"status"`
Attempt int `bson:"attempt"`
LastError string `bson:"last_error,omitempty"`
LastHTTPCode int `bson:"last_http_code,omitempty"`
NextAttemptAt time.Time `bson:"next_attempt_at"`
LockedUntil *time.Time `bson:"locked_until,omitempty"`
WorkerID string `bson:"worker_id,omitempty"`
DeliveredAt *time.Time `bson:"delivered_at,omitempty"`
}
func (d *taskDoc) Collection() string {
@@ -152,7 +154,7 @@ func (m *mongoRepository) ensureIndexes() error {
Unique: true,
Keys: []ri.Key{
{Field: "event_id", Sort: ri.Asc},
{Field: "endpoint_id", Sort: ri.Asc},
{Field: "endpoint_ref", Sort: ri.Asc},
},
},
{
@@ -172,7 +174,7 @@ func (m *mongoRepository) ensureIndexes() error {
if err := m.endpointsRepo.CreateIndex(&ri.Definition{
Name: "idx_client_event",
Keys: []ri.Key{
{Field: "client_id", Sort: ri.Asc},
{Field: "organization_ref", Sort: ri.Asc},
{Field: "status", Sort: ri.Asc},
{Field: "event_types", Sort: ri.Asc},
},
@@ -188,11 +190,11 @@ type inboxStore struct {
repo repository.Repository
}
func (r *inboxStore) TryInsert(ctx context.Context, eventID, clientID, eventType string, at time.Time) (bool, error) {
func (r *inboxStore) TryInsert(ctx context.Context, eventID, eventType string, organizationRef bson.ObjectID, at time.Time) (bool, error) {
doc := &inboxDoc{
EventID: strings.TrimSpace(eventID),
ClientID: strings.TrimSpace(clientID),
EventType: strings.TrimSpace(eventType),
OrganizationBoundBase: pmodel.OrganizationBoundBase{OrganizationRef: organizationRef},
EventID: strings.TrimSpace(eventID),
EventType: strings.TrimSpace(eventType),
}
filter := repository.Filter("event_id", doc.EventID)
@@ -212,21 +214,20 @@ type endpointStore struct {
repo repository.Repository
}
func (r *endpointStore) FindActiveByClientAndType(ctx context.Context, clientID, eventType string) ([]Endpoint, error) {
clientID = strings.TrimSpace(clientID)
func (r *endpointStore) FindActive(ctx context.Context, eventType string, organizationRef bson.ObjectID) ([]model.Endpoint, error) {
eventType = strings.TrimSpace(eventType)
if clientID == "" {
return nil, merrors.InvalidArgument("client_id is required", "client_id")
if organizationRef == bson.NilObjectID {
return nil, merrors.InvalidArgument("organization_ref is required", "organization_ref")
}
if eventType == "" {
return nil, merrors.InvalidArgument("event type is required", "event_type")
}
query := repository.Query().
Filter(repository.Field("client_id"), clientID).
Filter(repository.OrgField(), organizationRef).
In(repository.Field("status"), "active", "enabled")
out := make([]Endpoint, 0)
out := make([]model.Endpoint, 0)
err := r.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
doc := &endpointDoc{}
if err := cur.Decode(doc); err != nil {
@@ -238,17 +239,17 @@ func (r *endpointStore) FindActiveByClientAndType(ctx context.Context, clientID,
if !supportsEventType(doc.EventTypes, eventType) {
return nil
}
out = append(out, Endpoint{
ID: doc.ID,
ClientID: doc.ClientID,
URL: strings.TrimSpace(doc.URL),
SigningMode: strings.TrimSpace(doc.SigningMode),
SecretRef: strings.TrimSpace(doc.SecretRef),
Headers: cloneHeaders(doc.Headers),
MaxAttempts: doc.MaxAttempts,
MinDelay: time.Duration(doc.MinDelayMS) * time.Millisecond,
MaxDelay: time.Duration(doc.MaxDelayMS) * time.Millisecond,
RequestTimeout: time.Duration(doc.RequestTimeoutMS) * time.Millisecond,
out = append(out, model.Endpoint{
Base: doc.Base,
OrganizationBoundBase: doc.OrganizationBoundBase,
URL: strings.TrimSpace(doc.URL),
SigningMode: strings.TrimSpace(doc.SigningMode),
SecretRef: strings.TrimSpace(doc.SecretRef),
Headers: cloneHeaders(doc.Headers),
MaxAttempts: doc.MaxAttempts,
MinDelay: time.Duration(doc.MinDelayMS) * time.Millisecond,
MaxDelay: time.Duration(doc.MaxDelayMS) * time.Millisecond,
RequestTimeout: time.Duration(doc.RequestTimeoutMS) * time.Millisecond,
})
return nil
})
@@ -281,7 +282,7 @@ type taskStore struct {
repo repository.Repository
}
func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints []Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error {
func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints []model.Endpoint, payload []byte, defaults TaskDefaults, at time.Time) error {
eventID = strings.TrimSpace(eventID)
if eventID == "" {
return merrors.InvalidArgument("event id is required", "event_id")
@@ -292,7 +293,7 @@ func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints [
now := at.UTC()
for _, endpoint := range endpoints {
if endpoint.ID == bson.NilObjectID {
if endpoint.GetID() == nil || *endpoint.GetID() == bson.NilObjectID {
continue
}
@@ -327,13 +328,13 @@ func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints [
doc := &taskDoc{}
doc.EventID = eventID
doc.EndpointID = endpoint.ID
doc.EndpointRef = *endpoint.GetID()
doc.EndpointURL = strings.TrimSpace(endpoint.URL)
doc.SigningMode = strings.TrimSpace(endpoint.SigningMode)
doc.SecretRef = strings.TrimSpace(endpoint.SecretRef)
doc.Headers = cloneHeaders(endpoint.Headers)
doc.Payload = append([]byte(nil), payload...)
doc.Status = TaskStatusPending
doc.Status = model.TaskStatusPending
doc.Attempt = 0
doc.MaxAttempts = maxAttempts
doc.MinDelayMS = int(minDelay / time.Millisecond)
@@ -341,7 +342,7 @@ func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints [
doc.RequestTimeoutMS = int(requestTimeout / time.Millisecond)
doc.NextAttemptAt = now
filter := repository.Filter("event_id", eventID).And(repository.Filter("endpoint_id", endpoint.ID))
filter := repository.Filter("event_id", eventID).And(repository.Filter("endpoint_ref", endpoint.ID))
if err := r.repo.Insert(ctx, doc, filter); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
continue
@@ -353,7 +354,7 @@ func (r *taskStore) UpsertTasks(ctx context.Context, eventID string, endpoints [
return nil
}
func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*Task, error) {
func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID string, lockTTL time.Duration) (*model.Task, error) {
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return nil, merrors.InvalidArgument("worker id is required", "worker_id")
@@ -368,7 +369,7 @@ func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID st
)
query := repository.Query().
In(repository.Field("status"), string(TaskStatusPending), string(TaskStatusRetry)).
In(repository.Field("status"), string(model.TaskStatusPending), string(model.TaskStatusRetry)).
Comparison(repository.Field("next_attempt_at"), builder.Lte, now).
And(lockFilter).
Sort(repository.Field("next_attempt_at"), true).
@@ -390,7 +391,7 @@ func (r *taskStore) LockNextTask(ctx context.Context, now time.Time, workerID st
Set(repository.Field("worker_id"), workerID)
conditional := repository.IDFilter(candidate.ID).And(
repository.Query().In(repository.Field("status"), string(TaskStatusPending), string(TaskStatusRetry)),
repository.Query().In(repository.Field("status"), string(model.TaskStatusPending), string(model.TaskStatusRetry)),
repository.Query().Comparison(repository.Field("next_attempt_at"), builder.Lte, now),
lockFilter,
)
@@ -427,7 +428,7 @@ func (r *taskStore) MarkDelivered(ctx context.Context, taskID bson.ObjectID, htt
}
patch := repository.Patch().
Set(repository.Field("status"), TaskStatusDelivered).
Set(repository.Field("status"), model.TaskStatusDelivered).
Set(repository.Field("last_http_code"), httpCode).
Set(repository.Field("delivered_at"), time.Now()).
Set(repository.Field("locked_until"), nil).
@@ -446,7 +447,7 @@ func (r *taskStore) MarkRetry(ctx context.Context, taskID bson.ObjectID, attempt
}
patch := repository.Patch().
Set(repository.Field("status"), TaskStatusRetry).
Set(repository.Field("status"), model.TaskStatusRetry).
Set(repository.Field("attempt"), attempt).
Set(repository.Field("next_attempt_at"), nextAttemptAt.UTC()).
Set(repository.Field("last_error"), strings.TrimSpace(lastError)).
@@ -466,7 +467,7 @@ func (r *taskStore) MarkFailed(ctx context.Context, taskID bson.ObjectID, attemp
}
patch := repository.Patch().
Set(repository.Field("status"), TaskStatusFailed).
Set(repository.Field("status"), model.TaskStatusFailed).
Set(repository.Field("attempt"), attempt).
Set(repository.Field("last_error"), strings.TrimSpace(lastError)).
Set(repository.Field("last_http_code"), httpCode).
@@ -479,14 +480,14 @@ func (r *taskStore) MarkFailed(ctx context.Context, taskID bson.ObjectID, attemp
return nil
}
func mapTaskDoc(doc *taskDoc) *Task {
func mapTaskDoc(doc *taskDoc) *model.Task {
if doc == nil {
return nil
}
return &Task{
ID: doc.ID,
return &model.Task{
Base: doc.Base,
EventID: doc.EventID,
EndpointID: doc.EndpointID,
EndpointRef: doc.EndpointRef,
EndpointURL: doc.EndpointURL,
SigningMode: doc.SigningMode,
SecretRef: doc.SecretRef,

View File

@@ -3,12 +3,14 @@ package subscriptions
import (
"context"
"github.com/tech/sendico/edge/callbacks/internal/model"
"github.com/tech/sendico/edge/callbacks/internal/storage"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Resolver resolves active webhook endpoints for an event.
type Resolver interface {
Resolve(ctx context.Context, clientID, eventType string) ([]storage.Endpoint, error)
Resolve(ctx context.Context, eventType string, organizationRef bson.ObjectID) ([]model.Endpoint, error)
}
// Dependencies defines subscriptions resolver dependencies.

View File

@@ -4,8 +4,10 @@ import (
"context"
"strings"
"github.com/tech/sendico/edge/callbacks/internal/model"
"github.com/tech/sendico/edge/callbacks/internal/storage"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
type service struct {
@@ -21,15 +23,15 @@ func New(deps Dependencies) (Resolver, error) {
return &service{repo: deps.EndpointRepo}, nil
}
func (s *service) Resolve(ctx context.Context, clientID, eventType string) ([]storage.Endpoint, error) {
if strings.TrimSpace(clientID) == "" {
func (s *service) Resolve(ctx context.Context, eventType string, organizationRef bson.ObjectID) ([]model.Endpoint, error) {
if organizationRef == bson.NilObjectID {
return nil, merrors.InvalidArgument("subscriptions: client id is required", "clientID")
}
if strings.TrimSpace(eventType) == "" {
return nil, merrors.InvalidArgument("subscriptions: event type is required", "eventType")
}
endpoints, err := s.repo.FindActiveByClientAndType(ctx, clientID, eventType)
endpoints, err := s.repo.FindActive(ctx, eventType, organizationRef)
if err != nil {
return nil, err
}