internal/rest/subscription.go
Rama main · réplica de lectura del repositorio original.
package rest
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/sarathsp06/sparrow/internal/webhooks"
"github.com/sarathsp06/sparrow/internal/webhooks/store"
"github.com/sarathsp06/sparrow/pkg/access/httpauth"
)
type createSubscriptionBody struct {
WebhookID string `json:"webhook_id" required:"true" doc:"Webhook id (UUID) to deliver matching events to. Must belong to this consumer."`
EventName string `json:"event_name" required:"true" doc:"Event type name to subscribe to, or \"*\" to receive every event in the consumer (catch-all). sparrow.* names only under the _sparrow consumer. One subscription per webhook, event name and consumer (409 on a duplicate)."`
Headers map[string]string `json:"headers,omitempty" doc:"Extra HTTP headers sent with deliveries from this subscription. They override the webhook's headers of the same name; the webhook's secret_headers override both. Same name and value rules as webhook headers."`
Method string `json:"method,omitempty" doc:"HTTP method used for deliveries from this subscription, e.g. PUT. Defaults to POST."`
Timeout int `json:"timeout,omitempty" doc:"Request timeout for this subscription's deliveries, in seconds, overriding the webhook's request_timeout_seconds. 0 to 300; 0 (default) uses the webhook's."`
TransformEnabled bool `json:"transform_enabled,omitempty" doc:"Whether to render transform_template into the delivered body instead of sending the default envelope. Defaults to false."`
TransformTemplate string `json:"transform_template,omitempty" doc:"Go text/template rendered against the event to produce the delivered body, used when transform_enabled is true. See GET /v1/template-functions for available helpers; test it with POST /v1/subscriptions:testTemplate. A template that does not parse is rejected with 400; it is not rendered on save. Saving a new template records a version in the template history."`
LabelFilters map[string]string `json:"label_filters,omitempty" doc:"Key/value pairs that must ALL be present in an event's labels for this subscription to receive it. Empty means match every event of event_name. Same limits as event labels (20 pairs, 64-character keys, 256-character values)."`
OnTransformError string `json:"on_transform_error,omitempty" enum:"fail,fallback" doc:"What happens when transform_template fails to render. fail (default): the delivery fails with error category template_error, is not retried automatically, and can be retried once the template is fixed; it does not affect the webhook's health. fallback: the default envelope payload is sent instead, and the error is still recorded on the delivery."`
TemplateMissingKey string `json:"template_missing_key,omitempty" enum:"error,zero" doc:"How transform_template reads a key the payload does not have. error (default): the render fails, so a field removed from the event schema cannot silently turn into \"<no value>\" in the body; read optional fields with index or dig (default only fills a key that is present but empty). zero: the key renders as \"<no value>\"."`
}
type createSubscriptionInput struct {
Consumer string `path:"consumer" doc:"Consumer the webhook belongs to."`
Body createSubscriptionBody
}
type subscriptionIDInput struct {
Consumer string `path:"consumer" doc:"Consumer the subscription belongs to."`
SubscriptionID string `path:"subscription_id" doc:"Subscription id (UUID)."`
}
type SubscriptionItem struct {
SubscriptionID string `json:"subscription_id" doc:"Subscription id (UUID)."`
Consumer string `json:"consumer" doc:"Consumer this subscription belongs to."`
WebhookID string `json:"webhook_id" doc:"Webhook this subscription delivers to."`
EventName string `json:"event_name" doc:"Event type name this subscription matches, or \"*\" for catch-all."`
Headers map[string]string `json:"headers,omitempty" doc:"Extra HTTP headers sent with deliveries from this subscription."`
Method string `json:"method,omitempty" enum:"GET,POST,PUT,PATCH,DELETE" doc:"HTTP method used for deliveries from this subscription; absent means POST."`
Timeout int `json:"timeout,omitempty" doc:"Request timeout override for this subscription's deliveries, in seconds; absent means the webhook's request_timeout_seconds."`
TransformEnabled bool `json:"transform_enabled" doc:"Whether transform_template is rendered into the delivered payload."`
TransformTemplate string `json:"transform_template,omitempty" doc:"Go text/template rendered against the event to produce the delivered body when transform_enabled is true."`
LabelFilters map[string]string `json:"label_filters,omitempty" doc:"Key/value pairs that must all be present in an event's labels for this subscription to receive it."`
OnTransformError string `json:"on_transform_error" enum:"fail,fallback" doc:"What happens when transform_template fails to render. fail (default): the delivery fails with error category template_error, is not retried automatically, and can be retried once the template is fixed; it does not affect the webhook's health. fallback: the default envelope payload is sent instead, and the error is still recorded on the delivery."`
TemplateMissingKey string `json:"template_missing_key" enum:"error,zero" doc:"How transform_template reads a key the payload does not have. error (default): the render fails, so a field removed from the event schema cannot silently turn into \"<no value>\" in the body; read optional fields with index or dig (default only fills a key that is present but empty). zero: the key renders as \"<no value>\"."`
Paused bool `json:"paused" doc:"Whether the subscription is paused. While paused, events still fan out to it, but each delivery is recorded with status paused and not attempted until retried. A pause never affects the webhook's health."`
PausedAt *string `json:"paused_at,omitempty" format:"date-time" doc:"When the current pause began, RFC3339. Absent when not paused."`
PausedReason string `json:"paused_reason,omitempty" doc:"Why the subscription was paused."`
CreatedAt string `json:"created_at" format:"date-time" doc:"Creation timestamp, RFC3339."`
UpdatedAt string `json:"updated_at" format:"date-time" doc:"Last-modified timestamp, RFC3339."`
}
type subscriptionOutput struct {
Body SubscriptionItem
}
func toSubscriptionItem(s *store.EventSubscription) SubscriptionItem {
item := SubscriptionItem{
SubscriptionID: s.ID.String(),
Consumer: s.Consumer,
WebhookID: s.WebhookID.String(),
EventName: s.EventName,
Headers: s.Headers,
Method: s.Method,
Timeout: s.Timeout,
TransformEnabled: s.TransformEnabled,
TransformTemplate: s.TransformTemplate,
LabelFilters: s.LabelFilters,
OnTransformError: s.OnTransformError,
TemplateMissingKey: s.TemplateMissingKey,
Paused: s.Paused(),
PausedReason: s.PausedReason,
CreatedAt: s.CreatedAt.Format(time.RFC3339Nano),
UpdatedAt: s.UpdatedAt.Format(time.RFC3339Nano),
}
if s.PausedAt != nil {
t := s.PausedAt.Format(time.RFC3339Nano)
item.PausedAt = &t
}
return item
}
type pauseSubscriptionInput struct {
Consumer string `path:"consumer" doc:"Consumer the subscription belongs to."`
SubscriptionID string `path:"subscription_id" doc:"Subscription id (UUID)."`
Body struct {
Reason string `json:"reason,omitempty" maxLength:"500" doc:"Why the subscription is paused, shown next to it, for example: receiver maintenance until Friday."`
}
}
type resumeSubscriptionOutput struct {
Body struct {
SubscriptionItem
PausedSince *string `json:"paused_since,omitempty" format:"date-time" doc:"When the pause that just ended began, RFC3339; absent when the subscription was not paused. List or retry what was held with status=paused&subscription_id=...&created_after=<paused_since>."`
PausedDeliveries int `json:"paused_deliveries" doc:"Deliveries of this subscription currently in status paused. They are not sent automatically; retry them to deliver."`
}
}
func toSubscriptionOutput(s *store.EventSubscription) *subscriptionOutput {
return &subscriptionOutput{Body: toSubscriptionItem(s)}
}
// SubscriptionListParams are the query filters shared by the consumer-scoped
// and global subscription list routes.
type SubscriptionListParams struct {
WebhookID string `query:"webhook_id,omitempty" doc:"Filter to subscriptions for one webhook (UUID). Takes precedence over event_name: when both are given, event_name is ignored."`
EventName string `query:"event_name,omitempty" doc:"Filter to subscriptions with exactly this event_name (\"*\" matches only catch-all subscriptions)."`
Limit int32 `query:"limit" default:"50" minimum:"1" maximum:"1000" doc:"Maximum items to return, 1 to 1000."`
Offset int32 `query:"offset" default:"0" doc:"Number of items to skip, for pagination."`
}
type listSubscriptionsInput struct {
Consumer string `path:"consumer" doc:"Consumer to list subscriptions in."`
SubscriptionListParams
}
type listSubscriptionsGlobalInput struct {
Consumer string `query:"consumer,omitempty" doc:"Filter to one consumer; omit to list subscriptions across all consumers."`
SubscriptionListParams
}
type listSubscriptionsOutput struct {
Body struct {
Items []SubscriptionItem `json:"items" doc:"Subscriptions, newest first."`
Pagination PaginationOutput `json:"pagination" doc:"Offset pagination with a total count."`
}
}
// patchSubscriptionBody applies a partial update: only fields present in the
// request JSON are changed. webhook_id and event_name are immutable —
// delete and recreate the subscription to change either.
type patchSubscriptionBody struct {
Headers *map[string]string `json:"headers,omitempty" doc:"Replace all extra HTTP headers."`
Method *string `json:"method,omitempty" doc:"Replace the HTTP method."`
Timeout *int `json:"timeout,omitempty" doc:"Replace the request timeout override, in seconds (0 to 300; 0 uses the webhook's)."`
TransformEnabled *bool `json:"transform_enabled,omitempty" doc:"Enable or disable payload transformation."`
TransformTemplate *string `json:"transform_template,omitempty" doc:"Replace the transform template. A template that does not parse is rejected with 400. A change is recorded as a new version in the template history (the last 20 are kept)."`
LabelFilters *map[string]string `json:"label_filters,omitempty" doc:"Replace all label filters; an empty object matches every event."`
OnTransformError *string `json:"on_transform_error,omitempty" enum:"fail,fallback" doc:"Replace what happens when the template fails to render."`
TemplateMissingKey *string `json:"template_missing_key,omitempty" enum:"error,zero" doc:"Replace how the template reads a missing key."`
TemplateSource string `json:"template_source,omitempty" enum:"manual,ai_draft" doc:"How a changed transform_template was produced, recorded in the subscription's template history. Defaults to manual."`
TemplateNotes string `json:"template_notes,omitempty" maxLength:"2000" doc:"Optional note stored with the template version, e.g. the AI drafter's summary."`
}
type templateVersionItem struct {
VersionID string `json:"version_id" doc:"Version id (UUID)."`
Template string `json:"template" doc:"The template text as saved."`
Source string `json:"source" enum:"manual,ai_draft" doc:"How the template was produced: manual or ai_draft."`
Notes string `json:"notes,omitempty" doc:"Note stored with the version."`
SavedBy string `json:"saved_by,omitempty" doc:"Name of the master key or access token that saved it; absent when auth is off."`
CreatedAt time.Time `json:"created_at" doc:"When it was saved, RFC3339."`
Current bool `json:"current" doc:"True for the newest version, which is the subscription's current template."`
}
type listTemplateVersionsInput struct {
Consumer string `path:"consumer" doc:"Consumer the subscription belongs to."`
SubscriptionID string `path:"subscription_id" doc:"Subscription id (UUID)."`
Limit int `query:"limit" minimum:"1" maximum:"20" default:"20" doc:"Maximum versions to return, newest first, 1 to 20."`
}
type listTemplateVersionsOutput struct {
Body struct {
Items []templateVersionItem `json:"items" doc:"Saved versions, newest first."`
}
}
type patchSubscriptionInput struct {
Consumer string `path:"consumer" doc:"Consumer the subscription belongs to."`
SubscriptionID string `path:"subscription_id" doc:"Subscription id (UUID)."`
Body patchSubscriptionBody
}
type testTemplateBody struct {
EventName string `json:"event_name" required:"true" doc:"Registered event type. Its name is the envelope's event_name, and its sample_payload (generated from its schema; empty without one) is rendered against when payload is omitted."`
Payload map[string]any `json:"payload,omitempty" required:"false" doc:"Payload to render against instead of the event type's generated sample, e.g. a real event's payload from listEventOccurrences. Used as given, even when empty; the event type needs no schema."`
Template string `json:"template" required:"true" doc:"Go template to render, in the same syntax used by transform_template."`
TemplateMissingKey string `json:"template_missing_key,omitempty" enum:"error,zero" required:"false" doc:"Render as a subscription with this template_missing_key would. Defaults to error, the subscription default."`
}
type testTemplateInput struct {
Body testTemplateBody
}
type testTemplateOutput struct {
Body struct {
Rendered string `json:"rendered" doc:"The template output, rendered against payload (or the event type's sample payload) with a placeholder event id and the current time."`
}
}
func registerSubscriptionRoutes(api huma.API, svc webhooks.SubscriptionManager) {
huma.Register(api, huma.Operation{
OperationID: "createSubscription",
Method: http.MethodPost,
Path: "/v1/consumers/{consumer}/subscriptions",
Summary: "Create a subscription linking a webhook to an event",
Description: "Subscribes a webhook to an event type within a consumer, with an optional payload transform and label filters. Registering a webhook with events already creates one subscription per name; use this for additional, catch-all (\"*\") or per-subscription-template subscriptions. A non-empty transform_template is recorded as the first template version.\n\n" +
"Not idempotent: 409 when the webhook already has a subscription for event_name. 404 when the webhook is not in this consumer. 400 for invalid headers, timeout or label filters, a sparrow.* event outside _sparrow, or a missing transform when the webhook has requires_transform.\n\n" +
"Portal tokens: allowed, as POST /portal/api/subscriptions.",
Errors: []int{400, 404, 409},
Tags: []string{"Subscriptions"},
DefaultStatus: http.StatusCreated,
}, func(ctx context.Context, in *createSubscriptionInput) (*subscriptionOutput, error) {
id, _, err := svc.CreateSubscription(ctx, in.Body.WebhookID, in.Body.EventName, in.Consumer, in.Body.Headers, in.Body.Method, in.Body.Timeout, in.Body.TransformEnabled, in.Body.TransformTemplate, in.Body.LabelFilters, webhooks.SubscriptionTemplateSettings{
OnTransformError: in.Body.OnTransformError,
TemplateMissingKey: in.Body.TemplateMissingKey,
})
if err != nil {
return nil, mapError(ctx, err, "failed to create subscription")
}
sub, err := svc.GetSubscription(ctx, id, in.Consumer)
if err != nil {
return nil, mapError(ctx, err, "failed to reload subscription")
}
return toSubscriptionOutput(sub), nil
})
huma.Register(api, huma.Operation{
OperationID: "getSubscription",
Method: http.MethodGet,
Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}",
Summary: "Get a subscription by id",
Description: "Returns one subscription: its webhook link, transform template and settings, label filters and pause state. 404 when it is not in this consumer.\n\nPortal tokens: allowed, as GET /portal/api/subscriptions/{subscription_id}.",
Errors: []int{404},
Tags: []string{"Subscriptions"},
}, func(ctx context.Context, in *subscriptionIDInput) (*subscriptionOutput, error) {
sub, err := svc.GetSubscription(ctx, in.SubscriptionID, in.Consumer)
if err != nil {
return nil, mapError(ctx, err, "failed to get subscription")
}
return toSubscriptionOutput(sub), nil
})
huma.Register(api, huma.Operation{
OperationID: "listSubscriptions",
Method: http.MethodGet,
Path: "/v1/consumers/{consumer}/subscriptions",
Summary: "List subscriptions",
Description: "Lists subscriptions in a consumer, newest first, optionally filtered by webhook or event type name. Offset paginated with total_count. 404 when webhook_id is given and the webhook is not in this consumer.\n\nPortal tokens: allowed, as GET /portal/api/subscriptions.",
Tags: []string{"Subscriptions"},
}, func(ctx context.Context, in *listSubscriptionsInput) (*listSubscriptionsOutput, error) {
return listSubscriptionsImpl(ctx, svc, in.Consumer, in.SubscriptionListParams)
})
huma.Register(api, huma.Operation{
OperationID: "listSubscriptionsGlobal",
Method: http.MethodGet,
Path: "/v1/subscriptions",
Summary: "List subscriptions across all consumers",
Description: "Lists subscriptions across every consumer with the same filters and pagination as the per-consumer route. Pass consumer to scope to one consumer.\n\nPortal tokens: not allowed.",
Tags: []string{"Subscriptions"},
}, func(ctx context.Context, in *listSubscriptionsGlobalInput) (*listSubscriptionsOutput, error) {
return listSubscriptionsImpl(ctx, svc, in.Consumer, in.SubscriptionListParams)
})
huma.Register(api, huma.Operation{
OperationID: "updateSubscription",
Method: http.MethodPatch,
Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}",
Summary: "Partially update a subscription",
Description: "Partially updates a subscription's headers, method, timeout, transform, or label filters: only fields present in the body change, and map fields replace the whole map. The linked webhook_id and event_name cannot be changed; delete and recreate instead. A changed transform_template is recorded as a new template version with template_source and template_notes. Returns the updated subscription. Idempotent: repeating the same body records no new version.\n\n" +
"400 for invalid values, or when the webhook has requires_transform and the result has no enabled template. 404 when it is not in this consumer.\n\nPortal tokens: allowed, as PATCH /portal/api/subscriptions/{subscription_id}.",
Errors: []int{400, 404},
Tags: []string{"Subscriptions"},
}, func(ctx context.Context, in *patchSubscriptionInput) (*subscriptionOutput, error) {
existing, err := svc.GetSubscription(ctx, in.SubscriptionID, in.Consumer)
if err != nil {
return nil, mapError(ctx, err, "failed to get subscription")
}
headers := map[string]string(existing.Headers)
if in.Body.Headers != nil {
headers = *in.Body.Headers
}
method := existing.Method
if in.Body.Method != nil {
method = *in.Body.Method
}
timeout := existing.Timeout
if in.Body.Timeout != nil {
timeout = *in.Body.Timeout
}
transformEnabled := existing.TransformEnabled
if in.Body.TransformEnabled != nil {
transformEnabled = *in.Body.TransformEnabled
}
transformTemplate := existing.TransformTemplate
if in.Body.TransformTemplate != nil {
transformTemplate = *in.Body.TransformTemplate
}
labelFilters := map[string]string(existing.LabelFilters)
if in.Body.LabelFilters != nil {
labelFilters = *in.Body.LabelFilters
}
settings := webhooks.SubscriptionTemplateSettings{
OnTransformError: existing.OnTransformError,
TemplateMissingKey: existing.TemplateMissingKey,
}
if in.Body.OnTransformError != nil {
settings.OnTransformError = *in.Body.OnTransformError
}
if in.Body.TemplateMissingKey != nil {
settings.TemplateMissingKey = *in.Body.TemplateMissingKey
}
meta := webhooks.TemplateSaveMeta{Source: in.Body.TemplateSource, Notes: in.Body.TemplateNotes}
// Record who saved it only for a real credential; with auth off the
// middleware supplies an anonymous placeholder that is not worth storing.
if p, ok := httpauth.FromContext(ctx); ok && (p.Root || p.TokenID != "") {
meta.SavedBy = p.Name
}
if err := svc.UpdateSubscription(ctx, in.SubscriptionID, in.Consumer, headers, method, timeout, transformEnabled, transformTemplate, labelFilters, settings, meta); err != nil {
return nil, mapError(ctx, err, "failed to update subscription")
}
updated, err := svc.GetSubscription(ctx, in.SubscriptionID, in.Consumer)
if err != nil {
return nil, mapError(ctx, err, "failed to reload subscription")
}
return toSubscriptionOutput(updated), nil
})
huma.Register(api, huma.Operation{
OperationID: "pauseSubscription",
Method: http.MethodPost,
Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}:pause",
Summary: "Pause a subscription",
Description: "Stops the subscription's deliveries from being attempted, for any reason (receiver maintenance, a template being fixed, an investigation). Events keep fanning out to it: each delivery is recorded with status paused and nothing is queued, so nothing is lost. Queued or retrying deliveries of the subscription are held as paused when their turn comes. A pause never affects the webhook's health. Returns the subscription (200), unlike webhook pause, which returns 204.\n\n" +
"Idempotent: pausing again updates the reason and keeps the original pause time. 400 for a reason over 500 characters, 404 when it is not in this consumer.\n\nPortal tokens: allowed, as POST /portal/api/subscriptions/{subscription_id}:pause.",
Errors: []int{400, 404},
Tags: []string{"Subscriptions"},
}, func(ctx context.Context, in *pauseSubscriptionInput) (*subscriptionOutput, error) {
sub, err := svc.PauseSubscription(ctx, in.SubscriptionID, in.Consumer, in.Body.Reason)
if err != nil {
return nil, mapError(ctx, err, "failed to pause subscription")
}
return toSubscriptionOutput(sub), nil
})
huma.Register(api, huma.Operation{
OperationID: "resumeSubscription",
Method: http.MethodPost,
Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}:resume",
Summary: "Resume a paused subscription",
Description: "Lets new deliveries be attempted again. Deliveries recorded while paused stay paused and are never sent automatically: the response says how many there are and when the pause began, so you can retry all of them or only those since a time with the delivery retry endpoints. Idempotent: resuming a subscription that is not paused succeeds with no paused_since.\n\n" +
"Portal tokens: allowed, as POST /portal/api/subscriptions/{subscription_id}:resume.",
Errors: []int{404},
Tags: []string{"Subscriptions"},
}, func(ctx context.Context, in *subscriptionIDInput) (*resumeSubscriptionOutput, error) {
res, err := svc.ResumeSubscription(ctx, in.SubscriptionID, in.Consumer)
if err != nil {
return nil, mapError(ctx, err, "failed to resume subscription")
}
out := &resumeSubscriptionOutput{}
out.Body.SubscriptionItem = toSubscriptionItem(res.Subscription)
out.Body.PausedDeliveries = res.PausedDeliveries
if res.PausedSince != nil {
t := res.PausedSince.Format(time.RFC3339Nano)
out.Body.PausedSince = &t
}
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "listSubscriptionTemplateVersions",
Method: http.MethodGet,
Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}/templateVersions",
Summary: "List a subscription's saved template history",
Description: "Returns the transform templates this subscription has been saved with, newest first; the newest is the current one. A version is recorded whenever a save changes the template, with how it was produced (manual or ai_draft), an optional note, and who saved it. The last 20 are kept. There is no restore endpoint: load a version into the editor and save the subscription, which records it as a new version.\n\nPortal tokens: allowed, as GET /portal/api/subscriptions/{subscription_id}/templateVersions.",
Errors: []int{404},
Tags: []string{"Subscriptions"},
}, func(ctx context.Context, in *listTemplateVersionsInput) (*listTemplateVersionsOutput, error) {
versions, err := svc.ListSubscriptionTemplateVersions(ctx, in.SubscriptionID, in.Consumer, in.Limit)
if err != nil {
return nil, mapError(ctx, err, "failed to list template versions")
}
out := &listTemplateVersionsOutput{}
out.Body.Items = make([]templateVersionItem, 0, len(versions))
for i, v := range versions {
out.Body.Items = append(out.Body.Items, templateVersionItem{
VersionID: v.ID.String(), Template: v.Template, Source: v.Source, Notes: v.Notes,
SavedBy: v.SavedBy, CreatedAt: v.CreatedAt, Current: i == 0,
})
}
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteSubscription",
Method: http.MethodDelete,
Path: "/v1/consumers/{consumer}/subscriptions/{subscription_id}",
Summary: "Delete a subscription",
Description: "Deletes the subscription and its template history. The webhook stops receiving that event's occurrences; delivery history is kept. Cannot be undone. A second delete returns 404.\n\nPortal tokens: allowed, as DELETE /portal/api/subscriptions/{subscription_id}.",
Errors: []int{404},
Tags: []string{"Subscriptions"},
DefaultStatus: http.StatusNoContent,
}, func(ctx context.Context, in *subscriptionIDInput) (*emptyOutput, error) {
if err := svc.DeleteSubscription(ctx, in.SubscriptionID, in.Consumer); err != nil {
return nil, mapError(ctx, err, "failed to delete subscription")
}
return &emptyOutput{Status: http.StatusNoContent}, nil
})
huma.Register(api, huma.Operation{
OperationID: "testSubscriptionTemplate",
Method: http.MethodPost,
Path: "/v1/subscriptions:testTemplate",
Summary: "Render a transform template against a sample or given payload",
Description: "Dry-runs a transform template against payload when given, else the named event type's generated sample payload, without creating a subscription or any delivery. Use this to iterate on transform_template before saving it; pass a real event's payload to see what a receiver would get. Stateless and idempotent.\n\n" +
"400 when the template does not parse or fails to render (with template_missing_key error, a key the payload lacks is an error), 404 when the event type is not registered, 503 when the template engine is at capacity (retry).\n\nPortal tokens: allowed, as POST /portal/api/subscriptions:testTemplate.",
Errors: []int{400, 404, 503},
Tags: []string{"Subscriptions"},
}, func(ctx context.Context, in *testTemplateInput) (*testTemplateOutput, error) {
rendered, err := svc.TestSubscriptionTemplate(ctx, in.Body.EventName, in.Body.Template, "", in.Body.TemplateMissingKey != store.TemplateMissingKeyZero, in.Body.Payload)
if err != nil {
return nil, mapError(ctx, err, "failed to test template")
}
out := &testTemplateOutput{}
out.Body.Rendered = rendered
return out, nil
})
}
// listSubscriptionsImpl is the shared body of the consumer-scoped and global
// subscription list routes. An empty consumer lists across all consumers.
func listSubscriptionsImpl(ctx context.Context, svc webhooks.SubscriptionManager, consumer string, p SubscriptionListParams) (*listSubscriptionsOutput, error) {
subs, total, err := svc.ListSubscriptions(ctx, consumer, p.WebhookID, p.EventName, p.Limit, p.Offset)
if err != nil {
return nil, mapError(ctx, err, "failed to list subscriptions")
}
out := &listSubscriptionsOutput{}
out.Body.Items = make([]SubscriptionItem, 0, len(subs))
for _, s := range subs {
out.Body.Items = append(out.Body.Items, toSubscriptionItem(s))
}
out.Body.Pagination = newPagination(p.Limit, p.Offset, total)
return out, nil
}