diff --git a/api-contracts/openapi/components/schemas/_index.yaml b/api-contracts/openapi/components/schemas/_index.yaml index 26dfc6112..0114a27e3 100644 --- a/api-contracts/openapi/components/schemas/_index.yaml +++ b/api-contracts/openapi/components/schemas/_index.yaml @@ -108,6 +108,14 @@ V1FilterList: $ref: "./v1/filter.yaml#/V1FilterList" V1Filter: $ref: "./v1/filter.yaml#/V1Filter" +V1WebhookList: + $ref: "./v1/webhook.yaml#/V1WebhookList" +V1Webhook: + $ref: "./v1/webhook.yaml#/V1Webhook" +V1WebhookSourceName: + $ref: "./v1/webhook.yaml#/V1WebhookSourceName" +V1WebhookAuthType: + $ref: "./v1/webhook.yaml#/V1WebhookAuthType" RateLimit: $ref: "./rate_limits.yaml#/RateLimit" RateLimitList: @@ -346,6 +354,8 @@ V1TaskTimingList: $ref: "./v1/task.yaml#/V1TaskTimingList" V1CreateFilterRequest: $ref: "./v1/filter.yaml#/V1CreateFilterRequest" +V1CreateWebhookRequest: + $ref: "./v1/webhook.yaml#/V1CreateWebhookRequest" V1UpdateFilterRequest: $ref: "./v1/filter.yaml#/V1UpdateFilterRequest" V1CELDebugRequest: diff --git a/api-contracts/openapi/components/schemas/v1/event.yaml b/api-contracts/openapi/components/schemas/v1/event.yaml index 6ce80a6b8..64965a931 100644 --- a/api-contracts/openapi/components/schemas/v1/event.yaml +++ b/api-contracts/openapi/components/schemas/v1/event.yaml @@ -48,6 +48,9 @@ V1Event: type: array items: $ref: "#/V1EventTriggeredRun" + triggeringWebhookName: + type: string + description: The name of the webhook that triggered this event, if applicable. required: - metadata diff --git a/api-contracts/openapi/components/schemas/v1/webhook.yaml b/api-contracts/openapi/components/schemas/v1/webhook.yaml new file mode 100644 index 000000000..41c7342ad --- /dev/null +++ b/api-contracts/openapi/components/schemas/v1/webhook.yaml @@ -0,0 +1,186 @@ +V1Webhook: + properties: + metadata: + $ref: "../metadata.yaml#/APIResourceMeta" + tenantId: + type: string + description: The ID of the tenant associated with this webhook. + name: + type: string + description: The name of the webhook + sourceName: + $ref: "#/V1WebhookSourceName" + description: The name of the source for this webhook + eventKeyExpression: + type: string + description: The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + authType: + $ref: "#/V1WebhookAuthType" + description: The type of authentication to use for the webhook + required: + - metadata + - tenantId + - name + - sourceName + - eventKeyExpression + - authType + +V1WebhookList: + type: object + properties: + pagination: + $ref: "../metadata.yaml#/PaginationResponse" + rows: + type: array + items: + $ref: "#/V1Webhook" + + +V1WebhookAuthType: + type: string + enum: + - BASIC + - API_KEY + - HMAC + +# TODO: Add more sources here as we support them +# IMPORTANT: Keep these in sync with the database enum v1_incoming_webhook_source_name +V1WebhookSourceName: + type: string + enum: + - GENERIC + - GITHUB + - STRIPE + +V1WebhookHMACAlgorithm: + type: string + enum: + - SHA1 + - SHA256 + - SHA512 + - MD5 + +V1WebhookHMACEncoding: + type: string + enum: + - HEX + - BASE64 + - BASE64URL + +V1WebhookBasicAuth: + type: object + properties: + username: + type: string + description: The username for basic auth + password: + type: string + description: The password for basic auth + required: + - username + - password + +V1WebhookAPIKeyAuth: + type: object + properties: + headerName: + type: string + description: The name of the header to use for the API key + apiKey: + type: string + description: The API key to use for authentication + required: + - headerName + - apiKey + +V1WebhookHMACAuth: + type: object + properties: + algorithm: + $ref: "#/V1WebhookHMACAlgorithm" + description: The HMAC algorithm to use for the webhook + encoding: + $ref: "#/V1WebhookHMACEncoding" + description: The encoding to use for the HMAC signature + signatureHeaderName: + type: string + description: The name of the header to use for the HMAC signature + signingSecret: + type: string + description: The secret key used to sign the HMAC signature + required: + - algorithm + - encoding + - signatureHeaderName + - signingSecret + +V1CreateWebhookRequestBase: + type: object + properties: + sourceName: + $ref: "#/V1WebhookSourceName" + description: The name of the source for this webhook + name: + type: string + description: The name of the webhook + eventKeyExpression: + type: string + description: The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + required: + - name + - sourceName + - eventKeyExpression + +V1CreateWebhookRequestBasicAuth: + allOf: + - $ref: "#/V1CreateWebhookRequestBase" + - type: object + properties: + authType: + type: string + enum: + - BASIC + description: The type of authentication to use for the webhook + auth: + $ref: "#/V1WebhookBasicAuth" + required: + - authType + - auth + +V1CreateWebhookRequestAPIKey: + allOf: + - $ref: "#/V1CreateWebhookRequestBase" + - type: object + properties: + authType: + type: string + enum: + - API_KEY + description: The type of authentication to use for the webhook + auth: + $ref: "#/V1WebhookAPIKeyAuth" + required: + - authType + - auth + +V1CreateWebhookRequestHMAC: + allOf: + - $ref: "#/V1CreateWebhookRequestBase" + - type: object + properties: + authType: + type: string + enum: + - HMAC + description: The type of authentication to use for the webhook + auth: + $ref: "#/V1WebhookHMACAuth" + required: + - authType + - auth + +V1CreateWebhookRequest: + oneOf: + - $ref: "#/V1CreateWebhookRequestBasicAuth" + - $ref: "#/V1CreateWebhookRequestAPIKey" + - $ref: "#/V1CreateWebhookRequestHMAC" diff --git a/api-contracts/openapi/openapi.yaml b/api-contracts/openapi/openapi.yaml index cfcf1468c..e7eee6fa3 100644 --- a/api-contracts/openapi/openapi.yaml +++ b/api-contracts/openapi/openapi.yaml @@ -58,6 +58,13 @@ paths: $ref: "./paths/v1/filters/filter.yaml#/V1FilterListCreate" /api/v1/stable/tenants/{tenant}/filters/{v1-filter}: $ref: "./paths/v1/filters/filter.yaml#/V1FilterGetDeleteUpdate" + /api/v1/stable/tenants/{tenant}/webhooks: + $ref: "./paths/v1/webhooks/webhook.yaml#/V1WebhookListCreate" + + # !!! IMPORTANT: we directly reference this path in the webhook rate limiter in `webhook_rate_limit.go` + # changing this path will break the rate limiter. !!! + /api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook}: + $ref: "./paths/v1/webhooks/webhook.yaml#/V1WebhookGetDeleteReceive" /api/v1/stable/tenants/{tenant}/cel/debug: $ref: "./paths/v1/cel/cel.yaml#/V1CELDebug" /api/ready: diff --git a/api-contracts/openapi/paths/v1/webhooks/webhook.yaml b/api-contracts/openapi/paths/v1/webhooks/webhook.yaml new file mode 100644 index 000000000..842cd1fd6 --- /dev/null +++ b/api-contracts/openapi/paths/v1/webhooks/webhook.yaml @@ -0,0 +1,263 @@ +V1WebhookGetDeleteReceive: + get: + description: Get a webhook by its name + operationId: v1-webhook:get + parameters: + - description: The tenant id + in: path + name: tenant + required: true + schema: + type: string + format: uuid + minLength: 36 + maxLength: 36 + - description: The webhook name + in: path + name: v1-webhook + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/V1Webhook" + description: Successfully got the webhook + "400": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: A malformed or bad request + "403": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: Forbidden + summary: Get a webhook + tags: + - Webhook + delete: + x-resources: ["tenant", "v1-webhook"] + description: Delete a webhook + operationId: v1-webhook:delete + parameters: + - description: The tenant id + in: path + name: tenant + required: true + schema: + type: string + format: uuid + minLength: 36 + maxLength: 36 + - description: The name of the webhook to delete + in: path + name: v1-webhook + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/V1Webhook" + description: Successfully deleted webhook + "400": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: A malformed or bad request + "403": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: Not found + tags: + - Webhook + post: + x-resources: ["tenant", "v1-webhook"] + description: Post an incoming webhook message + operationId: v1-webhook:receive + security: [] + parameters: + - description: The tenant id + in: path + name: tenant + required: true + schema: + type: string + format: uuid + minLength: 36 + maxLength: 36 + - description: The webhook name + in: path + name: v1-webhook + required: true + schema: + type: string + requestBody: + description: Any payload in any format + required: false + content: + text/plain: + schema: + type: string + responses: + '200': + description: 'Success' + required: false + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'OK' + "400": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: A malformed or bad request + "403": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: Forbidden + summary: Post a webhook message + tags: + - Webhook + +V1WebhookListCreate: + get: + x-resources: ["tenant"] + description: Lists all webhook for a tenant. + operationId: v1-webhook:list + parameters: + - description: The tenant id + in: path + name: tenant + required: true + schema: + type: string + format: uuid + minLength: 36 + maxLength: 36 + - description: The number to skip + in: query + name: offset + required: false + schema: + type: integer + format: int64 + - description: The number to limit by + in: query + name: limit + required: false + schema: + type: integer + format: int64 + - description: The source names to filter by + in: query + name: sourceNames + required: false + schema: + type: array + items: + $ref: "../../../components/schemas/_index.yaml#/V1WebhookSourceName" + description: The source name + - description: The webhook names to filter by + in: query + name: webhookNames + required: false + schema: + type: array + items: + type: string + description: The webhook name + responses: + "200": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/V1WebhookList" + description: Successfully listed the webhooks + "400": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: A malformed or bad request + "403": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: Forbidden + summary: List webhooks + tags: + - Webhook + post: + x-resources: ["tenant"] + description: Create a new webhook + operationId: v1-webhook:create + parameters: + - description: The tenant id + in: path + name: tenant + required: true + schema: + type: string + format: uuid + minLength: 36 + maxLength: 36 + requestBody: + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/V1CreateWebhookRequest" + description: The input to the webhook creation + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/V1Webhook" + description: Successfully created the webhook + "400": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: A malformed or bad request + "403": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "../../../components/schemas/_index.yaml#/APIErrors" + description: Not found + summary: Create a webhook + tags: + - Webhook diff --git a/api/v1/server/authn/middleware.go b/api/v1/server/authn/middleware.go index b242b8feb..73b7bedd4 100644 --- a/api/v1/server/authn/middleware.go +++ b/api/v1/server/authn/middleware.go @@ -36,6 +36,7 @@ func NewAuthN(config *server.ServerConfig) *AuthN { func (a *AuthN) Middleware(r *middleware.RouteInfo) echo.HandlerFunc { return func(c echo.Context) error { err := a.authenticate(c, r) + if err != nil { return err } @@ -58,6 +59,7 @@ func (a *AuthN) authenticate(c echo.Context, r *middleware.RouteInfo) error { if r.Security.CookieAuth() { cookieErr = a.handleCookieAuth(c) + c.Set("auth_strategy", "cookie") if cookieErr == nil { @@ -73,6 +75,7 @@ func (a *AuthN) authenticate(c echo.Context, r *middleware.RouteInfo) error { if r.Security.BearerAuth() { bearerErr = a.handleBearerAuth(c) + c.Set("auth_strategy", "bearer") if bearerErr == nil { diff --git a/api/v1/server/handlers/events/create.go b/api/v1/server/handlers/events/create.go index b0a07fdcb..e2ee31e99 100644 --- a/api/v1/server/handlers/events/create.go +++ b/api/v1/server/handlers/events/create.go @@ -32,7 +32,7 @@ func (t *EventService) EventCreate(ctx echo.Context, request gen.EventCreateRequ } } - newEvent, err := t.config.Ingestor.IngestEvent(ctx.Request().Context(), tenant, request.Body.Key, dataBytes, additionalMetadata, request.Body.Priority, request.Body.Scope) + newEvent, err := t.config.Ingestor.IngestEvent(ctx.Request().Context(), tenant, request.Body.Key, dataBytes, additionalMetadata, request.Body.Priority, request.Body.Scope, nil) if err != nil { if err == metered.ErrResourceExhausted { diff --git a/api/v1/server/handlers/ingestors/sns.go b/api/v1/server/handlers/ingestors/sns.go index 9e99f3c66..0dea44f82 100644 --- a/api/v1/server/handlers/ingestors/sns.go +++ b/api/v1/server/handlers/ingestors/sns.go @@ -64,7 +64,7 @@ func (i *IngestorsService) SnsUpdate(ctx echo.Context, req gen.SnsUpdateRequestO return nil, err } default: - _, err := i.config.Ingestor.IngestEvent(ctx.Request().Context(), tenant, req.Event, body, nil, nil, nil) + _, err := i.config.Ingestor.IngestEvent(ctx.Request().Context(), tenant, req.Event, body, nil, nil, nil, nil) if err != nil { return nil, err diff --git a/api/v1/server/handlers/v1/tasks/get.go b/api/v1/server/handlers/v1/tasks/get.go index 01ae70f4e..aee7ec935 100644 --- a/api/v1/server/handlers/v1/tasks/get.go +++ b/api/v1/server/handlers/v1/tasks/get.go @@ -13,7 +13,13 @@ func (t *TasksService) V1TaskGet(ctx echo.Context, request gen.V1TaskGetRequestO taskInterface := ctx.Get("task") if taskInterface == nil { - return nil, echo.NewHTTPError(404, "Task not found") + return gen.V1TaskGet404JSONResponse{ + Errors: []gen.APIError{ + { + Description: "task not found", + }, + }, + }, nil } task, ok := taskInterface.(*sqlcv1.V1TasksOlap) diff --git a/api/v1/server/handlers/v1/webhooks/create.go b/api/v1/server/handlers/v1/webhooks/create.go new file mode 100644 index 000000000..f4412793e --- /dev/null +++ b/api/v1/server/handlers/v1/webhooks/create.go @@ -0,0 +1,184 @@ +package webhooksv1 + +import ( + "encoding/json" + "fmt" + + "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers/v1" + "github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc" + "github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers" + v1 "github.com/hatchet-dev/hatchet/pkg/repository/v1" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" + "github.com/labstack/echo/v4" +) + +func (w *V1WebhooksService) V1WebhookCreate(ctx echo.Context, request gen.V1WebhookCreateRequestObject) (gen.V1WebhookCreateResponseObject, error) { + tenant := ctx.Get("tenant").(*dbsqlc.Tenant) + + canCreate, _, err := w.config.EntitlementRepository.TenantLimit().CanCreate(ctx.Request().Context(), dbsqlc.LimitResourceINCOMINGWEBHOOK, tenant.ID.String(), 1) + + if err != nil { + return nil, fmt.Errorf("failed to check if webhook can be created: %w", err) + } + + if !canCreate { + return gen.V1WebhookCreate400JSONResponse{ + Errors: []gen.APIError{ + { + Description: "incoming webhook limit reached", + }, + }, + }, nil + } + + params, err := w.constructCreateOpts(tenant.ID.String(), *request.Body) + if err != nil { + return gen.V1WebhookCreate400JSONResponse{ + Errors: []gen.APIError{ + { + Description: fmt.Sprintf("failed to construct webhook create params: %v", err), + }, + }, + }, nil + } + + webhook, err := w.config.V1.Webhooks().CreateWebhook( + ctx.Request().Context(), + tenant.ID.String(), + params, + ) + + if err != nil { + return nil, fmt.Errorf("failed to create webhook") + } + + transformed := transformers.ToV1Webhook(webhook) + + return gen.V1WebhookCreate200JSONResponse(transformed), nil +} + +func extractAuthType(request gen.V1CreateWebhookRequest) (sqlcv1.V1IncomingWebhookAuthType, error) { + j, err := request.MarshalJSON() + + if err != nil { + return "", fmt.Errorf("failed to get marshal request: %w", err) + } + + parsedBody := make(map[string]interface{}) + if err := json.Unmarshal(j, &parsedBody); err != nil { + return "", fmt.Errorf("failed to unmarshal request body: %w", err) + } + + unparsedDiscriminator, ok := parsedBody["authType"] + if !ok { + return "", fmt.Errorf("authType field is missing in the request body") + } + + discriminator := unparsedDiscriminator.(string) + + authType := sqlcv1.V1IncomingWebhookAuthType(discriminator) + if authType == "" { + return "", fmt.Errorf("invalid auth type: %s", unparsedDiscriminator) + } + + return authType, nil +} + +func (w *V1WebhooksService) constructCreateOpts(tenantId string, request gen.V1CreateWebhookRequest) (v1.CreateWebhookOpts, error) { + params := v1.CreateWebhookOpts{ + Tenantid: sqlchelpers.UUIDFromStr(tenantId), + } + + discriminator, err := extractAuthType(request) + + if err != nil { + return params, fmt.Errorf("failed to get discriminator: %w", err) + } + + authConfig := v1.AuthConfig{} + + switch discriminator { + case sqlcv1.V1IncomingWebhookAuthTypeBASIC: + basicAuth, err := request.AsV1CreateWebhookRequestBasicAuth() + if err != nil { + return params, fmt.Errorf("failed to parse basic auth: %w", err) + } + + authConfig.Type = sqlcv1.V1IncomingWebhookAuthTypeBASIC + + passwordEncrypted, err := w.config.Encryption.Encrypt([]byte(basicAuth.Auth.Password), "v1_webhook_basic_auth_password") + + if err != nil { + return params, fmt.Errorf("failed to encrypt basic auth password: %s", err.Error()) + } + + authConfig.BasicAuth = &v1.BasicAuthCredentials{ + Username: basicAuth.Auth.Username, + EncryptedPassword: passwordEncrypted, + } + + params.Sourcename = sqlcv1.V1IncomingWebhookSourceName(basicAuth.SourceName) + params.Name = basicAuth.Name + params.Eventkeyexpression = basicAuth.EventKeyExpression + params.AuthConfig = authConfig + case sqlcv1.V1IncomingWebhookAuthTypeAPIKEY: + apiKeyAuth, err := request.AsV1CreateWebhookRequestAPIKey() + if err != nil { + return params, fmt.Errorf("failed to parse api key auth: %w", err) + } + + authConfig.Type = sqlcv1.V1IncomingWebhookAuthTypeAPIKEY + + authConfig := v1.AuthConfig{ + Type: sqlcv1.V1IncomingWebhookAuthTypeAPIKEY, + } + + apiKeyEncrypted, err := w.config.Encryption.Encrypt([]byte(apiKeyAuth.Auth.ApiKey), "v1_webhook_api_key") + + if err != nil { + return params, fmt.Errorf("failed to encrypt api key: %s", err.Error()) + } + + authConfig.APIKeyAuth = &v1.APIKeyAuthCredentials{ + HeaderName: apiKeyAuth.Auth.HeaderName, + EncryptedKey: apiKeyEncrypted, + } + + params.Sourcename = sqlcv1.V1IncomingWebhookSourceName(apiKeyAuth.SourceName) + params.Name = apiKeyAuth.Name + params.Eventkeyexpression = apiKeyAuth.EventKeyExpression + params.AuthConfig = authConfig + case sqlcv1.V1IncomingWebhookAuthTypeHMAC: + hmacAuth, err := request.AsV1CreateWebhookRequestHMAC() + if err != nil { + return params, fmt.Errorf("failed to parse hmac auth: %w", err) + } + + authConfig := v1.AuthConfig{ + Type: sqlcv1.V1IncomingWebhookAuthTypeHMAC, + } + + signingSecretEncrypted, err := w.config.Encryption.Encrypt([]byte(hmacAuth.Auth.SigningSecret), "v1_webhook_hmac_signing_secret") + + if err != nil { + return params, fmt.Errorf("failed to encrypt api key: %s", err.Error()) + } + + authConfig.HMACAuth = &v1.HMACAuthCredentials{ + Algorithm: sqlcv1.V1IncomingWebhookHmacAlgorithm(hmacAuth.Auth.Algorithm), + Encoding: sqlcv1.V1IncomingWebhookHmacEncoding(hmacAuth.Auth.Encoding), + SignatureHeaderName: hmacAuth.Auth.SignatureHeaderName, + EncryptedWebhookSigningSecret: signingSecretEncrypted, + } + + params.Sourcename = sqlcv1.V1IncomingWebhookSourceName(hmacAuth.SourceName) + params.Name = hmacAuth.Name + params.Eventkeyexpression = hmacAuth.EventKeyExpression + params.AuthConfig = authConfig + default: + return params, fmt.Errorf("unsupported auth type: %s", discriminator) + } + + return params, nil +} diff --git a/api/v1/server/handlers/v1/webhooks/delete.go b/api/v1/server/handlers/v1/webhooks/delete.go new file mode 100644 index 000000000..6926f2a4c --- /dev/null +++ b/api/v1/server/handlers/v1/webhooks/delete.go @@ -0,0 +1,29 @@ +package webhooksv1 + +import ( + "github.com/hatchet-dev/hatchet/api/v1/server/oas/apierrors" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers/v1" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" + "github.com/labstack/echo/v4" +) + +func (w *V1WebhooksService) V1WebhookDelete(ctx echo.Context, request gen.V1WebhookDeleteRequestObject) (gen.V1WebhookDeleteResponseObject, error) { + webhook := ctx.Get("v1-webhook").(*sqlcv1.V1IncomingWebhook) + + webhook, err := w.config.V1.Webhooks().DeleteWebhook( + ctx.Request().Context(), + webhook.TenantID.String(), + webhook.Name, + ) + + if err != nil { + return gen.V1WebhookDelete400JSONResponse(apierrors.NewAPIErrors("failed to delete webhook")), nil + } + + transformed := transformers.ToV1Webhook(webhook) + + return gen.V1WebhookDelete200JSONResponse( + transformed, + ), nil +} diff --git a/api/v1/server/handlers/v1/webhooks/get.go b/api/v1/server/handlers/v1/webhooks/get.go new file mode 100644 index 000000000..b1f81c4cd --- /dev/null +++ b/api/v1/server/handlers/v1/webhooks/get.go @@ -0,0 +1,18 @@ +package webhooksv1 + +import ( + "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers/v1" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" + "github.com/labstack/echo/v4" +) + +func (w *V1WebhooksService) V1WebhookGet(ctx echo.Context, request gen.V1WebhookGetRequestObject) (gen.V1WebhookGetResponseObject, error) { + webhook := ctx.Get("v1-webhook").(*sqlcv1.V1IncomingWebhook) + + transformed := transformers.ToV1Webhook(webhook) + + return gen.V1WebhookGet200JSONResponse( + transformed, + ), nil +} diff --git a/api/v1/server/handlers/v1/webhooks/list.go b/api/v1/server/handlers/v1/webhooks/list.go new file mode 100644 index 000000000..00d57e898 --- /dev/null +++ b/api/v1/server/handlers/v1/webhooks/list.go @@ -0,0 +1,49 @@ +package webhooksv1 + +import ( + "github.com/hatchet-dev/hatchet/api/v1/server/oas/apierrors" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers/v1" + "github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc" + v1 "github.com/hatchet-dev/hatchet/pkg/repository/v1" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" + "github.com/labstack/echo/v4" +) + +func (w *V1WebhooksService) V1WebhookList(ctx echo.Context, request gen.V1WebhookListRequestObject) (gen.V1WebhookListResponseObject, error) { + tenant := ctx.Get("tenant").(*dbsqlc.Tenant) + + var sourceNames []sqlcv1.V1IncomingWebhookSourceName + var webhookNames []string + + if request.Params.SourceNames != nil { + for _, sourceName := range *request.Params.SourceNames { + sourceNames = append(sourceNames, sqlcv1.V1IncomingWebhookSourceName(sourceName)) + } + } + + if request.Params.WebhookNames != nil { + webhookNames = *request.Params.WebhookNames + } + + webhooks, err := w.config.V1.Webhooks().ListWebhooks( + ctx.Request().Context(), + tenant.ID.String(), + v1.ListWebhooksOpts{ + WebhookNames: webhookNames, + WebhookSourceNames: sourceNames, + Limit: request.Params.Limit, + Offset: request.Params.Offset, + }, + ) + + if err != nil { + return gen.V1WebhookList400JSONResponse(apierrors.NewAPIErrors("failed to list webhooks")), nil + } + + transformed := transformers.ToV1WebhookList(webhooks) + + return gen.V1WebhookList200JSONResponse( + transformed, + ), nil +} diff --git a/api/v1/server/handlers/v1/webhooks/receive.go b/api/v1/server/handlers/v1/webhooks/receive.go new file mode 100644 index 000000000..27773c00f --- /dev/null +++ b/api/v1/server/handlers/v1/webhooks/receive.go @@ -0,0 +1,459 @@ +package webhooksv1 + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" + "github.com/hatchet-dev/hatchet/internal/cel" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" + "github.com/labstack/echo/v4" +) + +func (w *V1WebhooksService) V1WebhookReceive(ctx echo.Context, request gen.V1WebhookReceiveRequestObject) (gen.V1WebhookReceiveResponseObject, error) { + tenantId := request.Tenant.String() + webhookName := request.V1Webhook + + tenant, err := w.config.APIRepository.Tenant().GetTenantByID(ctx.Request().Context(), tenantId) + + if err != nil || tenant == nil { + return gen.V1WebhookReceive400JSONResponse{ + Errors: []gen.APIError{ + { + Description: "tenant not found", + }, + }, + }, nil + } + + webhook, err := w.config.V1.Webhooks().GetWebhook(ctx.Request().Context(), tenantId, webhookName) + + if err != nil || webhook == nil { + return gen.V1WebhookReceive400JSONResponse{ + Errors: []gen.APIError{ + { + Description: fmt.Sprintf("webhook %s not found for tenant %s", webhookName, tenantId), + }, + }, + }, nil + } + + if webhook.TenantID.String() != tenantId { + return gen.V1WebhookReceive403JSONResponse{ + Errors: []gen.APIError{ + { + Description: fmt.Sprintf("webhook %s does not belong to tenant %s", webhookName, tenantId), + }, + }, + }, nil + } + + rawBody, err := io.ReadAll(ctx.Request().Body) + + if err != nil { + return nil, fmt.Errorf("failed to read request body: %w", err) + } + + ok, validationError := w.validateWebhook(rawBody, *webhook, *ctx.Request()) + + if !ok { + err := w.config.Ingestor.IngestWebhookValidationFailure( + ctx.Request().Context(), + tenant, + webhook.Name, + validationError.ErrorText, + ) + + if err != nil { + return nil, fmt.Errorf("failed to ingest webhook validation failure: %w", err) + } + + return validationError.ToResponse() + } + + payloadMap := make(map[string]interface{}) + + if rawBody != nil { + err := json.Unmarshal(rawBody, &payloadMap) + + if err != nil { + return gen.V1WebhookReceive400JSONResponse{ + Errors: []gen.APIError{ + { + Description: fmt.Sprintf("failed to unmarshal request body: %v", err), + }, + }, + }, nil + } + + // This could cause unexpected behavior if the payload contains a key named "tenant" or "v1-webhook" + delete(payloadMap, "tenant") + delete(payloadMap, "v1-webhook") + } + + eventKey, err := w.celParser.EvaluateIncomingWebhookExpression(webhook.EventKeyExpression, cel.NewInput( + cel.WithInput(payloadMap), + ), + ) + + if err != nil { + if eventKey == "" { + err = fmt.Errorf("event key evaluted to an empty string") + } + + ingestionErr := w.config.Ingestor.IngestCELEvaluationFailure( + ctx.Request().Context(), + tenant.ID.String(), + err.Error(), + sqlcv1.V1CelEvaluationFailureSourceWEBHOOK, + ) + + if ingestionErr != nil { + return nil, fmt.Errorf("failed to ingest webhook validation failure: %w", ingestionErr) + } + + return gen.V1WebhookReceive400JSONResponse{ + Errors: []gen.APIError{ + { + Description: fmt.Sprintf("failed to evaluate event key expression: %v", err), + }, + }, + }, nil + } + + payload, err := json.Marshal(payloadMap) + if err != nil { + return gen.V1WebhookReceive400JSONResponse{ + Errors: []gen.APIError{ + { + Description: fmt.Sprintf("failed to marshal request body: %v", err), + }, + }, + }, nil + } + + _, err = w.config.Ingestor.IngestEvent( + ctx.Request().Context(), + tenant, + eventKey, + payload, + nil, + nil, + nil, + &webhook.Name, + ) + + if err != nil { + return nil, fmt.Errorf("failed to ingest event") + } + + msg := "ok" + + return gen.V1WebhookReceive200JSONResponse( + gen.V1WebhookReceive200JSONResponse{ + Message: &msg, + }, + ), nil +} + +func computeHMACSignature(payload []byte, secret []byte, algorithm sqlcv1.V1IncomingWebhookHmacAlgorithm, encoding sqlcv1.V1IncomingWebhookHmacEncoding) (string, error) { + var hashFunc func() hash.Hash + switch algorithm { + case sqlcv1.V1IncomingWebhookHmacAlgorithmSHA1: + hashFunc = sha1.New + case sqlcv1.V1IncomingWebhookHmacAlgorithmSHA256: + hashFunc = sha256.New + case sqlcv1.V1IncomingWebhookHmacAlgorithmSHA512: + hashFunc = sha512.New + case sqlcv1.V1IncomingWebhookHmacAlgorithmMD5: + hashFunc = md5.New + default: + return "", fmt.Errorf("unsupported HMAC algorithm: %s", algorithm) + } + + h := hmac.New(hashFunc, secret) + h.Write(payload) + signature := h.Sum(nil) + + switch encoding { + case sqlcv1.V1IncomingWebhookHmacEncodingHEX: + return hex.EncodeToString(signature), nil + case sqlcv1.V1IncomingWebhookHmacEncodingBASE64: + return base64.StdEncoding.EncodeToString(signature), nil + case sqlcv1.V1IncomingWebhookHmacEncodingBASE64URL: + return base64.URLEncoding.EncodeToString(signature), nil + default: + return "", fmt.Errorf("unsupported HMAC encoding: %s", encoding) + } +} + +type HttpResponseCode int + +const ( + Http400 HttpResponseCode = iota + Http403 + Http500 +) + +type ValidationError struct { + Code HttpResponseCode + ErrorText string +} + +func (vr ValidationError) ToResponse() (gen.V1WebhookReceiveResponseObject, error) { + switch vr.Code { + case Http400: + return gen.V1WebhookReceive400JSONResponse{ + Errors: []gen.APIError{ + { + Description: vr.ErrorText, + }, + }, + }, nil + case Http403: + return gen.V1WebhookReceive403JSONResponse{ + Errors: []gen.APIError{ + { + Description: vr.ErrorText, + }, + }, + }, nil + case Http500: + return nil, errors.New(vr.ErrorText) + default: + return nil, fmt.Errorf("no validation error set") + } +} + +func (w *V1WebhooksService) validateWebhook(webhookPayload []byte, webhook sqlcv1.V1IncomingWebhook, request http.Request) ( + bool, + *ValidationError, +) { + switch webhook.SourceName { + case sqlcv1.V1IncomingWebhookSourceNameSTRIPE: + signatureHeader := request.Header.Get(webhook.AuthHmacSignatureHeaderName.String) + + if signatureHeader == "" { + return false, &ValidationError{ + Code: Http400, + ErrorText: fmt.Sprintf("missing or invalid signature header: %s", webhook.AuthHmacSignatureHeaderName.String), + } + } + + splitHeader := strings.Split(signatureHeader, ",") + headersMap := make(map[string]string) + + for _, header := range splitHeader { + parts := strings.Split(header, "=") + if len(parts) != 2 { + return false, &ValidationError{ + Code: Http400, + ErrorText: fmt.Sprintf("invalid signature header format: %s", webhook.AuthHmacSignatureHeaderName.String), + } + } + headersMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + timestampHeader, hasTimestampHeader := headersMap["t"] + v1SignatureHeader, hasV1SignatureHeader := headersMap["v1"] + + if timestampHeader == "" || v1SignatureHeader == "" || !hasTimestampHeader || !hasV1SignatureHeader { + return false, &ValidationError{ + Code: Http400, + ErrorText: fmt.Sprintf("missing or invalid signature header: %s", webhook.AuthHmacSignatureHeaderName.String), + } + } + + timestamp := strings.TrimPrefix(timestampHeader, "t=") + signature := strings.TrimPrefix(v1SignatureHeader, "v1=") + + if timestamp == "" || signature == "" { + return false, &ValidationError{ + Code: Http400, + ErrorText: fmt.Sprintf("missing or invalid signature header: %s", webhook.AuthHmacSignatureHeaderName.String), + } + } + + parsedTimestamp, err := strconv.ParseInt(timestamp, 10, 64) + + if err != nil { + return false, &ValidationError{ + Code: Http400, + ErrorText: fmt.Sprintf("invalid timestamp in signature header: %s", err), + } + } + + if time.Unix(parsedTimestamp, 0).UTC().Before(time.Now().Add(-10 * time.Minute)) { + return false, &ValidationError{ + Code: Http400, + ErrorText: "timestamp in signature header is out of range", + } + } + + decryptedSigningSecret, err := w.config.Encryption.Decrypt(webhook.AuthHmacWebhookSigningSecret, "v1_webhook_hmac_signing_secret") + if err != nil { + return false, &ValidationError{ + Code: Http500, + ErrorText: fmt.Sprintf("failed to decrypt HMAC signing secret: %s", err), + } + } + + algorithm := webhook.AuthHmacAlgorithm.V1IncomingWebhookHmacAlgorithm + encoding := webhook.AuthHmacEncoding.V1IncomingWebhookHmacEncoding + + signedPayload := fmt.Sprintf("%s.%s", timestamp, webhookPayload) + + expectedSignature, err := computeHMACSignature([]byte(signedPayload), decryptedSigningSecret, algorithm, encoding) + + if err != nil { + return false, &ValidationError{ + Code: Http403, + ErrorText: fmt.Sprintf("failed to compute HMAC signature: %s", err), + } + } + + if !signaturesMatch(signature, expectedSignature) { + return false, &ValidationError{ + Code: Http403, + ErrorText: "invalid HMAC signature", + } + } + case sqlcv1.V1IncomingWebhookSourceNameGITHUB: + fallthrough + case sqlcv1.V1IncomingWebhookSourceNameGENERIC: + switch webhook.AuthMethod { + case sqlcv1.V1IncomingWebhookAuthTypeBASIC: + username, password, ok := request.BasicAuth() + + if !ok { + return false, &ValidationError{ + Code: Http403, + ErrorText: "missing or invalid authorization header", + } + } + + decryptedPassword, err := w.config.Encryption.Decrypt(webhook.AuthBasicPassword, "v1_webhook_basic_auth_password") + + if err != nil { + return false, &ValidationError{ + Code: Http500, + ErrorText: fmt.Sprintf("failed to decrypt basic auth password: %s", err), + } + } + + if username != webhook.AuthBasicUsername.String || password != string(decryptedPassword) { + return false, &ValidationError{ + Code: Http403, + ErrorText: "invalid basic auth credentials", + } + } + case sqlcv1.V1IncomingWebhookAuthTypeAPIKEY: + apiKey := request.Header.Get(webhook.AuthApiKeyHeaderName.String) + + if apiKey == "" { + return false, &ValidationError{ + Code: Http403, + ErrorText: fmt.Sprintf("missing or invalid api key header: %s", webhook.AuthApiKeyHeaderName.String), + } + } + + decryptedApiKey, err := w.config.Encryption.Decrypt(webhook.AuthApiKeyKey, "v1_webhook_api_key") + + if err != nil { + return false, &ValidationError{ + Code: Http500, + ErrorText: fmt.Sprintf("failed to decrypt api key: %s", err), + } + } + + if apiKey != string(decryptedApiKey) { + return false, &ValidationError{ + Code: Http403, + ErrorText: fmt.Sprintf("invalid api key: %s", webhook.AuthApiKeyHeaderName.String), + } + } + case sqlcv1.V1IncomingWebhookAuthTypeHMAC: + signature := request.Header.Get(webhook.AuthHmacSignatureHeaderName.String) + + if signature == "" { + return false, &ValidationError{ + Code: Http403, + ErrorText: fmt.Sprintf("missing or invalid signature header: %s", webhook.AuthHmacSignatureHeaderName.String), + } + } + + decryptedSigningSecret, err := w.config.Encryption.Decrypt(webhook.AuthHmacWebhookSigningSecret, "v1_webhook_hmac_signing_secret") + + if err != nil { + return false, &ValidationError{ + Code: Http500, + ErrorText: fmt.Sprintf("failed to decrypt HMAC signing secret: %s", err), + } + } + + algorithm := webhook.AuthHmacAlgorithm.V1IncomingWebhookHmacAlgorithm + encoding := webhook.AuthHmacEncoding.V1IncomingWebhookHmacEncoding + + expectedSignature, err := computeHMACSignature(webhookPayload, decryptedSigningSecret, algorithm, encoding) + + if err != nil { + return false, &ValidationError{ + Code: Http500, + ErrorText: fmt.Sprintf("failed to compute HMAC signature: %s", err), + } + } + + if !signaturesMatch(signature, expectedSignature) { + return false, &ValidationError{ + Code: Http403, + ErrorText: "invalid HMAC signature", + } + } + default: + return false, &ValidationError{ + Code: Http400, + ErrorText: fmt.Sprintf("unsupported auth type: %s", webhook.AuthMethod), + } + } + default: + return false, &ValidationError{ + Code: Http400, + ErrorText: fmt.Sprintf("unsupported source name: %+v", webhook.SourceName), + } + } + + return true, nil +} + +func signaturesMatch(providedSignature, expectedSignature string) bool { + providedSignature = strings.TrimSpace(providedSignature) + expectedSignature = strings.TrimSpace(expectedSignature) + + return hmac.Equal( + []byte(removePrefixesFromSignature(providedSignature)), + []byte(removePrefixesFromSignature(expectedSignature)), + ) +} + +func removePrefixesFromSignature(signature string) string { + signature = strings.TrimPrefix(signature, "sha1=") + signature = strings.TrimPrefix(signature, "sha256=") + signature = strings.TrimPrefix(signature, "sha512=") + signature = strings.TrimPrefix(signature, "md5=") + + return signature +} diff --git a/api/v1/server/handlers/v1/webhooks/service.go b/api/v1/server/handlers/v1/webhooks/service.go new file mode 100644 index 000000000..513d4db70 --- /dev/null +++ b/api/v1/server/handlers/v1/webhooks/service.go @@ -0,0 +1,18 @@ +package webhooksv1 + +import ( + "github.com/hatchet-dev/hatchet/internal/cel" + "github.com/hatchet-dev/hatchet/pkg/config/server" +) + +type V1WebhooksService struct { + config *server.ServerConfig + celParser *cel.CELParser +} + +func NewV1WebhooksService(config *server.ServerConfig) *V1WebhooksService { + return &V1WebhooksService{ + config: config, + celParser: cel.NewCELParser(), + } +} diff --git a/api/v1/server/middleware/populator/populator_test.go b/api/v1/server/middleware/populator/populator_test.go index f4fd01b8f..accc2df58 100644 --- a/api/v1/server/middleware/populator/populator_test.go +++ b/api/v1/server/middleware/populator/populator_test.go @@ -46,6 +46,15 @@ func topLevelResourceGetter(config *server.ServerConfig, parentId, id string) (i }, "", nil } +func invalidParentGetter(config *server.ServerConfig, parentId, id string) (interface{}, string, error) { + newUuid := uuid.NewString() + + return &oneToManyResource{ + ID: id, + ParentID: newUuid, + }, newUuid, nil +} + func TestPopulatorMiddleware(t *testing.T) { e := echo.New() req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -78,3 +87,35 @@ func TestPopulatorMiddleware(t *testing.T) { assert.NotNil(t, c.Get("resource1")) assert.NotNil(t, c.Get("resource2")) } + +func TestPopulatorMiddlewareParentDisagreement(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + // Mock RouteInfo + routeInfo := &middleware.RouteInfo{ + Resources: []string{"resource1", "resource2"}, + } + + resource1Id := uuid.New().String() + resource2Id := uuid.New().String() + + // Setting params for the context - NOTE: we need to set both params at once + c.SetParamNames("resource1", "resource2") + c.SetParamValues(resource1Id, resource2Id) + + // Creating Populator with mock getter function + populator := NewPopulator(&server.ServerConfig{}) + + populator.RegisterGetter("resource1", topLevelResourceGetter) + populator.RegisterGetter("resource2", invalidParentGetter) + + // Using the Populator middleware + middlewareFunc := populator.Middleware(routeInfo) + err := middlewareFunc(c) + + // Assertions + assert.ErrorContains(t, err, "could not be populated") +} diff --git a/api/v1/server/middleware/webhook_rate_limit.go b/api/v1/server/middleware/webhook_rate_limit.go new file mode 100644 index 000000000..cf01c0e83 --- /dev/null +++ b/api/v1/server/middleware/webhook_rate_limit.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "fmt" + "net/http" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog" + "golang.org/x/time/rate" +) + +func WebhookRateLimitMiddleware(rateLimit rate.Limit, burst int, l *zerolog.Logger) echo.MiddlewareFunc { + config := middleware.RateLimiterConfig{ + Skipper: func(c echo.Context) bool { + method := c.Request().Method + + if method != http.MethodPost { + return true + } + + tenantId := c.Param("tenant") + webhookName := c.Param("v1-webhook") + + if tenantId == "" || webhookName == "" { + return true + } + + return c.Request().URL.Path != fmt.Sprintf("/api/v1/stable/tenants/%s/webhooks/%s", tenantId, webhookName) + }, + + Store: middleware.NewRateLimiterMemoryStoreWithConfig( + middleware.RateLimiterMemoryStoreConfig{ + Rate: rateLimit, + Burst: burst, + ExpiresIn: 10 * time.Minute, + }, + ), + } + + return middleware.RateLimiterWithConfig(config) +} diff --git a/api/v1/server/oas/gen/openapi.gen.go b/api/v1/server/oas/gen/openapi.gen.go index cd4be383d..0a173c54a 100644 --- a/api/v1/server/oas/gen/openapi.gen.go +++ b/api/v1/server/oas/gen/openapi.gen.go @@ -200,6 +200,21 @@ const ( V1CELDebugResponseStatusSUCCESS V1CELDebugResponseStatus = "SUCCESS" ) +// Defines values for V1CreateWebhookRequestAPIKeyAuthType. +const ( + V1CreateWebhookRequestAPIKeyAuthTypeAPIKEY V1CreateWebhookRequestAPIKeyAuthType = "API_KEY" +) + +// Defines values for V1CreateWebhookRequestBasicAuthAuthType. +const ( + BASIC V1CreateWebhookRequestBasicAuthAuthType = "BASIC" +) + +// Defines values for V1CreateWebhookRequestHMACAuthType. +const ( + HMAC V1CreateWebhookRequestHMACAuthType = "HMAC" +) + // Defines values for V1LogLineLevel. const ( V1LogLineLevelDEBUG V1LogLineLevel = "DEBUG" @@ -241,6 +256,35 @@ const ( V1TaskStatusRUNNING V1TaskStatus = "RUNNING" ) +// Defines values for V1WebhookAuthType. +const ( + V1WebhookAuthTypeAPIKEY V1WebhookAuthType = "API_KEY" + V1WebhookAuthTypeBASIC V1WebhookAuthType = "BASIC" + V1WebhookAuthTypeHMAC V1WebhookAuthType = "HMAC" +) + +// Defines values for V1WebhookHMACAlgorithm. +const ( + MD5 V1WebhookHMACAlgorithm = "MD5" + SHA1 V1WebhookHMACAlgorithm = "SHA1" + SHA256 V1WebhookHMACAlgorithm = "SHA256" + SHA512 V1WebhookHMACAlgorithm = "SHA512" +) + +// Defines values for V1WebhookHMACEncoding. +const ( + BASE64 V1WebhookHMACEncoding = "BASE64" + BASE64URL V1WebhookHMACEncoding = "BASE64URL" + HEX V1WebhookHMACEncoding = "HEX" +) + +// Defines values for V1WebhookSourceName. +const ( + GENERIC V1WebhookSourceName = "GENERIC" + GITHUB V1WebhookSourceName = "GITHUB" + STRIPE V1WebhookSourceName = "STRIPE" +) + // Defines values for V1WorkflowType. const ( V1WorkflowTypeDAG V1WorkflowType = "DAG" @@ -1268,6 +1312,75 @@ type V1CreateFilterRequest struct { WorkflowId openapi_types.UUID `json:"workflowId"` } +// V1CreateWebhookRequest defines model for V1CreateWebhookRequest. +type V1CreateWebhookRequest struct { + union json.RawMessage +} + +// V1CreateWebhookRequestAPIKey defines model for V1CreateWebhookRequestAPIKey. +type V1CreateWebhookRequestAPIKey struct { + Auth V1WebhookAPIKeyAuth `json:"auth"` + + // AuthType The type of authentication to use for the webhook + AuthType V1CreateWebhookRequestAPIKeyAuthType `json:"authType"` + + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` +} + +// V1CreateWebhookRequestAPIKeyAuthType The type of authentication to use for the webhook +type V1CreateWebhookRequestAPIKeyAuthType string + +// V1CreateWebhookRequestBase defines model for V1CreateWebhookRequestBase. +type V1CreateWebhookRequestBase struct { + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` +} + +// V1CreateWebhookRequestBasicAuth defines model for V1CreateWebhookRequestBasicAuth. +type V1CreateWebhookRequestBasicAuth struct { + Auth V1WebhookBasicAuth `json:"auth"` + + // AuthType The type of authentication to use for the webhook + AuthType V1CreateWebhookRequestBasicAuthAuthType `json:"authType"` + + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` +} + +// V1CreateWebhookRequestBasicAuthAuthType The type of authentication to use for the webhook +type V1CreateWebhookRequestBasicAuthAuthType string + +// V1CreateWebhookRequestHMAC defines model for V1CreateWebhookRequestHMAC. +type V1CreateWebhookRequestHMAC struct { + Auth V1WebhookHMACAuth `json:"auth"` + + // AuthType The type of authentication to use for the webhook + AuthType V1CreateWebhookRequestHMACAuthType `json:"authType"` + + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` +} + +// V1CreateWebhookRequestHMACAuthType The type of authentication to use for the webhook +type V1CreateWebhookRequestHMACAuthType string + // V1DagChildren defines model for V1DagChildren. type V1DagChildren struct { Children *[]V1TaskSummary `json:"children,omitempty"` @@ -1297,8 +1410,11 @@ type V1Event struct { TenantId string `json:"tenantId"` // TriggeredRuns The external IDs of the runs that were triggered by this event. - TriggeredRuns *[]V1EventTriggeredRun `json:"triggeredRuns,omitempty"` - WorkflowRunSummary V1EventWorkflowRunSummary `json:"workflowRunSummary"` + TriggeredRuns *[]V1EventTriggeredRun `json:"triggeredRuns,omitempty"` + + // TriggeringWebhookName The name of the webhook that triggered this event, if applicable. + TriggeringWebhookName *string `json:"triggeringWebhookName,omitempty"` + WorkflowRunSummary V1EventWorkflowRunSummary `json:"workflowRunSummary"` } // V1EventList defines model for V1EventList. @@ -1619,6 +1735,70 @@ type V1UpdateFilterRequest struct { Scope *string `json:"scope,omitempty"` } +// V1Webhook defines model for V1Webhook. +type V1Webhook struct { + AuthType V1WebhookAuthType `json:"authType"` + + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + Metadata APIResourceMeta `json:"metadata"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` + + // TenantId The ID of the tenant associated with this webhook. + TenantId string `json:"tenantId"` +} + +// V1WebhookAPIKeyAuth defines model for V1WebhookAPIKeyAuth. +type V1WebhookAPIKeyAuth struct { + // ApiKey The API key to use for authentication + ApiKey string `json:"apiKey"` + + // HeaderName The name of the header to use for the API key + HeaderName string `json:"headerName"` +} + +// V1WebhookAuthType defines model for V1WebhookAuthType. +type V1WebhookAuthType string + +// V1WebhookBasicAuth defines model for V1WebhookBasicAuth. +type V1WebhookBasicAuth struct { + // Password The password for basic auth + Password string `json:"password"` + + // Username The username for basic auth + Username string `json:"username"` +} + +// V1WebhookHMACAlgorithm defines model for V1WebhookHMACAlgorithm. +type V1WebhookHMACAlgorithm string + +// V1WebhookHMACAuth defines model for V1WebhookHMACAuth. +type V1WebhookHMACAuth struct { + Algorithm V1WebhookHMACAlgorithm `json:"algorithm"` + Encoding V1WebhookHMACEncoding `json:"encoding"` + + // SignatureHeaderName The name of the header to use for the HMAC signature + SignatureHeaderName string `json:"signatureHeaderName"` + + // SigningSecret The secret key used to sign the HMAC signature + SigningSecret string `json:"signingSecret"` +} + +// V1WebhookHMACEncoding defines model for V1WebhookHMACEncoding. +type V1WebhookHMACEncoding string + +// V1WebhookList defines model for V1WebhookList. +type V1WebhookList struct { + Pagination *PaginationResponse `json:"pagination,omitempty"` + Rows *[]V1Webhook `json:"rows,omitempty"` +} + +// V1WebhookSourceName defines model for V1WebhookSourceName. +type V1WebhookSourceName string + // V1WorkflowRun defines model for V1WorkflowRun. type V1WorkflowRun struct { // AdditionalMetadata Additional metadata for the task run. @@ -2160,6 +2340,21 @@ type V1TaskGetPointMetricsParams struct { FinishedBefore *time.Time `form:"finishedBefore,omitempty" json:"finishedBefore,omitempty"` } +// V1WebhookListParams defines parameters for V1WebhookList. +type V1WebhookListParams struct { + // Offset The number to skip + Offset *int64 `form:"offset,omitempty" json:"offset,omitempty"` + + // Limit The number to limit by + Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"` + + // SourceNames The source names to filter by + SourceNames *[]V1WebhookSourceName `form:"sourceNames,omitempty" json:"sourceNames,omitempty"` + + // WebhookNames The webhook names to filter by + WebhookNames *[]string `form:"webhookNames,omitempty" json:"webhookNames,omitempty"` +} + // V1WorkflowRunListParams defines parameters for V1WorkflowRunList. type V1WorkflowRunListParams struct { // Offset The number to skip @@ -2505,6 +2700,9 @@ type V1TaskCancelJSONRequestBody = V1CancelTaskRequest // V1TaskReplayJSONRequestBody defines body for V1TaskReplay for application/json ContentType. type V1TaskReplayJSONRequestBody = V1ReplayTaskRequest +// V1WebhookCreateJSONRequestBody defines body for V1WebhookCreate for application/json ContentType. +type V1WebhookCreateJSONRequestBody = V1CreateWebhookRequest + // V1WorkflowRunCreateJSONRequestBody defines body for V1WorkflowRunCreate for application/json ContentType. type V1WorkflowRunCreateJSONRequestBody = V1TriggerWorkflowRunRequest @@ -2583,6 +2781,94 @@ type WorkflowUpdateJSONRequestBody = WorkflowUpdateRequest // WorkflowRunCreateJSONRequestBody defines body for WorkflowRunCreate for application/json ContentType. type WorkflowRunCreateJSONRequestBody = TriggerWorkflowRunRequest +// AsV1CreateWebhookRequestBasicAuth returns the union data inside the V1CreateWebhookRequest as a V1CreateWebhookRequestBasicAuth +func (t V1CreateWebhookRequest) AsV1CreateWebhookRequestBasicAuth() (V1CreateWebhookRequestBasicAuth, error) { + var body V1CreateWebhookRequestBasicAuth + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1CreateWebhookRequestBasicAuth overwrites any union data inside the V1CreateWebhookRequest as the provided V1CreateWebhookRequestBasicAuth +func (t *V1CreateWebhookRequest) FromV1CreateWebhookRequestBasicAuth(v V1CreateWebhookRequestBasicAuth) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1CreateWebhookRequestBasicAuth performs a merge with any union data inside the V1CreateWebhookRequest, using the provided V1CreateWebhookRequestBasicAuth +func (t *V1CreateWebhookRequest) MergeV1CreateWebhookRequestBasicAuth(v V1CreateWebhookRequestBasicAuth) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +// AsV1CreateWebhookRequestAPIKey returns the union data inside the V1CreateWebhookRequest as a V1CreateWebhookRequestAPIKey +func (t V1CreateWebhookRequest) AsV1CreateWebhookRequestAPIKey() (V1CreateWebhookRequestAPIKey, error) { + var body V1CreateWebhookRequestAPIKey + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1CreateWebhookRequestAPIKey overwrites any union data inside the V1CreateWebhookRequest as the provided V1CreateWebhookRequestAPIKey +func (t *V1CreateWebhookRequest) FromV1CreateWebhookRequestAPIKey(v V1CreateWebhookRequestAPIKey) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1CreateWebhookRequestAPIKey performs a merge with any union data inside the V1CreateWebhookRequest, using the provided V1CreateWebhookRequestAPIKey +func (t *V1CreateWebhookRequest) MergeV1CreateWebhookRequestAPIKey(v V1CreateWebhookRequestAPIKey) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +// AsV1CreateWebhookRequestHMAC returns the union data inside the V1CreateWebhookRequest as a V1CreateWebhookRequestHMAC +func (t V1CreateWebhookRequest) AsV1CreateWebhookRequestHMAC() (V1CreateWebhookRequestHMAC, error) { + var body V1CreateWebhookRequestHMAC + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1CreateWebhookRequestHMAC overwrites any union data inside the V1CreateWebhookRequest as the provided V1CreateWebhookRequestHMAC +func (t *V1CreateWebhookRequest) FromV1CreateWebhookRequestHMAC(v V1CreateWebhookRequestHMAC) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1CreateWebhookRequestHMAC performs a merge with any union data inside the V1CreateWebhookRequest, using the provided V1CreateWebhookRequestHMAC +func (t *V1CreateWebhookRequest) MergeV1CreateWebhookRequestHMAC(v V1CreateWebhookRequestHMAC) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +func (t V1CreateWebhookRequest) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *V1CreateWebhookRequest) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Get liveness @@ -2675,6 +2961,21 @@ type ServerInterface interface { // Replay tasks // (POST /api/v1/stable/tenants/{tenant}/tasks/replay) V1TaskReplay(ctx echo.Context, tenant openapi_types.UUID) error + // List webhooks + // (GET /api/v1/stable/tenants/{tenant}/webhooks) + V1WebhookList(ctx echo.Context, tenant openapi_types.UUID, params V1WebhookListParams) error + // Create a webhook + // (POST /api/v1/stable/tenants/{tenant}/webhooks) + V1WebhookCreate(ctx echo.Context, tenant openapi_types.UUID) error + + // (DELETE /api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook}) + V1WebhookDelete(ctx echo.Context, tenant openapi_types.UUID, v1Webhook string) error + // Get a webhook + // (GET /api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook}) + V1WebhookGet(ctx echo.Context, tenant openapi_types.UUID, v1Webhook string) error + // Post a webhook message + // (POST /api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook}) + V1WebhookReceive(ctx echo.Context, tenant openapi_types.UUID, v1Webhook string) error // List workflow runs // (GET /api/v1/stable/tenants/{tenant}/workflow-runs) V1WorkflowRunList(ctx echo.Context, tenant openapi_types.UUID, params V1WorkflowRunListParams) error @@ -3715,6 +4016,156 @@ func (w *ServerInterfaceWrapper) V1TaskReplay(ctx echo.Context) error { return err } +// V1WebhookList converts echo context to params. +func (w *ServerInterfaceWrapper) V1WebhookList(ctx echo.Context) error { + var err error + // ------------- Path parameter "tenant" ------------- + var tenant openapi_types.UUID + + err = runtime.BindStyledParameterWithLocation("simple", false, "tenant", runtime.ParamLocationPath, ctx.Param("tenant"), &tenant) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tenant: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params V1WebhookListParams + // ------------- Optional query parameter "offset" ------------- + + err = runtime.BindQueryParameter("form", true, false, "offset", ctx.QueryParams(), ¶ms.Offset) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter offset: %s", err)) + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", ctx.QueryParams(), ¶ms.Limit) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter limit: %s", err)) + } + + // ------------- Optional query parameter "sourceNames" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sourceNames", ctx.QueryParams(), ¶ms.SourceNames) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter sourceNames: %s", err)) + } + + // ------------- Optional query parameter "webhookNames" ------------- + + err = runtime.BindQueryParameter("form", true, false, "webhookNames", ctx.QueryParams(), ¶ms.WebhookNames) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter webhookNames: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.V1WebhookList(ctx, tenant, params) + return err +} + +// V1WebhookCreate converts echo context to params. +func (w *ServerInterfaceWrapper) V1WebhookCreate(ctx echo.Context) error { + var err error + // ------------- Path parameter "tenant" ------------- + var tenant openapi_types.UUID + + err = runtime.BindStyledParameterWithLocation("simple", false, "tenant", runtime.ParamLocationPath, ctx.Param("tenant"), &tenant) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tenant: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.V1WebhookCreate(ctx, tenant) + return err +} + +// V1WebhookDelete converts echo context to params. +func (w *ServerInterfaceWrapper) V1WebhookDelete(ctx echo.Context) error { + var err error + // ------------- Path parameter "tenant" ------------- + var tenant openapi_types.UUID + + err = runtime.BindStyledParameterWithLocation("simple", false, "tenant", runtime.ParamLocationPath, ctx.Param("tenant"), &tenant) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tenant: %s", err)) + } + + // ------------- Path parameter "v1-webhook" ------------- + var v1Webhook string + + err = runtime.BindStyledParameterWithLocation("simple", false, "v1-webhook", runtime.ParamLocationPath, ctx.Param("v1-webhook"), &v1Webhook) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter v1-webhook: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.V1WebhookDelete(ctx, tenant, v1Webhook) + return err +} + +// V1WebhookGet converts echo context to params. +func (w *ServerInterfaceWrapper) V1WebhookGet(ctx echo.Context) error { + var err error + // ------------- Path parameter "tenant" ------------- + var tenant openapi_types.UUID + + err = runtime.BindStyledParameterWithLocation("simple", false, "tenant", runtime.ParamLocationPath, ctx.Param("tenant"), &tenant) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tenant: %s", err)) + } + + // ------------- Path parameter "v1-webhook" ------------- + var v1Webhook string + + err = runtime.BindStyledParameterWithLocation("simple", false, "v1-webhook", runtime.ParamLocationPath, ctx.Param("v1-webhook"), &v1Webhook) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter v1-webhook: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{}) + + ctx.Set(CookieAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.V1WebhookGet(ctx, tenant, v1Webhook) + return err +} + +// V1WebhookReceive converts echo context to params. +func (w *ServerInterfaceWrapper) V1WebhookReceive(ctx echo.Context) error { + var err error + // ------------- Path parameter "tenant" ------------- + var tenant openapi_types.UUID + + err = runtime.BindStyledParameterWithLocation("simple", false, "tenant", runtime.ParamLocationPath, ctx.Param("tenant"), &tenant) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tenant: %s", err)) + } + + // ------------- Path parameter "v1-webhook" ------------- + var v1Webhook string + + err = runtime.BindStyledParameterWithLocation("simple", false, "v1-webhook", runtime.ParamLocationPath, ctx.Param("v1-webhook"), &v1Webhook) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter v1-webhook: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.V1WebhookReceive(ctx, tenant, v1Webhook) + return err +} + // V1WorkflowRunList converts echo context to params. func (w *ServerInterfaceWrapper) V1WorkflowRunList(ctx echo.Context) error { var err error @@ -6211,6 +6662,11 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/api/v1/stable/tenants/:tenant/task-point-metrics", wrapper.V1TaskGetPointMetrics) router.POST(baseURL+"/api/v1/stable/tenants/:tenant/tasks/cancel", wrapper.V1TaskCancel) router.POST(baseURL+"/api/v1/stable/tenants/:tenant/tasks/replay", wrapper.V1TaskReplay) + router.GET(baseURL+"/api/v1/stable/tenants/:tenant/webhooks", wrapper.V1WebhookList) + router.POST(baseURL+"/api/v1/stable/tenants/:tenant/webhooks", wrapper.V1WebhookCreate) + router.DELETE(baseURL+"/api/v1/stable/tenants/:tenant/webhooks/:v1-webhook", wrapper.V1WebhookDelete) + router.GET(baseURL+"/api/v1/stable/tenants/:tenant/webhooks/:v1-webhook", wrapper.V1WebhookGet) + router.POST(baseURL+"/api/v1/stable/tenants/:tenant/webhooks/:v1-webhook", wrapper.V1WebhookReceive) router.GET(baseURL+"/api/v1/stable/tenants/:tenant/workflow-runs", wrapper.V1WorkflowRunList) router.GET(baseURL+"/api/v1/stable/tenants/:tenant/workflow-runs/display-names", wrapper.V1WorkflowRunDisplayNamesList) router.POST(baseURL+"/api/v1/stable/tenants/:tenant/workflow-runs/trigger", wrapper.V1WorkflowRunCreate) @@ -7453,6 +7909,206 @@ func (response V1TaskReplay501JSONResponse) VisitV1TaskReplayResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type V1WebhookListRequestObject struct { + Tenant openapi_types.UUID `json:"tenant"` + Params V1WebhookListParams +} + +type V1WebhookListResponseObject interface { + VisitV1WebhookListResponse(w http.ResponseWriter) error +} + +type V1WebhookList200JSONResponse V1WebhookList + +func (response V1WebhookList200JSONResponse) VisitV1WebhookListResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookList400JSONResponse APIErrors + +func (response V1WebhookList400JSONResponse) VisitV1WebhookListResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookList403JSONResponse APIErrors + +func (response V1WebhookList403JSONResponse) VisitV1WebhookListResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookCreateRequestObject struct { + Tenant openapi_types.UUID `json:"tenant"` + Body *V1WebhookCreateJSONRequestBody +} + +type V1WebhookCreateResponseObject interface { + VisitV1WebhookCreateResponse(w http.ResponseWriter) error +} + +type V1WebhookCreate200JSONResponse V1Webhook + +func (response V1WebhookCreate200JSONResponse) VisitV1WebhookCreateResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookCreate400JSONResponse APIErrors + +func (response V1WebhookCreate400JSONResponse) VisitV1WebhookCreateResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookCreate403JSONResponse APIErrors + +func (response V1WebhookCreate403JSONResponse) VisitV1WebhookCreateResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookCreate404JSONResponse APIErrors + +func (response V1WebhookCreate404JSONResponse) VisitV1WebhookCreateResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookDeleteRequestObject struct { + Tenant openapi_types.UUID `json:"tenant"` + V1Webhook string `json:"v1-webhook"` +} + +type V1WebhookDeleteResponseObject interface { + VisitV1WebhookDeleteResponse(w http.ResponseWriter) error +} + +type V1WebhookDelete200JSONResponse V1Webhook + +func (response V1WebhookDelete200JSONResponse) VisitV1WebhookDeleteResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookDelete400JSONResponse APIErrors + +func (response V1WebhookDelete400JSONResponse) VisitV1WebhookDeleteResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookDelete403JSONResponse APIErrors + +func (response V1WebhookDelete403JSONResponse) VisitV1WebhookDeleteResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookDelete404JSONResponse APIErrors + +func (response V1WebhookDelete404JSONResponse) VisitV1WebhookDeleteResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookGetRequestObject struct { + Tenant openapi_types.UUID `json:"tenant"` + V1Webhook string `json:"v1-webhook"` +} + +type V1WebhookGetResponseObject interface { + VisitV1WebhookGetResponse(w http.ResponseWriter) error +} + +type V1WebhookGet200JSONResponse V1Webhook + +func (response V1WebhookGet200JSONResponse) VisitV1WebhookGetResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookGet400JSONResponse APIErrors + +func (response V1WebhookGet400JSONResponse) VisitV1WebhookGetResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookGet403JSONResponse APIErrors + +func (response V1WebhookGet403JSONResponse) VisitV1WebhookGetResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookReceiveRequestObject struct { + Tenant openapi_types.UUID `json:"tenant"` + V1Webhook string `json:"v1-webhook"` +} + +type V1WebhookReceiveResponseObject interface { + VisitV1WebhookReceiveResponse(w http.ResponseWriter) error +} + +type V1WebhookReceive200JSONResponse struct { + Message *string `json:"message,omitempty"` +} + +func (response V1WebhookReceive200JSONResponse) VisitV1WebhookReceiveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookReceive400JSONResponse APIErrors + +func (response V1WebhookReceive400JSONResponse) VisitV1WebhookReceiveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type V1WebhookReceive403JSONResponse APIErrors + +func (response V1WebhookReceive403JSONResponse) VisitV1WebhookReceiveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type V1WorkflowRunListRequestObject struct { Tenant openapi_types.UUID `json:"tenant"` Params V1WorkflowRunListParams @@ -10986,6 +11642,16 @@ type StrictServerInterface interface { V1TaskReplay(ctx echo.Context, request V1TaskReplayRequestObject) (V1TaskReplayResponseObject, error) + V1WebhookList(ctx echo.Context, request V1WebhookListRequestObject) (V1WebhookListResponseObject, error) + + V1WebhookCreate(ctx echo.Context, request V1WebhookCreateRequestObject) (V1WebhookCreateResponseObject, error) + + V1WebhookDelete(ctx echo.Context, request V1WebhookDeleteRequestObject) (V1WebhookDeleteResponseObject, error) + + V1WebhookGet(ctx echo.Context, request V1WebhookGetRequestObject) (V1WebhookGetResponseObject, error) + + V1WebhookReceive(ctx echo.Context, request V1WebhookReceiveRequestObject) (V1WebhookReceiveResponseObject, error) + V1WorkflowRunList(ctx echo.Context, request V1WorkflowRunListRequestObject) (V1WorkflowRunListResponseObject, error) V1WorkflowRunDisplayNamesList(ctx echo.Context, request V1WorkflowRunDisplayNamesListRequestObject) (V1WorkflowRunDisplayNamesListResponseObject, error) @@ -11870,6 +12536,126 @@ func (sh *strictHandler) V1TaskReplay(ctx echo.Context, tenant openapi_types.UUI return nil } +// V1WebhookList operation +func (sh *strictHandler) V1WebhookList(ctx echo.Context, tenant openapi_types.UUID, params V1WebhookListParams) error { + var request V1WebhookListRequestObject + + request.Tenant = tenant + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.V1WebhookList(ctx, request.(V1WebhookListRequestObject)) + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(V1WebhookListResponseObject); ok { + return validResponse.VisitV1WebhookListResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + +// V1WebhookCreate operation +func (sh *strictHandler) V1WebhookCreate(ctx echo.Context, tenant openapi_types.UUID) error { + var request V1WebhookCreateRequestObject + + request.Tenant = tenant + + var body V1WebhookCreateJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.V1WebhookCreate(ctx, request.(V1WebhookCreateRequestObject)) + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(V1WebhookCreateResponseObject); ok { + return validResponse.VisitV1WebhookCreateResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + +// V1WebhookDelete operation +func (sh *strictHandler) V1WebhookDelete(ctx echo.Context, tenant openapi_types.UUID, v1Webhook string) error { + var request V1WebhookDeleteRequestObject + + request.Tenant = tenant + request.V1Webhook = v1Webhook + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.V1WebhookDelete(ctx, request.(V1WebhookDeleteRequestObject)) + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(V1WebhookDeleteResponseObject); ok { + return validResponse.VisitV1WebhookDeleteResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + +// V1WebhookGet operation +func (sh *strictHandler) V1WebhookGet(ctx echo.Context, tenant openapi_types.UUID, v1Webhook string) error { + var request V1WebhookGetRequestObject + + request.Tenant = tenant + request.V1Webhook = v1Webhook + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.V1WebhookGet(ctx, request.(V1WebhookGetRequestObject)) + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(V1WebhookGetResponseObject); ok { + return validResponse.VisitV1WebhookGetResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + +// V1WebhookReceive operation +func (sh *strictHandler) V1WebhookReceive(ctx echo.Context, tenant openapi_types.UUID, v1Webhook string) error { + var request V1WebhookReceiveRequestObject + + request.Tenant = tenant + request.V1Webhook = v1Webhook + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.V1WebhookReceive(ctx, request.(V1WebhookReceiveRequestObject)) + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(V1WebhookReceiveResponseObject); ok { + return validResponse.VisitV1WebhookReceiveResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + // V1WorkflowRunList operation func (sh *strictHandler) V1WorkflowRunList(ctx echo.Context, tenant openapi_types.UUID, params V1WorkflowRunListParams) error { var request V1WorkflowRunListRequestObject @@ -13992,280 +14778,294 @@ func (sh *strictHandler) WorkflowVersionGet(ctx echo.Context, workflow openapi_t // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "AgA=", + "H4sIAAAAAAAC/+x9+2/bOrLwvyL4+4C7CzjPtueeG+D+kCZu622aZO2k/faeG2RpibF5IktekUrqLfq/", + "f+BTlERKlF+xGwGLPanFx3A4Lw6HMz86fjydxRGMCO6c/OhgfwKngP15et3vJUmc0L9nSTyDCUGQffHj", + "ANL/BhD7CZoRFEedkw7w/BSTeOp9AsSfQOJB2ttjjbsd+B1MZyHsnBy9PTzsdh7iZApI56STooj89rbT", + "7ZD5DHZOOigicAyTzs9ufvjybNq/vYc48cgEYT6nPl3nNGv4BAVMU4gxGMNsVkwSFI3ZpLGP70MUPZqm", + "pL97JPbIBHpB7KdTGBFgAKDroQcPEQ9+R5jgHDhjRCbpaN+PpwcTjqe9AD7Jv00QPSAYBmVoKAzsk0cm", + "gGiTewh7AOPYR4DAwHtGZMLgAbNZiHwwCnPb0YnA1ICIn91OAv+VogQGnZM/clPfqcbx6E/oEwqjpBVc", + "JhaofkcETtkf/zeBD52Tzv85yGjvQBDegaK6n2oakCRgXgJJjGuB5gskoAwLCMP4+WwCojG8Bhg/x4kB", + "sc8TSCYw8eLEi2LipRgm2PNB5PmsI918lHgz2V/DJUlSqMAZxXEIQUTh4dMmEBB4AyMQkSaTsm5eBJ89", + "wvpi5xn70RMifOGOkyHWw4vZV/4zo3aEPRRhAiIfOs8+ROMonTWYHKNx5KWzjJUaTZmSiQNpUbI4pU1/", + "djuzGJNJPHbsdS1a047zMI5OZ7O+hSuv6XfKbl7/nK0mxZD1oVxPqYh4OJ3N4oTkGPHo+M3bd7/95+97", + "9I/C/9Hf/+vw6NjIqDb6PxU4yfMAW5eJKijoAi4YeHRQ7MUPHsUsjAjymaDTIf6jMwIY+Z1uZxzH4xBS", + "XlQ8XhJjJWa2gd2nGiABUuwXpElEBVgF1wrKUUNQaSg6eXHEJLdGV2VCYuLQiBv6hSKED5HBWJbuteJU", + "yFy5mAoZdp0RaUGUzdCnGBMLBcaYfIrH3ul135vQVjqME0Jm+OTgQND/vvhCidOkfsAMfYbz+nke4Tw3", + "zWzyeJ+RLhj5AXxwJt8BxHGa+NAsxrlMDE4tqydoCjWlmIixvGeAhTjNSe3O8eHx8d7R8d7Rm5ujdyeH", + "v528/X3/999/f/Pu973DdyeHhx3NXAkAgXt0AhOqkEUgoIDTjQZM10ORd3vLBQQdWgdoNDo+evv74X/u", + "Hb/9De69fQPe7YHjd8He26P//O0oOPIfHv6Lzj8F3y9gNKZM/uY3AzjpLFgUTSHAxBP914GrAj8gOkm2", + "qzroFt64iR+hSTx8n6EEYtOSv00gZ39KrIR290TrfecNnkICAsBJskZn5CjYKlduCnJFwbaf39/jd+/q", + "cKhg6yrxopBhRKLvwxnhNsIA/iuFXJjk8ckNAo7Z5ahziiI7sXY73/diMEN79LAwhtEe/E4SsEfAmEHx", + "BEJE96VzolbcTVMUdH6WCInDa1rv+zR85DZY7wlGxLpk+CTPQk72qmHIWsuVz3D3s9s5o3oodACoH+RB", + "arwd2YErZdzWZHucFkQhZEuKIz9NEhj58ws0RWRIEkDgeM61dzqlHc5OL896F/f9y/vrwdXHQW847HQ7", + "54Or6/vL3rfe8KbT7fz9tnfby/75cXB1e30/uLq9PL8fXL3vX2p7nEHJN0OKBztGOWP0IzNDBmmSHeqe", + "J8ifMN7kMgNhj5HjfmdxIo6niEQo7MqJGELNAuKUiwduEy8lH9j4JsYoIg3P4gjDMtaIFLlljOXAqgaD", + "j2KH4yyJo29x8vgQxs83CRqPYWLdRxAEiEIBwi+aYC4N7Cdx1Ps+SyDGwqYsEQ5tcik2oKzWo1lKjCPP", + "EhQniDDaVgyGIvLmmG8PmlJ6f8PYi/99VHZ0lEQYna1rWpwGZ2lVdwqD1dLEjLMC0ak2ntQqigIZr2vb", + "nCHDPBZjKLcBHk1mJu3/COfW7tk26ZtRHkN+lZpWjVPat7IjCvvxzKK82ScGHBvQe0AhgRSiek7gBjPD", + "WrZ5w8uhdv6x7iKJZ8g/TWzsOAX/jiNPmiAepRjvL6eDy7/K1Q8vhx4bYxkxpnTxFEX/fdSdgu//ffzu", + "t7JSVsDauZ67RU5DmJDeFKDwYxKnM7v8pk2wSViGCBO6Rt5CHr4T3HE+mS6w/AA9wS6bsbx2AWrdymvM", + "MD64ca/ZJ7mtdK0eiYUfZyV7K9fV7SRxCOusIb6aL3A6gsmAtjfioyMGq8OKHR/RGEXwK0ykQK+HSTZ2", + "NsW5t20VOGRIwGE6toiQMB2vftKu8CgzbUEBSFEjfN32FcbMzgu2IPMOZhocuyqg7NdrrXXO25dX6EZO", + "1rxDZc+OUuON5lriyDeFZBIH9QcIDV1feBeNSCvV3MI2R7fDKa0fGOd4FvDUfLZaTLKBICHjMPbjqwLN", + "NFBh9hysgjIyOlB7UEunF8gkZ2ZgjCLliazaxWvVUhnQTGQ+NzlJ6nzj5DE10Y52zDrvfTi9vaDHp9Pr", + "vuXApA1wlQQweT//IO+b5DCRNDhhySeTjcSszk2am0tai0vwNVF3OPVitMhqZXD753nhX7y7Ezd71oVI", + "+h+k0TCdTkEyr4OMbdW3crcKluS2qlrIndzwc2DyzzY5CXh/+dvw6tIbzQnEf603mpW5zKb/vBwNyDG2", + "gPnVcsp8LwHdFigrQBQS5Bwl0JcgSSkCsN/hd/p2+WGTQA6iZwhB4k+M2shG7+V7BeaNM14vMeswpWYt", + "5VbV0EvSCBdPkZZwhgeAHIbmrZqMO4NRQFdaM7Bo1mTkf6UwrYeYt2oybpJGkQPEolmTkXHq+xAG9UCr", + "hu6jKyrHVU5jwwmNfdvXj6AL8NgSGssu1jVP9N/ikUGQV0XgMHmuxeAILfZnPNpf091JaUxM4Mxdeg0J", + "nJkQW2kKEzSFcUrMyxcf65b+tKwZ/KSZv/L4xZZusmv/Fo8GaVQh3fjtmNuNl+qkQsHsTQYQYMvB7AFF", + "CE+aTf0np8iqHaVEy1tadm8JoksgTkOz2xcTkJBmi8EEkBQ7rIfqJ95W0PcgjZqRON385lTuP8KkmgWa", + "LFczSutA1hRzoefyx0Y+iCQQtQt2rhmqbZKmx3Xv8rx/+bHT7QxuLy/5X8Pbs7Ne77x33ul2Ppz2L9gf", + "/E6L//3+9Ozz1YcPRmuFmnHmSBfX+LhiV8Nmi0nYjQ62X+ls1HhUt/ZG+5FCnHd+4xeGNw9N7SWoBpuY", + "yERmbJkh8B+/wdEkjh9ffJEaLKtaYjy+QBFsFLZDlSn7TA0JKlmkSg3jsReiCDaJ0eCxvcY56HCiQa2R", + "YuvNWxh8EgVs6fEsWcCxmuEuQ9UFfIJh3nHz/pYKmv7lh6tOt/PtdHDZ6XZ6g8HVwCxTtHHU4clp/3MQ", + "mASJ+P7yZ09JVmbpwT8ucf7Mj9DwBCo6V5xBDQjQozh+dHjMBLmfMdo97nYi+F3+6023E6VT9g/cOTk6", + "ZF7gHGflOpuCvUQLb8apUE187HSs0mAxRkbC7+WR37iNnK3LGKMWExDqh1jalHl2QoQJvxnJXhYcupzi", + "DBLr7/QE+wWSBPkGeRyl02u3IzajY3nQ3ret9+9Op2o+FuIha+yIbR1w4Hac5iOKQ/V+pzYQIQM1N0tX", + "R4hJ/g8AgSzyp4xKJ59tQsV/SAcwiugQYDKADyi0XIiy0EUR26gPxuIaE9YRsuidNQSAsom+gjC1qB9x", + "PaP7OPgVJ/ZYzLxw+Ypdf0ZRED+bt30VPuUaRD/Z1yGliWEdUxBA10Xwb+Yp+De2DLqXKNIisTI08+ju", + "hzjxYeAacaGdE7T9kutVUOUo7U6n6y1QhhmPGdWh+ryEQiyOUVKJHJsSaxoqjaNBH0ZkqJ1nC/dEDDwb", + "PfOvninqTndANDmhLuKRWMKbsDaXgUBp5jMoHaCLkZ/VPKI2oqufrQUsxdGN4h/Sv15PXPEAzkIw/6VC", + "ePmSNMcMtq4sRw8vuz6t+bvDw5r1FuC2rdrmONG6uwvtgqfLFT4JXUK5nDF7BVuZI1WNIaZ01IKPwzDg", + "GGJym1hsrdvBhUdiD8MoYCGF4piLPRKv59LdpiDSCP2LWgMBjAh6QDBR1qQwgMQ7Fx75qD8PG8EwjsYS", + "4hpZ2V1n4KWba7MymHLoT2CQhlCjtGWDp9cc/NztEB7k7a4Zm8RLZ4PfaegJVufpZc8U6B/Ds0+981v6", + "o8n8UTOvNzBuS0PcyqvP4tw2Ec7WmMRWFwE3SKMz3e3Z+PqEA7BpXaoB4LLEoZOp+q3U4SVDBTOiqIwS", + "LNPuFhz/DOLEKV7QyoiNggbLo9iOiDqOqz2oQzgFs0mcwGEYkxWfD3NnL/MlPneI4DDmbiLRw/3SYcGz", + "mrjftS2LfvaSVC6s3jjRL2rrF4rCUEYwuK+0JJoMrhvRxB30AoNnaOnq59HC2ZNSjX57Vb5vmoAogqEN", + "TPHZQ4HZPYbp4N4zH93seOAjXFrfE8gp2LuCBSdZymYGU9vq6bcllk6729fNBl9m0Vth7bvZ4xIRCt15", + "uuhqZGjULwTObOLOHG4zQWGQwHzEQM1hf00hMjOQlN5K10KSQBCAUQhtmyu/q6wJXA7WkslSkVuWGewU", + "oK0iRw4y0kRsIL86q9j6NURqnZLeLM5dQ2p28oriuRgRfrM5QWppINcdn8VpRMzgQiuUi/hvsz4VGCoe", + "eHMBaQ7xTCL8TrVfPdvFKbGBuCBHsvvF0wcCE3dkrjw+jnep2JkljCzX0FDa1iZOHGRNkxWrLhUrphaP", + "JSzPSTkpClQrq4yBE6g7TfwJeoI7KZean7W3SsTE9CBl7lTB9QkkybxCiq6NH7XTy2ZYouKgoCFB4tF8", + "6LTR+zac6/MMaLzbFW0s7+18OxXYXbyBuYMWSWcgOcmDDusRl2OsB6Ub+ASly8+191D2caK7DyjBZAi5", + "kexOexegaa+G0cr8lJEDsDCzwqyGJj18kO9vBTFvy1OxHJnWEnIm0qXraNDjrvX7y6v7b1eDz71Bp5v9", + "ODi96d1f9L/0bzLXe//y4/1N/0vv/P7qlrmvhsP+x0vunL85Hdywv07PPl9efbvonX/kPv3+ZX/4Ke/e", + "H/RuBv/g7n/d00+Hvrq9uR/0Pgx6os+gp02izz28uKItL3qnQzVmv3d+//4f97dDthS6pg8XV9/uB7eX", + "9zy70efeP+71CwdLEwGo0Ytm4hgNqVo8qVjgoH/TPzu9qBqt6qZE/HXP0fCld1lAfIObFPE3b10VQJ+l", + "UC0md4WJSD3RsyQI+SaTRMYeay39BVPWC+8bM0KCCIRzgnx8NSNXKakYNXNATAD24hmBgScOmWoQ8xxr", + "TyxnSyyxdGaKpTJLqJdNDXN41Ka+Y2vKRjfJS2PKmc3mmlnToz57yhnjmrdAWZj3wpSaZxzvcYLvDNit", + "x8/8qlA0HkJC/4M3JyB4tone9xmiu8zeuDBgqsfnvfg02Htm2SnZcx0PJNADs1kSA3+CojFPU8kQXDW/", + "TJnDiYRF7i0IBV+yzAdahoeF+lXiQvMMfQAoTBPoAAqLItEB0e8RMHsYbZ4zBJgv1X7HkwUFg0jsLLvn", + "KeYAqw7/A98lkX1gPpPIn1vjfL0H2cQDRMauCqparZ/fLgmMANvlQl8F5a0n+9RPlZK08n5KJqQVycg3", + "maR1sRRXddcVgqFsly3ysx1rvEXVdQsbIZcp0qqvaxSHzM2V7ZWe96OGdrZGlQhSbqZB+J6W4X8xgnJP", + "MUNZr671LYYJ73GdjkLkV5ECG68iS5sO89Zsuti/RTZ9IPZJnnCuvl2yU9rp+Zf+Zafb+dL78r43qDiO", + "VD8hYv51bA/MMnlfSjhnb6HqMJGDQ3NQVM3dZLxiYKlCgKR8HYvq3M7/uKen4k630/vKz4n6+Zaen0+H", + "n8WfZ4OrSy2mrgLvOXvHZPKBZFrxIId999gbBrNw5k+HSOw9g4SluCgZQry3+YFLs7dK5mdKq3l5xMe2", + "L9EM/3LpExQ91LOuoh63d0d1G9b8udEUEpjIR0dSh/KxvL+gfbjvHXkBmHe9I+8Zwkf632kckclfFwwb", + "UOgxPkKyi1yJqOs4RL4hhRG3zauOqyrPP29qMBgaiNw8+9UFtQvg7KsTHidXYWoVRpmLQZNGXw873c7X", + "owph0rQTj27bQLC1NX7/ltVJeI3Zc/WV1zw2WkniWqsppANi3/8d9k227o2XdW+s0e2wlpIFDVzPL+I5", + "tnDwNxZrYX9aha9Bik3v9nUW4wEbHsLejLX2QBR4PoiimHiAFVxhldxkzrniZhuhw6Yzaa1PBgRBAjHW", + "fTM5a1Ie9ssuGvrhE8ATk4aYADzRh/wPXJhO6AxukPFCaENeU8w7mwBinfArTNADqkMv8zBR+fUkmoti", + "fDkYzFw0Adhe8s84B1A1/jwMyQbvbQKEZyGY55hI7l9jZ04eu3cWAsvXRLQyQQSf7UhkfA+fM6xJy9IM", + "+wKmgqq5+JOFulUBooCoxN9yMJSyI6mKkDqebCi/iMcoWjzj/2L8vVQBgK3DuFzjrA7XAzhGmFRI921E", + "t5t2tQiGLdwtWZXMddN0kxxP0AzvqqOx5HjdoDZfh5bhk5m27evRWe/iHI7S8arrD3WFLYvRNA0BgThL", + "Fc9ujPw4DQNvBNmVHrc+QCQyjMeJB3LWtimdPMwViCqj66x34WVt2NniCYQppX5jGGhIYHIN5mEMLBzI", + "m3gz3qa8PiA/UevDiyP6QwKfUJziPRHWKMboVL0ELk/MPpXnI6WXW+JhdbUvQsObnLWOMmxJFVQkroEL", + "9LLdHpIlnNgGsEptPLe2YSeysFnTUxOchuoNSGGHs9FZ9WyWMBrjhzQ0GoJusellLMgw9VJgqzVI2zqG", + "5Qkh/ZZboloXK5TB3VwsOotV4bMnGfx6xKsV3gD8WFFaj8AkAqFIBmL1MIlmXv8cS1L0QeQl8EEc3BE3", + "yAF+pPybI0y9s+6aWml+FMnD9VtK8fGBtzUe4CTeQhjQpgaPJgqwzWvP0cXQoJaNAsyF3jNMYJaLfm2o", + "+MkXwWQOX2hVZcVKKarxlzwdFGWYbsFUiE8pHG3DaK9R6qq3Ge8R+Hj73i09xdNJcDrCPLSIojxgho9o", + "hT1AdGnkllSg4uEs27Ol9tCSToa/AGEIyak8i6BhWy7e12p7Hkfw6qFz8ketsDP0fw8w8kUJ9kX6n173", + "eZmMRTp/+nJ61vl5Z12cGJw5bMNllggZgAXLx6FE/dcjMRSHRJaqp11v5jYqpjtXrtROpWiKs4AQ8dxW", + "E/qn1/37z71/GIR9MSeYnF7U2S9Tix2lDBnmhFif4bzX2OrSl8TNu0c43/duWMQQ9pjTjcSizne+lfeQ", + "xFMdF1KI7DeznHWLOcNqOa6WuXtk1I7Trg+zLraqa1mLrgmL7oycMeIWkLsuFdZE7e9Ph/2z9dI6Ey9b", + "gE0Kx3qRyVa6Mlyeg/GZ9qq9mMXB8N693iJTRa3Khl0Axq6ZEQ289IoKnTkZYHo9Xd2UH0EPRHPvb8Or", + "yz0MEwRC9G92GcdXtr+QqVYxmZT84lwdJ54PCBzHCfq3XoOnLKYhjKoypmACpjNxdai0CQ8e5vWuHd+X", + "bVXROJGYh2VCs5Ut0k5ZcjJ2U5kdPdQo3mhemNGRUxkz3WjAGCuX8O8oGgv5dtlENYuIYgVqBic714PZ", + "LEQ+JcwVVdcTi1qqvp5x3rtM/GyBJ1QKQstpsbyxJXA5o9ZTuGDo0jYycjTsYW2aJIcURhrxK0ZTUydp", + "tL+m85k9ra6drH6RMnZtsbmKh+6JSr7/L5mSP5td7YmWI0FIiw/Kh7Wwn6bKO7Ipe0MznKTZwYynMmy7", + "4QhahXKv2IIGfqa6sVcp5KrzNtpcUxlZ6CS9FRpQen1dUi9+PbLWRQKEwOnMYoKKj5o0KZZFMiRT2Uih", + "pVBWLapGUrHC0MvVZyomSjFdCJFk7rEcCy6Ybl7wqYCOJUo+ZSNtAydUFmf6esRTq7cXR00vjjje1nNv", + "lIix13xtREG3uUyayz26ILPMYzfEXyoy1rAjn/SE1W9MTzVfMEOOc76mSqsUkgRBXL98+uWcR3RYEzbT", + "Nk6OL56Thjk+mqXCkce5Zok1eRMOnD61vmcZrs2HI7VlWyEOM6Kv4wpJkGtOe9Mwz40cK5ffppjTxpwQ", + "p5jnZti7vLm/0Rej1nDPtVspKc/ZoHd6U8iu/7l/fW1JepMTpI5+WfcEHRhF/BFbk0TQsCmxZNkKi/On", + "EeGhYU3z1udBqOf4qodbHAl2zruOUUT4g63yDgiCMwrQLCuQ+Q0smsIFy0KIRoa0Q07LMGhbHi3UdGd1", + "1DgeFJidlEY2fPqVif6copB0kjNHHlVlGitA2BQj2dIM5J6DTZOLShJkGaXOrr5cX/RuSomkKvJj5a+G", + "Fksyr53O89o4m2bZuyBmtgkvYwn7K7Wa9Ms1uxkpW7GBsLt3v+YeruaYml2+KJw8Ayxu9hs8jQ7yZpFb", + "JKxhC7QR06xekmE48bU4VNdDkTdFYYgw9OMowG6GbF0wZGEW7y/q9TIgEBP621/rC+A5oZ8OL7u5478u", + "FLUC5YLqRWC1/HEGIzBD+5dxdJmGIRiF8G9Dli9AtdpD01mcsElFNHa58QzQc0xnjMgkHe378fRgAog/", + "gWQvgE/y7wMwQwdPRwcYJk8wOYgB09Hf9yIxVufkAYQYLvkSKJ0OZ+A5gsFZJTtqDmXevMyYVWljywPy", + "bw0paIf2hGfDZra28i443/fwzkp21hpQazjUORQuMXDomoqXFA3VLFW2pXBJWVEu615YbCNXOLuDx77y", + "hN6PMEyaqzwkujWNNnC9YMhXQ95kNcragCZxnJGOGHm8OYujBzQ25lDIX344Xwa7lLlagPgKz06cwcmV", + "wyrPJB4/GyZapqyJ7sDWraYud9HIByEGfaX0jFYqtsCuuosnzwr5eirc25O7FTJvwV3RoF+v66fay7oq", + "q7j0plQBLyCxH8hu0FTcc6/RzRrAGZlY7F76KWdMyBLggMDkAYSheciNGaJLV7pZjyXRUHDyAICGyKJa", + "hHd0R9drM2gMLvQVnBVbo+UXMloWixzTbYClyo5x4VtQsec5Rb2I0r0rqJCX1KOUmlj25kbqVKi+lWnT", + "jSX2yte/NQRZi682UjKXDNLt2ZoYWdG6/tF3oZRqloLs6xHPn9O+DFw4IMx8DWCta6k/6XB7TCY7/Ozu", + "wsurtRdDWN+jrdWE9tkrYzpF1Dk+FdPeBt3pBKc9PSyT3gx9tj1HOb3us73WSCX/rMiE7wkEAUzchBVv", + "WyRFMW0trrSZunIdd1WMd6qxWf4RWVc9nezaXkJp4+Qe2hV1qlOOFrrUER2FIdSYSA1TjW9DovxaO1AB", + "ZWrUmmwt+Ydn4ZiqrMlUx9vw0+lRp0v/c/zuN/7Hu6PjTrfz5fxdNfbUWzZDTkVtIvd3caoXS+fnx4E4", + "NDuP0JOdWHTAOAIkTeCnpemYDu2p8YyyCY0jVuDCT6DFFsfsG2NDKY9pL6cJio/3FKI0PJlXXAStlkZ6", + "Gt7Vk8Le/2OFf4Y9FhDP/7gdXFSTx1aE+0hN7Xi/X9YbGho+9i57AyZjPvZvPt2+Z2E8g/51z4aHzEhd", + "/aPEyovo5te30hPRXuG2V7i/1hVue8ta9o0u6WvZbl/hzriqGl6D1dw7GZxa4ipqKccWCnJerewok7+F", + "yl0KqQsn3RWhacNzSGSi7UIcX30N77xapVQyAfXnbf1tJm3/IU4M8Eif8JMs1F0Xvs8aZrkY8heKywck", + "c3Dw6tIr1N7Rlt82dnI4keiWkJW3Nm8O5Lc3qImCX0OdLX3KKmBfyrGqW0cNPKsWjK/Ky5q729dfQp1+", + "FNVgjCavsJ55uu6VFmtq5DwSqb7Nac0TSzJP2TdNwkbOHXEIp+OacJlDCU8qY0/hvKpFYrdjKJVbIk8s", + "1RJe/8GLYuLNkvgJBTDoesBLQBTEU9npGYWhN4LeGEYwkccEXdsdrw3jzdEcbCcBLrY3myZlBWctsqng", + "tOcN3ehpPy9+nE78uS5WxhSH4ntg2Td2qwCiIKsXlfChFjtSTyGZxEGj1QrQv/CeynY+iwML1X66ubmW", + "GUj9OFAUnAjku7+pvQf8US2bOTfxnSPCq0lIoLJGj0qal62dE90YKWBh2vmiti5zGt10up3rqyH7z+0N", + "s0JsGpK/Y8BVjxywuIfg1Sx8EHkzmFC62m9U3Bg8AcQOi/YsQ7kEHOVp4XfopwR6fhyJ2l/h3BKYhPCM", + "nVyNGWUo1SGVqwpgjMYRDLysE/Ps3N72zz3BPps/sYVgBENcXfiMtWEslbvC5WrAjRS5QKXjmLYsBJh8", + "giAhIwhI1dk7t1Wsjh3LvA28ieydP/UeHx4f7x0d7x29uTl6d3L428nb3/d///33N+9+3zt8d3J46J52", + "AHBmpuZBDxMwCpkzawshnYLvdsKfgu9omk5XxwDrtzvs9kYCfaiqt2FbbgXahoeG84pHcbIIAQ/ycxlo", + "OEkjuiX96CF244aB1oEVxI9tmgDDKZhN4gR6tJFgxAUXMpRjDdl8prenzom9s6lVctezm/7XHksoof68", + "Pr0dWl7GuYRjc2SpUGyumaw5ZoSu5BK1AGS9O4r3vq2zPm8HF4bhmxqjrL3RkNCEZUmPVuZllNlDaNdV", + "BzlUFMjkhTFrJq9OQ1eBh5e/a7Oa3QrIQZ75C9UxQTROxaWMs1gYnn/GXPHwzlrJsnLSB7NhJCRS7ztJ", + "gLEBDh7tw5YWxyDSzb+ri1P2JPb6HzefmIv/5h/XveHZoH99Y/ahZJysX9L3Lj58uhryF7VfTi9P+XP8", + "b733n66uPlsHkuWFC244nTbN4eLqF4dotG6DAmw8tZUswWYu3PVnPLIIVvrFBJATff4tHpkE+UZ0sxVz", + "slyPwTwC48XXqvx3wGj8u9bEd1mBuGNoJie06wyJzEq/pUEvVNW9/5a5ubllZirHO4ZE+87qshpu4CP5", + "tpznMxpDwsux+FlXb0z7Kl2nuWb3reWghyQBBI5rM5ZqEF7k+jW3YTMzNV/espjz8M1x/dFfTl1cTdeI", + "1aot6p+bUkopAPvnRhzK3p9RlDtsf7i9PLvpMzF7fjs4fX9BTavz04+VApIOIvVnIwpmsxvYS343K+Wl", + "nrpsWJ8z/eHmDBGtrSk6GJN8hlWvVlgVfRPFKh57hHNLXIccnpKl28MYec4BHp5BHz0gP5vE+8sMYAwD", + "7wkBES78VzNXWBHRIOjHXPyfJCk0jF93h6ZHz6iD89Hh4aE1GsY4TD5+pWEoSqMF/RmPpBhz1eOWZNRL", + "PyLjGnHTziU+tzg1vwwIuYCOVQZn6PfuxggNe/rz9/MGg99ovcohEw1NEmvQxTI5VLOB9HAKDey7amGy", + "JSc8LfDCXSkM0ugqCWDyfn6OEuiTQiH/0+EZVdO94Vmlns5G+YBgmNP7+uvojJZzUkyTjDWTDGVASSu7", + "W9ndyu6Xkt2WOX5B0V4RkbaAaGaj9Qmc2mPcLOeV+s7WCkBDlnGmOq/hkql7s6Q2K89Vs4IBLTK9mPmw", + "+ARYLKpbQqQ2ah31lBLyXfcuz3keviwjnyHZYj41n8ri9/707PPVhw+1WpJNu9C5OS9Q7MR4kxcnxZiM", + "OLrWJH8JVtpg6E9gkIYVWYctnZdWR9+Kz9EdBUzNZmNer9QaqZJ7Bb9GdqyqkYJrF2F1ErDElk3oSA51", + "xjvWWaGF5qX5M4Yw5vCsSpcqmc74UTCX8Zvk0eZJWKsWewPGJvSGtirOTV3+0YrfsAu3Loewin6EUDhL", + "6EHmwSwXjCzN+fIeWbixbkIWAG2ckcmRe3HluOppsXmFzS2DAt4MkheqsPdFBlb4Wa1xz80tM/oyC+xe", + "3EI0RzNPkGCVp6u82aoCQ7Nmiyybu8Jw2RD91oOlhXoAaUiuK5NZiEbWpBZOlwTZ1d0LXcjFScCj6hxA", + "xcI0uEFTGFuKAWCC/Me5LciDfvOwuPpwu+3TeLoBa2Htnq0655wLEM/avbCr/79xbj/n45Rclty83EB3", + "9RzDtn6VdyxNaGgr9mRTCOeBCdnlSqFiYAJZsNSZPQP6FHyvafHczGi2pUHnUfYplWP0ADDlEI4gSGAi", + "8xUwjDLxzH7ONmVCyIwdH+L4EUHZHNFd5T/JO+iTjniOmfUVqStYVAgSUS6G0GvezTu97rOaGoQ5i/K/", + "KsrqHO0f7h8ywuQvTDsnnTf7R/uH4rEoWxp7EBqiJyjutcvzfpT31rRVBDH2lKOC7iKQydU7F+L7R7Yu", + "GQ3OZjk+PCwP/AmCkEyY4H5n+n4ZEzVnbmc6J3/cdTtY5nunEGYNZWDEH2J8fwL9x84d7c/WmkAQzOsX", + "S5uhqtUOZINVLpcB55HYA74PZ8QjCXh4QH7t6hW0tct/OjoAIeW9aLwHpwCFe+zmEh/8YD/rv/3kMIaQ", + "GMz1c/Y79oBKeUO7e6w7vwwtYeyUtujRBuxun4/AaDEBU0iYcvujIqqkNIMn8pd2TvjjZ8VdpaV0dO7n", + "DmkuF5c+3f68K+392zK2hqnvQ4wf0jCcexylQS5fUAl5P7udt5xK/DgiotCSqIhKBz34E3Ptka2jRlv1", + "kiSm9sBPZtjlgyamIKRYgIHHUskE8i0EB+PNysEwQfEhTkYoCCA3dzP65nRSRWaS4kWZ5btu5/teInQz", + "+yCqNHcNhHHHzlnEN2Q/5fb9MiTOR/g1SJyXsY+57FwJMXDs8E0rIE49pimTSSW2SOylEud5bPw0i+iV", + "LMS4BBPsOTHAAW3FgKMY4NSyPjGgK8gZ2iPxI4yoVpR/M204i7HBaBjAp/gReiBiWctYaxEepGYsiIkZ", + "uqGtpAeBdneREmp4i0yQsG6VukvY8gSdM+h+baLGTahakA7d2Buxc5KMs9+qKFlteY6C/TBOgwP9KGu3", + "dkvpoeRxgg3ioQgTEPmwRMRn9LOMZ7AbwevHLQPESyP1LnFrCKzGaucI1i+IxdZ/0a50vu/JIfbiGY+u", + "EBpN22/ufz34wf77s2q/qZRirfZLG8rcsHwjayURzwFqM07Y140KodVttsinUqO8eW70JyHWODbYjrWy", + "LUfiGmYy8uYorpBqnH7u7BR+UCfW2LYoqVZD8+dKgL12uj9nJNzS/nbR/hQurMOt2ntzilukWWpCU0ol", + "7ogiX4UKp2McMIc23yVs3fELhOkBKPRyrW0bTFv38w3Xttt0LrHj2pQNN1+m5citbpsIQW0924jCJpT3", + "P7fJcYRITKX5wQ/O8T8PZkk8gvbDpbzI80B2V0xij/l1Gb7yT8btDK+mvo4xGaTRNZvX3TdlU3pKcm1Y", + "61UQlEivwOmJ4Xd/o1rhMiYsFXecoH/zdM0i0QpPBMGfBZbcnASgEAYe99t7bHu8D0Ke97NtNSuOHJnh", + "EPiPBz/Yfxy8+N6QNtTy5+cph30VGWvcnfa5Ma3Ew0DcSu98HifbZNocbQaM2ygjYT7xu81MzBMhsXxy", + "IAzjZzq96UagSLVS9LLfq0wsTnR5jonwwQ8cYSduuRzqUr/MLxFuwCb5weyMIjT31rFJARkto2who5QI", + "VrHK5bCSUSJsYBNpuGjeJrPpQueVR+ISizS+G3sx+6NrdwTwCiULeQI0GI7fvcsBcbQKG2iWxPQfMGh1", + "2Baxpu0QyZK2e2A2k9ReVmu8TYEfCRiF8CAAY3yg8j1bD42YnRpZO49MAPFGMIyjsf6MXeUWBuPykfLr", + "0TlgZeRuRGnUeneZLJuXZQTheYAZy/wrhck845kAjO9RUK3m1vUkwUnuFOB9qYOPM/WurLbtORirmsDG", + "JE0VcohOKW//2Kyv20vY7bzblPCjp1A0nYVwCiNSsg2Y80LSgbo6B/jRKGFYw4Mf9D8110s8vf1ozvmm", + "KEDoBI6udl5r2Kb0KaAbVvn5osoWoSDLMuuwlB7frNOPX0jk38j1xrD62vnzLT/7rH/WG72uLrUUHuKU", + "ZwXaEhGR8XNJRNjPDMRFhByE8bjOVgnjsReiCMpUOwKOokS5iMcXKOJFGLZcqqyX7XVENFDK4ulWe3eX", + "14yK+jTSv4jHy1M+/f+97L2c/YZHqxBjJX5VAGYXyL9bkVWLxB5+RDOLUo0fHjDM61T9iQ2rLFh+4Vo9", + "Hcs+543mlinZ54Yzrl+tZ3u9wCV9a3q3qj0n40wSZnk1z1pobkIfhgcBHKVju6Owx0tpQw8UC1SDMUAR", + "zgrLiMKEASBg3yAPz2B4zqbalWvN1UfVfz06610wJNQE0TNMYioKWaFCUq4OLpC/0Vh6HXyZa6xG1IlC", + "7ELU5dfQ2jX6bcAoHZdYTOP5s96FneWdeN3BruFOyLzoUeUUi/zczLbZxnuCX8m+6Zaz7UqH4iOcM1HC", + "E57ap6XtOkaHbm1sInseW++5PYsjjAKYSBJjju7YZ2kNAg88EJZ4AWFPZDgzQYkRD7UwIKciOVpTWEbw", + "IU5gLTBpRFC4AmA+8K0hcQ4akLDCObGPmAR9RmSi3wcU61Ia4MvegFt2ds2uevd15XJXe1NA/Ali1x8+", + "TAhAUfbUt2qdKgUVXICSSxVnnRentkSscjSn6g4lHr8yMUEsslS96LaM5h4wlCKPI/1cYnGllrNmGhdi", + "yGAup3mE8z1ePGMGUIK9vwSQCT7KfXMPeP88+edfi2Kr8iLW7eYI+/EMOslD3tJ1Xaz1cvCu94zqfj5t", + "PVB1HijFG46h4w0MtAOmhh2tNK7bnSy1z3C+K8ba2p9SSFw0ZQSG7pYZTMzgCetxlQzBJakLM4iWtZzA", + "FV97aNnWQ8tNLuta4KSma23cyilKhigz+fmc+8tncW5mkeB0hCHxfBAFiL2ol3S9UhulasXeLYYBYyMO", + "C6FGeBkeQKRnB1Fr0ZKPeqPmjcbaDcS6FDGtTM/LdImXTKBz/FZJ9K7Fg8zrdnvAi+CzGNgqmnnb1+0i", + "Zijg6HBxEzMvsSJlXpaa+w436RkW5FHHeqKigwZwe/G1qYuvy+yuK8fwij8Vb7rzvLsVd/Dj6WiP/+3y", + "kAPUSYrGKcq2y4wT3IrYC7RArsUAnsLazga/OIoG+WqlFQsvKRZcWb+rESZV/RVBp8qA9xDB5tBTPptr", + "8OlW8/Mr5+JxTFrlbk07sYCOLTJaZULEerW540+7cmpTpRN8SYZbxxGAb9LCR4AXSLPoLB9kZsVWPuye", + "lncw9lkE7TSrA1NhFgjJKJ88eUkaeaJndYZGfk97gTDhd7Wy7MyuyjRW6Y050Sgfj3nOGomGmigIB0Ab", + "BSKwh4FR0BCaVYVBFH2zzPsbBaqob40HWLxQfJkXibwgDCPl/8D640oL0KKADG1/L1vfs9ZrJbbssSe/", + "tmEBGaqoXfbSyPKskjdE0fiel8dZD+TrD9YepJEUG82fYemiqn0yuT3vodjeTJU2cAuXdldrsxhFxFG5", + "TVGUEkjPvPKvBILHIH6OlL5roOs+QnJNJ991Tce0igzz06LwhVe409WqrB4fHh/tHdL/3RwenrD//Y9F", + "KskywQ/c3F+FFmKQqiBAHdSYwrcEsLKK73s2eHNw1y8bc6S2gHRkfNLKxy2Vj/ndWbmUxAc+K2Vpf1DC", + "S12q5+0mecebvO5bQIYCZqrU1FvgKUNiz5dI2+iDEDZpCAOedqT2+k82b3NOtA/fSjKqIBlWLpkSOAvB", + "vKpWBP1eKZl4k1ctmTgKmkimRCJtk5KJg+kqmBLRupVLrVwqyaWCXFihXBKZxFxCXGW21roQV5EMto1x", + "3eYYV04urKa221MU1v6SNl/kXZOgiaEaxdWpKYnOGVDRoQLS6klePIxUZ58GcaSKkdsL73wgqUJMJjcF", + "ipcOJbXlxFab2AaTimBSgY8mV8mSKV8onFTSSJN40m3Mpfq6A0rLiVIdeL+B2cRiSsU/3IJKa2XGjoeV", + "0slVaXLBwvUBphlW7MBu1g/tyv8yaLTl/a2IJ6ll765ObjVxo5J+ReCoMA8tfLvLsaMFA/hX41EZEtry", + "qCUmtEJNWi3h6xizepEo8uMpisaKiKYQYzCu4JQB9CF6gi23NDDCozQMS6QVzb0ZmIcxCDwUeSCae2K1", + "3Q6B38nBLASoQEDFKZc1omcJ3WSCeG+59Sc/tKvpq8+mO2/5Szz6E/pVJ90cBh9AiGHLvpZCAZwlDYy4", + "qHZ0sYRF6NtekkZ1XsR8ip1aP2KWUqf1JW5/ki8s0h45eRM3liKJhZOCJEQQs8SQ0Am8Nca2hoA0AWVV", + "ga2nhjRLj3tPIvORAyBZvqX7aWXCpYViV8spF3Yk6JYCoZ6DuCSKgMmaY2y/TSCZcAGAIj9MA1ZVBVPt", + "FUfhXP9dFfowCaQonN/LBrUmzCiOQwgih9DkXNUXB5y9UJSyoTaNNVzZIQ3ehsKWDeL5IQRjpmqfBV3E", + "Cbvi1MlAuV5BFHhxSuifwrDE1LKkDaSVuO+dwweQhjw57D8pPfzTQw9eGmHI1Lhp+WKmezlop5KENlYA", + "o+kdS3stv21JqnMWpW7oyt8H9Pclfb26hXsQIDwLwXyPXUjW2LuiLR1WXGDGDxVGcLUNfM4HYxebO20P", + "a6JVFSbOI0U8+xHoE6izGwKaLH2R+mBrdnIZSaAVXa3oaiq6hBFSW01c3m7nzZoK0dRecx8J1GlIqbnr", + "1rHLgqclDjd6ya3JFlZhHDe779YppPXKFa+fCwy0AgbP8zO7e9Z+qau9lyM5auojgjNvAImVwhX5l/+3", + "EzCi+N+ON7M49DP6cbz+ysHAT4Bj1tPiRdeWt7OJSxbgslZz71BdTEeG7pYIegEWPxBZ6Ks4nfAn/SRl", + "5nWe7/druXgo09wvyMv69JrN/muytu6Mbll6SyNFzuI05NW2mVvZZLls0RvJHFepmhMvImucqxFmlcO5", + "P8P96KDq1jn7NF5P+ulMrBqvQ35dibpQoYhWqLZ2UlF2ETRF0bjeWhLtGkuvj5DciCl29uxjlEEBnJEJ", + "fznJsyt4vizxb5Z+rMPWVUTlm9NKkp2XJFX8uWrxAmdCpsg/fx6AxJ+gJ1hnBYlWAkza3ShChgTORFDT", + "qRzYQXzI8azeUwlvG+C0nVWaxb6LPW8LNe/Eu3DFdYW34WUhlWN/jfmlfKLbT2VTlWhSLFwvkxpXiXeR", + "Rz1ZVqyVRq9EGrVF439FWaQx/volURiP6yJhwnjshSgq2UZld/RFPL5AEXT1BrVi6GWjvkP4BEOnAGLe", + "MjdzFTNIOqC9PiAYBtZMFpAqXo/NpsFRkbmYdWgKyJD3MgbcAhZOGSdB1frZ5/dzvpaGk1/pfS144NMH", + "KIG+eC5fAcW51mwRSLL+61VSujRoi8YumwpDSWFNF1zE4+ZqQAQaVaRYZBEQWEQSWcIbb9jPZ3rgy6oD", + "c/jgfKK6ZGE8NOllQnE4hI2CbwRSf20aXyDqRhGbypIl4mmKRG6iaBU6V+sy5qEx4oa9ksCbPgxX4a9i", + "BuuVz06XQXakePl0u6X2zZ42ODEGMeQHDfida+BSQl9XZstlVqouIBTx2VA0ruar3SkktKaoU46AJsot", + "e62dq17U6rld0nOCTxZgvQp9dwBCShjReA9OAQr3xkmcziovTqlxJ0+BgrzYGB4bwBMDFFn3lDbp0RYf", + "aYNdeciyfk1oQkzD1PfWTWh5J3+bWEGtjfSY89GnPFcdY7z6JxX6ya2AGzddV0J5o6Pd0XrZewENaKCh", + "lq+NZz8jt61WSx5gSEhdaBFmuye7eLJL9ZtPjVxQNB6KPjuSXGxDalJDzBI6Ut+TlpUMxzoDmlbGRzO0", + "R+JHWJMyyDu97nu8XTXXnM7QDW3W2pP4gMUVXfcZPvBAzNKQT2R8VOtDLxqPlCI5ajVmUD8uk1I6yqjd", + "jdhbG5EhQNK6Zhau04VRnLTlrxU/m82YqSGDVSkch2gpXuUiFzJlS06XBc20Sem2OjzhEc6dghNou+bJ", + "6BgZfIZzl2RhGUwqfLl/jl2zhnFZ0RhAGRLdP18QxOwN2hKJ/VwgHKQRf0cpHF8vEurB9vNlAj3Y1FsQ", + "5qHDoQd5VBBLlk8Qzr0nEKbQnFVQZYT9g7Lb0QlretTp0n8d838dU/FenX3wy2qTD2bL4OndVP7Bajpn", + "jfubyTu4zrPCQi/t2uiayB5zqRktDLnLu5DZuBYbpD0CMAQwXNS4hUX6xhcJ7+GU0MTnC3mP1x5dffxf", + "m5l1IPhTmKfwuw9hAC1lZfjeNODz+oPJwSgNH+3hdO/TUBRCgjiTCbhSKNA+r1gw0OU3FA74JaUDbi4e", + "2tcXWyYfGJvqQgKvWEq4VTbnjgwtvWjOxLVJDR5W8uoLn3MEuBsU4sCwpgrD+fIaz9lhmZ491pjq3Lk6", + "h15VHWY5SlohtbVCStQSXot8Ym40Rx8r9805+Fk/w3l7rZc5Gxc6rTNktyd204ndE77fVfKBW51/3Ew1", + "v/rK/xwB26KaV+NWy5X9bxXmq1GYKHpCBDYNsJa9zEFjffa11ZUyVkzDx0JRYhLbbWyYKXw6o8U1xUzz", + "CSppvXV/a1HSHCVuwdEcty8aEc3BXSQQWhBGy5bm6GfFN6sJ1RR8Ln/Y4/92KHaO1cHOhZV3vOx5nq+q", + "YdtT6Nh13epcHX17uTfHSKJKv9ofW1aE/D7Wvmltxgm78651VzhhvU9vF9O7L/b41pFzOXw7w7niUWxj", + "zq3SfFM4HTHma3RGk73MLP6FfW3PaJIaNXwsdEaT2G6NQdMZLaPF1diCYryDH/wPByPQAwII7yGJp3XP", + "3jg1/BqmoFi2DTb+eaO8+3YtvLuIDfg6uHaLskdeWpJFKibNbczK5MUsiaeQTGCK96ZUevv1qfizLp7o", + "ou6T67IsXauuX8Rkv4SKJfA7OZiFABWIoThSE+1ZxnLLiy/Ni5QDDPuyKl78VwpT6MyGrHVjDvw77bVD", + "zLfbr3R26eHF+k8SOdpb7DWm9wQTjOKolYnbJBPV7pQlouScRWViAgjcY5e/LmFLtDW/Kq6LWxoAAi9o", + "w/aN6DZXWlvFe8JaTK7z1aCisy14OViEZVMpovO81iAwTmPnNjKu4D/ScZOJW4pq74L/uqjEFT32ZnGI", + "/Hl9+iTZweMdXJInybCea9ajTZ10YELLYu7Wwm60bteNZyDDIfAfq5MmDWkT7xmOJnH8WL6IYJ+/8a/t", + "RQTPl6TjpMnpoYDqbWKHDVXvu41ASiZxgv4NAz7xu81M/AWSSczLOoMwjJ/NlQP5BjE7kLOArs/Yx6UY", + "8QATkBArOw7pV67Hrk5TMvHYYaXIkLcYJvz+kgF0RRHKeu4iZ745PDbgQecehjKhVnJYmUAQiPvWMOYE", + "U+PxZBsO/TRBZM7w48fxI4J0UJbg/06nB4bS/IySEOgOLEwHdTnshpfDIgEWBHKEWzks5PDlsK+jqoEk", + "LmK5lcVbJ4vLjKAk8eVwidR5hYFNDNZGCjME5PmrMmPe6mg2P6lzxG9xV1uG3iKGtnKeI0dXalRRc2pv", + "E1dWogzmrt1crd9dYEJMM5+Bqs2Y25n2UmUbLlXU3qz6mtlUIbSSdbNioN5ozhnKWJ54R/x43W2tUrqB", + "WsILyodWImxdEWFdRKykcLCTnKjNb3NKCJzORKIm1tahrvmuJbZpJUhVMCnC7KmNECGcCMLtOyC88CVe", + "HaNsiqETSDtW5MFgCYNceZg1b1l4GzNzJGkktqrmIRSKZimLh+CXu6bl/twKS6XNy1EhX9iGv4RAydZU", + "6QvgzUSwQJ1w+QjJkA/bipaXsw6aZZyzeBrEcO2BYpsPFHKX1iI1xF383nOcPFY93szCOq2BEm2MRBai", + "zlHxjSGVIqSq7g1Fhgqj5x09uR2tE3/bbuU08l88bY8YxMZCr/72Lcc/HBsbKldlmDlolHRHbm3Ludt3", + "/aYz3iLOei6Vq93zVENy4V0de5vphlevLDNMtFXhlj5qyidA+TwGHMeLXlJJRPPjZfNsrXp9LEPSVq2o", + "VZu6VUvdquEF17iJchXIXi6Rqwlu54KPmgcpRzDt8XQrE7zm96j8yLD6gNpE4PzQ/1l3O57jhFoNLMh0", + "ly/LC6xvBk3H4A6bCWK7Fn2v3F6e218L5/3S9S+Fu3maWpyfD9gVR62Lml+EcIbWgd6v4es+G71l7pdn", + "7iw3wrVWpoXDuIw3O48jtt2tQ3tDDu1vOu4jl6wE2SY1NRlWJ3HwBMzgmuyIIRu7lTc7Y0zwDWstil/I", + "olAR8Q5l7HMV7MNQ3bphg61RxfrsORa/IO/J0hetDFg5gBcAE69/zhLITqAXArmDtuQnAJN+YM1+8ubY", + "lP1kA5F7TUre6JKnja3Z0hv7BWSJ+3W+myzETjcTrKWbRfMq0zEF8AGkIemcHHZzomITiZnU3O8WmXzI", + "8zON5h6bwDyp+GR/Jb4Js6u97Fm9vbXKRG9qTMcSuh7wRoD4k9JlT5XF9Opr5+r3JBwZrsHAIka9fFXy", + "qgvqhu3tUU3SJU42m7i5wQd+Ekf1Fglt5f0ZjzKgSILG49rwibMkjl61mbIzWSPVxqKATjuGRJnE+zXJ", + "gW0HtzWcdenMTcG7rDOljFMyim8yHe3QfKrdzHtckYlzNPceRLbPlSUE1aUIdk8KOpqvLy+oZhRsODNo", + "DhlLWOit2jVY6SU9tyZznSrdgx/0P3vyV7eyM2VF7HzxQQlnx4vQqNXbwMphdPNlaBzrxRg3sc06Wqzf", + "YkZTs7uKPEHc/exWXSYuyVy7HJ60xZy1JtXZqs1dcOw3UtYrkA9u+pvRgKsXX79aqI9NaE/J23xKZjdH", + "DY7IrP0Gz8fbeHifgYQizXJfXQCLN/6mezA3BJ/htbkRNnEzvF64To2PMjxMAEkxdCrdJNsucqQdsr7i", + "cOkC3COKAieoWMPGIH1GUVAPzc57UAiaQg88UEBLEZPPAMsHjPoSOseHx0d7h/R/N4eHJ+x//2P1ULHu", + "p3QCM/EGgMA9CkXHtSoohXgEH+IErhPk92yGVcJcgeUHFCE8WRxm2X+jeF4V0CvF9Po8gmX326v1BxZt", + "x/ZYs5YYyfU4AllYpEsqYOAJ0Kiiy7O/nhvYMfp5l4tZtmZ4a4Zv3gxvbcvWtnyRdw94yeKvTAC1Scrr", + "9fsaCrFmep6CGqQhVY81XkPVchH/4VB2br2I2+xFXN+5SBHAToVLtMZUa0ztjDGVLSMT1SvxzTpV1VcM", + "rry0Gy5LX5YwrddhtVaJxQJYr11y8EP9uVfK41IblWQGuaHNsuOxSQYcWPMWG1G9teFK5t1t45WK8UoW", + "PDULSLDQRk3k0koYcKdrEe0U961THbeqeNfjmtYrR9wMA5Wq4Wf2QqiyWinwIvhsfyfk/kzohnfYneTK", + "9S9WqnMzVIK20Tqqhm1oUvfEuvkbTW7ZLMhTzwlth78Vi5sv7rh1CTWFoKui8vU80dRkcc6PbJbH0iIQ", + "EtndHiyZEoM0aqXwJqWw3AFtA5rIX6vdsMFCVM3NUV0Cv8qTZit+ncSvMEjqbOKVi1yepX3Pj9OI1ITo", + "sDYy55UsLwCeAArBKIRM+mrixnwa/wgJzwKPz9iMOy9661KT7XhqwtxmLXj05qTCyaf1hlvu6HNIWixh", + "YZ79UwwTfOCnSQKrORvz0wFv6NFuJe69xTD5CMmZGGyNdEdnakhnDOK20M3LF7qBfpogMmdi3I/jRwRP", + "Uyq7/rijoqrwuC1PbpLc2fYbyHiMyCQdHfggDEfAf7SS81k8nYWQQE7TV3R+z6iP6ES8zMdHNvQVxeWZ", + "HL5A4G8Oj2vuE3wxb1CedwJBIGrahTHfDGMNRSXWfxaQmcOdXGB+Dkf0YQISuygY0q+LIY51bY41Bs/6", + "ccaga4iwOB6HcD30xob+xemNo2/F9JYh7pejNxQ9IQJdCl9Ka5h3YEa3k/qmI9ywvn0x1xq1uD6RU/xE", + "iLDcmPwCW3vRWa2y3K8F7GWUd2M4IeZo7wD4PpwRu+ftlH3HysMmJilRm775vE9nPf4kPjifqL4wYwX1", + "8ZWb6K+NAlDkxbFd2nt3+kogy6JYUbGNfm9GX7xPZ131z+jgK6AvvvKWvmqq01MkLUBfYTxGkZ2sLuIx", + "9lDkAaYb9ysMjAs20HpoialgOv6GKsg6naPDeDyGgYei9vi8VcfnvFqnVON6Tg7jcZySGmaIU+LGDXH6", + "8r4eQaPxltVTaom0xhhl1ONKtlM4HcEET9CswRFI6+R2DOIq5EvWTTwjWiuBmydtfh7SUdSeiRY5E+kY", + "rCfJGcD4OU4qIhG4mBSS1JPtq0TqtRxzfTbG2QREYzXRNhkbPoMsUIhqxfkOiXNOVnlKd2CiBI6pIEuq", + "Dn28Ba60SFSczrrYRoKxTQwjkddec+2EnS5JyNXmwSHwH9dywzCkI2/xBUONqGl44/AEEyxAqCzdK9rJ", + "+BUMkyeDjdiPHuKPkHwVg660cIkGaZbR4Wj/cP/QlDNCCxv5Q3W9c6hJclOx2EKoXAU5f4NeAkmaRDnk", + "FexsKqXSKELROJvi+54cci+e8Seq2Wxy057haBLHj3siiujgh/jB4T0e1RSidTnKiP/u/tRODGSP4lET", + "bTiIx/HtmoSv1QsvrxeK7+V0MrWG7ogWd07McSDw7HJIlk1l0b9qjhF2D3ZNrLG1fLOa4DcOPY99E6ih", + "mBmICW1SV+UNFdhR29Wy5xaxJ/MJlLaoKY8q3mR//HSo422wNjiFOT5MFRGCVQGnBh2/O+GmjQP/xIpb", + "b1gporT0WocazdUBpMysplRI/EmFr6uSkHmrnaHlNbgSGAJyesOmKwQGUomyzT1iceQ1DlnLaWZOEwyx", + "DLMVtEnxZYZTZhIVPu6UCqHBuWgrnzc0yeqhAGxfV23+dZXpOKRRzIKPG7p1FpY7JzQwuV7DK58FX/a0", + "vPXSvKU/IVqGsVzMPnfuamYHbgWDra+uNkeG60NnbnXluWzTxqGTRCiah608sBqIyzFnjZnolF6fblI+", + "j75ivCd102HVlA3S6W8DPxtSWvKElCuoN7R4tSEzYOMkTmcsT2gGgtwoKyis02c479TmcFizkFgyd7e8", + "VGrTd2+hNbFQvvBGgkvmlbHGhsiUCE0zvSyU4GUrJdeNgV32vf4D827jlFIHDLqMq0JAICaKpxD2HiDx", + "JzCwZZPOBP+WG1KCDBbMGvNiuWI0eBsliWlTw7SpYdaQGqaRaBayATvcauU0uZNYFrE1O+SC+RXk8pql", + "nAyYWs4UbOXdVpmAGSkuagIWA/9GECQwUYF/XWMoIIsk4/IgTcLOSafz8+7n/w8AAP//U3ZgcgDpAgA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/v1/server/oas/transformers/v1/events.go b/api/v1/server/oas/transformers/v1/events.go index d8a9a307c..5e2d7c63c 100644 --- a/api/v1/server/oas/transformers/v1/events.go +++ b/api/v1/server/oas/transformers/v1/events.go @@ -92,10 +92,11 @@ func ToV1EventList(events []*v1.ListEventsRow, limit, offset, total int64) gen.V Failed: row.FailedCount, Running: row.RunningCount, }, - Payload: &payload, - SeenAt: &row.EventSeenAt.Time, - Scope: &row.EventScope, - TriggeredRuns: &triggeredRuns, + Payload: &payload, + SeenAt: &row.EventSeenAt.Time, + Scope: &row.EventScope, + TriggeredRuns: &triggeredRuns, + TriggeringWebhookName: row.TriggeringWebhookName, } } diff --git a/api/v1/server/oas/transformers/v1/webhooks.go b/api/v1/server/oas/transformers/v1/webhooks.go new file mode 100644 index 000000000..74e9fa367 --- /dev/null +++ b/api/v1/server/oas/transformers/v1/webhooks.go @@ -0,0 +1,37 @@ +package transformers + +import ( + "github.com/google/uuid" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" +) + +func ToV1Webhook(webhook *sqlcv1.V1IncomingWebhook) gen.V1Webhook { + // Intentionally empty uuid + var id uuid.UUID + + return gen.V1Webhook{ + AuthType: gen.V1WebhookAuthType(webhook.AuthMethod), + Metadata: gen.APIResourceMeta{ + CreatedAt: webhook.InsertedAt.Time, + UpdatedAt: webhook.UpdatedAt.Time, + Id: id.String(), + }, + TenantId: webhook.TenantID.String(), + EventKeyExpression: webhook.EventKeyExpression, + Name: webhook.Name, + SourceName: gen.V1WebhookSourceName(webhook.SourceName), + } +} + +func ToV1WebhookList(webhooks []*sqlcv1.V1IncomingWebhook) gen.V1WebhookList { + rows := make([]gen.V1Webhook, len(webhooks)) + + for i, webhook := range webhooks { + rows[i] = ToV1Webhook(webhook) + } + + return gen.V1WebhookList{ + Rows: &rows, + } +} diff --git a/api/v1/server/run/run.go b/api/v1/server/run/run.go index 42c3bca65..cb2baa530 100644 --- a/api/v1/server/run/run.go +++ b/api/v1/server/run/run.go @@ -30,6 +30,7 @@ import ( eventsv1 "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/events" filtersv1 "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/filters" "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/tasks" + webhooksv1 "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/webhooks" workflowrunsv1 "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/workflow-runs" webhookworker "github.com/hatchet-dev/hatchet/api/v1/server/handlers/webhook-worker" "github.com/hatchet-dev/hatchet/api/v1/server/handlers/workers" @@ -41,6 +42,7 @@ import ( "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" "github.com/hatchet-dev/hatchet/pkg/config/server" "github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers" + "golang.org/x/time/rate" ) type apiService struct { @@ -64,6 +66,7 @@ type apiService struct { *workflowrunsv1.V1WorkflowRunsService *eventsv1.V1EventsService *filtersv1.V1FiltersService + *webhooksv1.V1WebhooksService *celv1.V1CELService } @@ -89,6 +92,7 @@ func newAPIService(config *server.ServerConfig) *apiService { V1WorkflowRunsService: workflowrunsv1.NewV1WorkflowRunsService(config), V1EventsService: eventsv1.NewV1EventsService(config), V1FiltersService: filtersv1.NewV1FiltersService(config), + V1WebhooksService: webhooksv1.NewV1WebhooksService(config), V1CELService: celv1.NewV1CELService(config), } } @@ -417,6 +421,20 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T, middlewares [] return filter, sqlchelpers.UUIDToStr(filter.TenantID), nil }) + populatorMW.RegisterGetter("v1-webhook", func(config *server.ServerConfig, parentId, id string) (result interface{}, uniqueParentId string, err error) { + webhook, err := t.config.V1.Webhooks().GetWebhook( + context.Background(), + parentId, + id, + ) + + if err != nil { + return nil, "", err + } + + return webhook, sqlchelpers.UUIDToStr(webhook.TenantID), nil + }) + authnMW := authn.NewAuthN(t.config) authzMW := authz.NewAuthZ(t.config) @@ -488,6 +506,11 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T, middlewares [] loggerMiddleware, middleware.Recover(), allHatchetMiddleware, + hatchetmiddleware.WebhookRateLimitMiddleware( + rate.Limit(t.config.Runtime.WebhookRateLimit), + t.config.Runtime.WebhookRateLimitBurst, + t.config.Logger, + ), ) return populatorMW, nil diff --git a/cmd/hatchet-migrate/migrate/migrations/20250725183217_v1_0_30.sql b/cmd/hatchet-migrate/migrate/migrations/20250725183217_v1_0_30.sql new file mode 100644 index 000000000..66f786c73 --- /dev/null +++ b/cmd/hatchet-migrate/migrate/migrations/20250725183217_v1_0_30.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE v1_cel_evaluation_failure_source AS ENUM ('FILTER', 'WEBHOOK'); + +CREATE TABLE v1_cel_evaluation_failures_olap ( + id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY, + + tenant_id UUID NOT NULL, + + source v1_cel_evaluation_failure_source NOT NULL, + + error TEXT NOT NULL, + + inserted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (inserted_at, id) +) PARTITION BY RANGE(inserted_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE v1_cel_evaluation_failures_olap; +DROP TYPE v1_cel_evaluation_failure_source; +-- +goose StatementEnd diff --git a/cmd/hatchet-migrate/migrate/migrations/20250726193737_v1_0_31.sql b/cmd/hatchet-migrate/migrate/migrations/20250726193737_v1_0_31.sql new file mode 100644 index 000000000..d15e8087a --- /dev/null +++ b/cmd/hatchet-migrate/migrate/migrations/20250726193737_v1_0_31.sql @@ -0,0 +1,76 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE v1_incoming_webhook_auth_type AS ENUM ('BASIC', 'API_KEY', 'HMAC'); +CREATE TYPE v1_incoming_webhook_hmac_algorithm AS ENUM ('SHA1', 'SHA256', 'SHA512', 'MD5'); +CREATE TYPE v1_incoming_webhook_hmac_encoding AS ENUM ('HEX', 'BASE64', 'BASE64URL'); + +-- Can add more sources in the future +CREATE TYPE v1_incoming_webhook_source_name AS ENUM ('GENERIC', 'GITHUB', 'STRIPE'); + +CREATE TABLE v1_incoming_webhook ( + tenant_id UUID NOT NULL, + name TEXT NOT NULL, + + source_name v1_incoming_webhook_source_name NOT NULL, + + -- CEL expression that creates an event key + -- from the payload of the webhook + event_key_expression TEXT NOT NULL, + + auth_method v1_incoming_webhook_auth_type NOT NULL, + + auth__basic__username TEXT, + auth__basic__password BYTEA, + + auth__api_key__header_name TEXT, + auth__api_key__key BYTEA, + + auth__hmac__algorithm v1_incoming_webhook_hmac_algorithm, + auth__hmac__encoding v1_incoming_webhook_hmac_encoding, + auth__hmac__signature_header_name TEXT, + auth__hmac__webhook_signing_secret BYTEA, + + inserted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (tenant_id, name), + CHECK ( + ( + auth_method = 'BASIC' + AND ( + auth__basic__username IS NOT NULL + AND auth__basic__password IS NOT NULL + ) + ) + OR + ( + auth_method = 'API_KEY' + AND ( + auth__api_key__header_name IS NOT NULL + AND auth__api_key__key IS NOT NULL + ) + ) + OR + ( + auth_method = 'HMAC' + AND ( + auth__hmac__algorithm IS NOT NULL + AND auth__hmac__encoding IS NOT NULL + AND auth__hmac__signature_header_name IS NOT NULL + AND auth__hmac__webhook_signing_secret IS NOT NULL + ) + ) + ), + CHECK (LENGTH(event_key_expression) > 0), + CHECK (LENGTH(name) > 0) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE v1_incoming_webhook; +DROP TYPE v1_incoming_webhook_auth_type; +DROP TYPE v1_incoming_webhook_hmac_algorithm; +DROP TYPE v1_incoming_webhook_hmac_encoding; +DROP TYPE v1_incoming_webhook_source_name; +-- +goose StatementEnd diff --git a/cmd/hatchet-migrate/migrate/migrations/20250726214427_v1_0_32.sql b/cmd/hatchet-migrate/migrate/migrations/20250726214427_v1_0_32.sql new file mode 100644 index 000000000..ab3b8ce64 --- /dev/null +++ b/cmd/hatchet-migrate/migrate/migrations/20250726214427_v1_0_32.sql @@ -0,0 +1,31 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE v1_events_olap ADD COLUMN triggering_webhook_name TEXT; + +CREATE TABLE v1_incoming_webhook_validation_failures_olap ( + id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY, + + tenant_id UUID NOT NULL, + + -- webhook names are tenant-unique + incoming_webhook_name TEXT NOT NULL, + + error TEXT NOT NULL, + + inserted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (inserted_at, id) +) PARTITION BY RANGE(inserted_at); + +CREATE INDEX v1_incoming_webhook_validation_failures_olap_tenant_id_incoming_webhook_name_idx ON v1_incoming_webhook_validation_failures_olap (tenant_id, incoming_webhook_name); + +SELECT create_v1_range_partition('v1_incoming_webhook_validation_failures_olap', NOW()::DATE); +SELECT create_v1_range_partition('v1_incoming_webhook_validation_failures_olap', (NOW() + INTERVAL '1 day')::DATE); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE v1_events_olap DROP COLUMN triggering_webhook_name; +DROP TABLE v1_incoming_webhook_validation_failures_olap; +-- +goose StatementEnd diff --git a/cmd/hatchet-migrate/migrate/migrations/20250726225025_v1_0_33.sql b/cmd/hatchet-migrate/migrate/migrations/20250726225025_v1_0_33.sql new file mode 100644 index 000000000..e7c7ed8e2 --- /dev/null +++ b/cmd/hatchet-migrate/migrate/migrations/20250726225025_v1_0_33.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TYPE "LimitResource" ADD VALUE IF NOT EXISTS 'INCOMING_WEBHOOK'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- +goose StatementEnd diff --git a/examples/go/bulk-operations/main.go b/examples/go/bulk-operations/main.go index 38621a5da..ef17f4b35 100644 --- a/examples/go/bulk-operations/main.go +++ b/examples/go/bulk-operations/main.go @@ -35,7 +35,6 @@ func main() { selectedWorkflow := (*workflows.Rows)[0] selectedWorkflowUUID := uuid.MustParse(selectedWorkflow.Metadata.Id) - // > List runs workflowRuns, err := hatchet.Runs().List(ctx, rest.V1WorkflowRunListParams{ WorkflowIds: &[]types.UUID{selectedWorkflowUUID}, @@ -50,7 +49,6 @@ func main() { runIds = append(runIds, uuid.MustParse(run.Metadata.Id)) } - // > Cancel by run ids _, err = hatchet.Runs().Cancel(ctx, rest.V1CancelTaskRequest{ ExternalIds: &runIds, @@ -59,7 +57,6 @@ func main() { log.Fatalf("failed to cancel runs by ids: %v", err) } - // > Cancel by filters tNow := time.Now().UTC() @@ -76,6 +73,5 @@ func main() { log.Fatalf("failed to cancel runs by filters: %v", err) } - fmt.Println("cancelled all runs for workflow", selectedWorkflow.Name) } diff --git a/examples/python/webhooks/test_webhooks.py b/examples/python/webhooks/test_webhooks.py new file mode 100644 index 000000000..317ce0657 --- /dev/null +++ b/examples/python/webhooks/test_webhooks.py @@ -0,0 +1,643 @@ +import asyncio +import base64 +import hashlib +import hmac +import json +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +import aiohttp +import pytest + +from examples.webhooks.worker import WebhookInput +from hatchet_sdk import Hatchet +from hatchet_sdk.clients.rest.api.webhook_api import WebhookApi +from hatchet_sdk.clients.rest.models.v1_create_webhook_request import ( + V1CreateWebhookRequest, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import ( + V1CreateWebhookRequestAPIKey, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import ( + V1CreateWebhookRequestBasicAuth, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import ( + V1CreateWebhookRequestHMAC, +) +from hatchet_sdk.clients.rest.models.v1_event import V1Event +from hatchet_sdk.clients.rest.models.v1_task_status import V1TaskStatus +from hatchet_sdk.clients.rest.models.v1_task_summary import V1TaskSummary +from hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook +from hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth +from hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import ( + V1WebhookHMACAlgorithm, +) +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import ( + V1WebhookHMACEncoding, +) +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName + +TEST_BASIC_USERNAME = "test_user" +TEST_BASIC_PASSWORD = "test_password" +TEST_API_KEY_HEADER = "X-API-Key" +TEST_API_KEY_VALUE = "test_api_key_123" +TEST_HMAC_SIGNATURE_HEADER = "X-Signature" +TEST_HMAC_SECRET = "test_hmac_secret" + + +@pytest.fixture +def webhook_body() -> WebhookInput: + return WebhookInput(type="test", message="Hello, world!") + + +@pytest.fixture +def test_run_id() -> str: + return str(uuid4()) + + +@pytest.fixture +def test_start() -> datetime: + return datetime.now(timezone.utc) + + +def create_hmac_signature( + payload: bytes, + secret: str, + algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256, + encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX, +) -> str: + algorithm_map = { + V1WebhookHMACAlgorithm.SHA1: hashlib.sha1, + V1WebhookHMACAlgorithm.SHA256: hashlib.sha256, + V1WebhookHMACAlgorithm.SHA512: hashlib.sha512, + V1WebhookHMACAlgorithm.MD5: hashlib.md5, + } + + hash_func = algorithm_map[algorithm] + signature = hmac.new(secret.encode(), payload, hash_func).digest() + + if encoding == V1WebhookHMACEncoding.HEX: + return signature.hex() + if encoding == V1WebhookHMACEncoding.BASE64: + return base64.b64encode(signature).decode() + if encoding == V1WebhookHMACEncoding.BASE64URL: + return base64.urlsafe_b64encode(signature).decode() + + raise ValueError(f"Unsupported encoding: {encoding}") + + +async def send_webhook_request( + url: str, + body: WebhookInput, + auth_type: str, + auth_data: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> aiohttp.ClientResponse: + request_headers = headers or {} + auth = None + + if auth_type == "BASIC" and auth_data: + auth = aiohttp.BasicAuth(auth_data["username"], auth_data["password"]) + elif auth_type == "API_KEY" and auth_data: + request_headers[auth_data["header_name"]] = auth_data["api_key"] + elif auth_type == "HMAC" and auth_data: + payload = json.dumps(body.model_dump()).encode() + signature = create_hmac_signature( + payload, + auth_data["secret"], + auth_data.get("algorithm", V1WebhookHMACAlgorithm.SHA256), + auth_data.get("encoding", V1WebhookHMACEncoding.HEX), + ) + request_headers[auth_data["header_name"]] = signature + + async with aiohttp.ClientSession() as session: + return await session.post( + url, json=body.model_dump(), auth=auth, headers=request_headers + ) + + +async def wait_for_event( + hatchet: Hatchet, + webhook_name: str, + test_start: datetime, +) -> V1Event | None: + await asyncio.sleep(5) + + events = await hatchet.event.aio_list(since=test_start) + + if events.rows is None: + return None + + return next( + ( + event + for event in events.rows + if event.triggering_webhook_name == webhook_name + ), + None, + ) + + +async def wait_for_workflow_run( + hatchet: Hatchet, event_id: str, test_start: datetime +) -> V1TaskSummary | None: + await asyncio.sleep(5) + + runs = await hatchet.runs.aio_list( + since=test_start, + additional_metadata={ + "hatchet__event_id": event_id, + }, + ) + + if len(runs.rows) == 0: + return None + + return runs.rows[0] + + +@asynccontextmanager +async def basic_auth_webhook( + hatchet: Hatchet, + test_run_id: str, + username: str = TEST_BASIC_USERNAME, + password: str = TEST_BASIC_PASSWORD, + source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC, +) -> AsyncGenerator[V1Webhook, None]: + ## Hack to get the API client + client = hatchet.metrics.client() + webhook_api = WebhookApi(client) + + webhook_request = V1CreateWebhookRequestBasicAuth( + sourceName=source_name, + name=f"test-webhook-basic-{test_run_id}", + eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type", + authType="BASIC", + auth=V1WebhookBasicAuth( + username=username, + password=password, + ), + ) + + incoming_webhook = webhook_api.v1_webhook_create( + tenant=hatchet.tenant_id, + v1_create_webhook_request=V1CreateWebhookRequest(webhook_request), + ) + + try: + yield incoming_webhook + finally: + webhook_api.v1_webhook_delete( + tenant=hatchet.tenant_id, + v1_webhook=incoming_webhook.name, + ) + + +@asynccontextmanager +async def api_key_webhook( + hatchet: Hatchet, + test_run_id: str, + header_name: str = TEST_API_KEY_HEADER, + api_key: str = TEST_API_KEY_VALUE, + source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC, +) -> AsyncGenerator[V1Webhook, None]: + client = hatchet.metrics.client() + webhook_api = WebhookApi(client) + + webhook_request = V1CreateWebhookRequestAPIKey( + sourceName=source_name, + name=f"test-webhook-apikey-{test_run_id}", + eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type", + authType="API_KEY", + auth=V1WebhookAPIKeyAuth( + headerName=header_name, + apiKey=api_key, + ), + ) + + incoming_webhook = webhook_api.v1_webhook_create( + tenant=hatchet.tenant_id, + v1_create_webhook_request=V1CreateWebhookRequest(webhook_request), + ) + + try: + yield incoming_webhook + finally: + webhook_api.v1_webhook_delete( + tenant=hatchet.tenant_id, + v1_webhook=incoming_webhook.name, + ) + + +@asynccontextmanager +async def hmac_webhook( + hatchet: Hatchet, + test_run_id: str, + signature_header_name: str = TEST_HMAC_SIGNATURE_HEADER, + signing_secret: str = TEST_HMAC_SECRET, + algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256, + encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX, + source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC, +) -> AsyncGenerator[V1Webhook, None]: + client = hatchet.metrics.client() + webhook_api = WebhookApi(client) + + webhook_request = V1CreateWebhookRequestHMAC( + sourceName=source_name, + name=f"test-webhook-hmac-{test_run_id}", + eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type", + authType="HMAC", + auth=V1WebhookHMACAuth( + algorithm=algorithm, + encoding=encoding, + signatureHeaderName=signature_header_name, + signingSecret=signing_secret, + ), + ) + + incoming_webhook = webhook_api.v1_webhook_create( + tenant=hatchet.tenant_id, + v1_create_webhook_request=V1CreateWebhookRequest(webhook_request), + ) + + try: + yield incoming_webhook + finally: + webhook_api.v1_webhook_delete( + tenant=hatchet.tenant_id, + v1_webhook=incoming_webhook.name, + ) + + +def url(tenant_id: str, webhook_name: str) -> str: + return f"http://localhost:8080/api/v1/stable/tenants/{tenant_id}/webhooks/{webhook_name}" + + +async def assert_has_runs( + hatchet: Hatchet, + test_start: datetime, + webhook_body: WebhookInput, + incoming_webhook: V1Webhook, +) -> None: + triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start) + assert triggered_event is not None + assert ( + triggered_event.key + == f"{hatchet.config.apply_namespace('webhook')}:{webhook_body.type}" + ) + assert triggered_event.payload == webhook_body.model_dump() + + workflow_run = await wait_for_workflow_run( + hatchet, triggered_event.metadata.id, test_start + ) + assert workflow_run is not None + assert workflow_run.status == V1TaskStatus.COMPLETED + assert workflow_run.additional_metadata is not None + + assert ( + workflow_run.additional_metadata["hatchet__event_id"] + == triggered_event.metadata.id + ) + assert workflow_run.additional_metadata["hatchet__event_key"] == triggered_event.key + assert workflow_run.status == V1TaskStatus.COMPLETED + + +async def assert_event_not_created( + hatchet: Hatchet, + test_start: datetime, + incoming_webhook: V1Webhook, +) -> None: + triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start) + assert triggered_event is None + + +@pytest.mark.asyncio(loop_scope="session") +async def test_basic_auth_success( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "BASIC", + {"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD}, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "username,password", + [ + ("test_user", "incorrect_password"), + ("incorrect_user", "test_password"), + ("incorrect_user", "incorrect_password"), + ("", ""), + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_basic_auth_failure( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + username: str, + password: str, +) -> None: + """Test basic authentication failures.""" + async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "BASIC", + {"username": username, "password": password}, + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_basic_auth_missing_credentials( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE" + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_api_key_success( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with api_key_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "API_KEY", + {"header_name": TEST_API_KEY_HEADER, "api_key": TEST_API_KEY_VALUE}, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "api_key", + [ + "incorrect_api_key", + "", + "partial_key", + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_api_key_failure( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + api_key: str, +) -> None: + async with api_key_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "API_KEY", + {"header_name": TEST_API_KEY_HEADER, "api_key": api_key}, + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_api_key_missing_header( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with api_key_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE" + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_hmac_success( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with hmac_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "HMAC", + { + "header_name": TEST_HMAC_SIGNATURE_HEADER, + "secret": TEST_HMAC_SECRET, + "algorithm": V1WebhookHMACAlgorithm.SHA256, + "encoding": V1WebhookHMACEncoding.HEX, + }, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "algorithm,encoding", + [ + (V1WebhookHMACAlgorithm.SHA1, V1WebhookHMACEncoding.HEX), + (V1WebhookHMACAlgorithm.SHA256, V1WebhookHMACEncoding.BASE64), + (V1WebhookHMACAlgorithm.SHA512, V1WebhookHMACEncoding.BASE64URL), + (V1WebhookHMACAlgorithm.MD5, V1WebhookHMACEncoding.HEX), + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_hmac_different_algorithms_and_encodings( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + algorithm: V1WebhookHMACAlgorithm, + encoding: V1WebhookHMACEncoding, +) -> None: + async with hmac_webhook( + hatchet, test_run_id, algorithm=algorithm, encoding=encoding + ) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "HMAC", + { + "header_name": TEST_HMAC_SIGNATURE_HEADER, + "secret": TEST_HMAC_SECRET, + "algorithm": algorithm, + "encoding": encoding, + }, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "secret", + [ + "incorrect_secret", + "", + "partial_secret", + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_hmac_signature_failure( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + secret: str, +) -> None: + async with hmac_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "HMAC", + { + "header_name": TEST_HMAC_SIGNATURE_HEADER, + "secret": secret, + "algorithm": V1WebhookHMACAlgorithm.SHA256, + "encoding": V1WebhookHMACEncoding.HEX, + }, + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_hmac_missing_signature_header( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with hmac_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE" + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "source_name", + [ + V1WebhookSourceName.GENERIC, + V1WebhookSourceName.GITHUB, + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_different_source_types( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + source_name: V1WebhookSourceName, +) -> None: + async with basic_auth_webhook( + hatchet, test_run_id, source_name=source_name + ) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "BASIC", + {"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD}, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) diff --git a/examples/python/webhooks/worker.py b/examples/python/webhooks/worker.py new file mode 100644 index 000000000..4c3a57df6 --- /dev/null +++ b/examples/python/webhooks/worker.py @@ -0,0 +1,27 @@ +# > Webhooks + +from pydantic import BaseModel + +from hatchet_sdk import Context, Hatchet + +hatchet = Hatchet(debug=True) + + +class WebhookInput(BaseModel): + type: str + message: str + + +@hatchet.task(input_validator=WebhookInput, on_events=["webhook:test"]) +def webhook(input: WebhookInput, ctx: Context) -> dict[str, str]: + return input.model_dump() + + +def main() -> None: + worker = hatchet.worker("webhook-worker", workflows=[webhook]) + worker.start() + + + +if __name__ == "__main__": + main() diff --git a/examples/python/worker.py b/examples/python/worker.py index 3bcdc68f6..dd8c3bd96 100644 --- a/examples/python/worker.py +++ b/examples/python/worker.py @@ -26,6 +26,7 @@ from examples.on_failure.worker import on_failure_wf, on_failure_wf_with_details from examples.return_exceptions.worker import return_exceptions_task from examples.simple.worker import simple, simple_durable from examples.timeout.worker import refresh_timeout_wf, timeout_wf +from examples.webhooks.worker import webhook from hatchet_sdk import Hatchet hatchet = Hatchet(debug=True) @@ -66,6 +67,7 @@ def main() -> None: bulk_replay_test_1, bulk_replay_test_2, bulk_replay_test_3, + webhook, return_exceptions_task, wait_for_sleep_twice, ], diff --git a/frontend/app/src/lib/api/generated/Api.ts b/frontend/app/src/lib/api/generated/Api.ts index 8cdc41c27..7fcacc8a7 100644 --- a/frontend/app/src/lib/api/generated/Api.ts +++ b/frontend/app/src/lib/api/generated/Api.ts @@ -89,6 +89,7 @@ import { V1CancelTaskRequest, V1CancelledTasks, V1CreateFilterRequest, + V1CreateWebhookRequest, V1DagChildren, V1EventList, V1Filter, @@ -105,6 +106,9 @@ import { V1TaskTimingList, V1TriggerWorkflowRunRequest, V1UpdateFilterRequest, + V1Webhook, + V1WebhookList, + V1WebhookSourceName, V1WorkflowRunDetails, V1WorkflowRunDisplayNameList, WebhookWorkerCreateRequest, @@ -788,6 +792,134 @@ export class Api< format: "json", ...params, }); + /** + * @description Lists all webhook for a tenant. + * + * @tags Webhook + * @name V1WebhookList + * @summary List webhooks + * @request GET:/api/v1/stable/tenants/{tenant}/webhooks + * @secure + */ + v1WebhookList = ( + tenant: string, + query?: { + /** + * The number to skip + * @format int64 + */ + offset?: number; + /** + * The number to limit by + * @format int64 + */ + limit?: number; + /** The source names to filter by */ + sourceNames?: V1WebhookSourceName[]; + /** The webhook names to filter by */ + webhookNames?: string[]; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/stable/tenants/${tenant}/webhooks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }); + /** + * @description Create a new webhook + * + * @tags Webhook + * @name V1WebhookCreate + * @summary Create a webhook + * @request POST:/api/v1/stable/tenants/{tenant}/webhooks + * @secure + */ + v1WebhookCreate = ( + tenant: string, + data: V1CreateWebhookRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/stable/tenants/${tenant}/webhooks`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Get a webhook by its name + * + * @tags Webhook + * @name V1WebhookGet + * @summary Get a webhook + * @request GET:/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook} + * @secure + */ + v1WebhookGet = ( + tenant: string, + v1Webhook: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/stable/tenants/${tenant}/webhooks/${v1Webhook}`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Delete a webhook + * + * @tags Webhook + * @name V1WebhookDelete + * @request DELETE:/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook} + * @secure + */ + v1WebhookDelete = ( + tenant: string, + v1Webhook: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/stable/tenants/${tenant}/webhooks/${v1Webhook}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }); + /** + * @description Post an incoming webhook message + * + * @tags Webhook + * @name V1WebhookReceive + * @summary Post a webhook message + * @request POST:/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook} + */ + v1WebhookReceive = ( + tenant: string, + v1Webhook: string, + data?: any, + params: RequestParams = {}, + ) => + this.request< + { + /** @example "OK" */ + message?: string; + }, + APIErrors + >({ + path: `/api/v1/stable/tenants/${tenant}/webhooks/${v1Webhook}`, + method: "POST", + body: data, + format: "json", + ...params, + }); /** * @description Evaluate a CEL expression against provided input data. * diff --git a/frontend/app/src/lib/api/generated/data-contracts.ts b/frontend/app/src/lib/api/generated/data-contracts.ts index 2a28ba1d9..1799b91cc 100644 --- a/frontend/app/src/lib/api/generated/data-contracts.ts +++ b/frontend/app/src/lib/api/generated/data-contracts.ts @@ -211,6 +211,31 @@ export enum V1CELDebugResponseStatus { ERROR = "ERROR", } +export enum V1WebhookHMACEncoding { + HEX = "HEX", + BASE64 = "BASE64", + BASE64URL = "BASE64URL", +} + +export enum V1WebhookHMACAlgorithm { + SHA1 = "SHA1", + SHA256 = "SHA256", + SHA512 = "SHA512", + MD5 = "MD5", +} + +export enum V1WebhookAuthType { + BASIC = "BASIC", + API_KEY = "API_KEY", + HMAC = "HMAC", +} + +export enum V1WebhookSourceName { + GENERIC = "GENERIC", + GITHUB = "GITHUB", + STRIPE = "STRIPE", +} + export enum TenantUIVersion { V0 = "V0", V1 = "V1", @@ -792,6 +817,8 @@ export interface V1Event { seenAt?: string; /** The external IDs of the runs that were triggered by this event. */ triggeredRuns?: V1EventTriggeredRun[]; + /** The name of the webhook that triggered this event, if applicable. */ + triggeringWebhookName?: string; } export interface V1EventList { @@ -853,6 +880,82 @@ export interface V1UpdateFilterRequest { payload?: object; } +export interface V1Webhook { + metadata: APIResourceMeta; + /** The ID of the tenant associated with this webhook. */ + tenantId: string; + /** The name of the webhook */ + name: string; + /** The name of the source for this webhook */ + sourceName: V1WebhookSourceName; + /** The CEL expression to use for the event key. This is used to create the event key from the webhook payload. */ + eventKeyExpression: string; + /** The type of authentication to use for the webhook */ + authType: V1WebhookAuthType; +} + +export interface V1WebhookList { + pagination?: PaginationResponse; + rows?: V1Webhook[]; +} + +export interface V1CreateWebhookRequestBase { + /** The name of the source for this webhook */ + sourceName: V1WebhookSourceName; + /** The name of the webhook */ + name: string; + /** The CEL expression to use for the event key. This is used to create the event key from the webhook payload. */ + eventKeyExpression: string; +} + +export interface V1WebhookBasicAuth { + /** The username for basic auth */ + username: string; + /** The password for basic auth */ + password: string; +} + +export type V1CreateWebhookRequestBasicAuth = V1CreateWebhookRequestBase & { + /** The type of authentication to use for the webhook */ + authType: "BASIC"; + auth: V1WebhookBasicAuth; +}; + +export interface V1WebhookAPIKeyAuth { + /** The name of the header to use for the API key */ + headerName: string; + /** The API key to use for authentication */ + apiKey: string; +} + +export type V1CreateWebhookRequestAPIKey = V1CreateWebhookRequestBase & { + /** The type of authentication to use for the webhook */ + authType: "API_KEY"; + auth: V1WebhookAPIKeyAuth; +}; + +export interface V1WebhookHMACAuth { + /** The HMAC algorithm to use for the webhook */ + algorithm: V1WebhookHMACAlgorithm; + /** The encoding to use for the HMAC signature */ + encoding: V1WebhookHMACEncoding; + /** The name of the header to use for the HMAC signature */ + signatureHeaderName: string; + /** The secret key used to sign the HMAC signature */ + signingSecret: string; +} + +export type V1CreateWebhookRequestHMAC = V1CreateWebhookRequestBase & { + /** The type of authentication to use for the webhook */ + authType: "HMAC"; + auth: V1WebhookHMACAuth; +}; + +export type V1CreateWebhookRequest = + | V1CreateWebhookRequestBasicAuth + | V1CreateWebhookRequestAPIKey + | V1CreateWebhookRequestHMAC; + export interface V1CELDebugRequest { /** The CEL expression to evaluate */ expression: string; diff --git a/frontend/app/src/lib/api/queries.ts b/frontend/app/src/lib/api/queries.ts index bf1c6e0bd..a123e0e49 100644 --- a/frontend/app/src/lib/api/queries.ts +++ b/frontend/app/src/lib/api/queries.ts @@ -24,6 +24,7 @@ type V2TaskGetPointMetricsQuery = Parameters< typeof api.v1TaskGetPointMetrics >[1]; type GetTaskMetricsQuery = Parameters[1]; +type ListWebhooksQuery = Parameters[1]; export const queries = createQueryKeyStore({ cloud: { @@ -256,6 +257,12 @@ export const queries = createQueryKeyStore({ (await api.workflowRunListStepRunEvents(tenantId, workflowRun)).data, }), }, + v1Webhooks: { + list: (tenant: string, params?: ListWebhooksQuery | undefined) => ({ + queryKey: ['v1:webhook:list', tenant], + queryFn: async () => (await api.v1WebhookList(tenant, params)).data, + }), + }, v1Events: { list: (tenant: string, query: V1EventListQuery) => ({ queryKey: ['v1:events:list', tenant, query], diff --git a/frontend/app/src/lib/breadcrumbs.ts b/frontend/app/src/lib/breadcrumbs.ts index d2028e1d3..98891dd99 100644 --- a/frontend/app/src/lib/breadcrumbs.ts +++ b/frontend/app/src/lib/breadcrumbs.ts @@ -10,6 +10,8 @@ const createRouteLabel = (path: TenantedPath): string => { switch (path) { case '/tenants/:tenant/events': return 'Events'; + case '/tenants/:tenant/webhooks': + return 'Webhooks'; case '/tenants/:tenant/rate-limits': return 'Rate Limits'; case '/tenants/:tenant/scheduled': diff --git a/frontend/app/src/next/lib/docs/generated/snips/python/index.ts b/frontend/app/src/next/lib/docs/generated/snips/python/index.ts index b9af0e479..fdc204ae7 100644 --- a/frontend/app/src/next/lib/docs/generated/snips/python/index.ts +++ b/frontend/app/src/next/lib/docs/generated/snips/python/index.ts @@ -43,6 +43,7 @@ import * as sticky_workers from './sticky_workers'; import * as streaming from './streaming'; import * as timeout from './timeout'; import * as unit_testing from './unit_testing'; +import * as webhooks from './webhooks'; import * as worker_existing_loop from './worker_existing_loop'; import * as workflow_registration from './workflow_registration'; @@ -91,5 +92,6 @@ export { sticky_workers }; export { streaming }; export { timeout }; export { unit_testing }; +export { webhooks }; export { worker_existing_loop }; export { workflow_registration }; diff --git a/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/index.ts b/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/index.ts new file mode 100644 index 000000000..8a1e07903 --- /dev/null +++ b/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/index.ts @@ -0,0 +1,5 @@ +import test_webhooks from './test_webhooks'; +import worker from './worker'; + +export { test_webhooks }; +export { worker }; diff --git a/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/test_webhooks.ts b/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/test_webhooks.ts new file mode 100644 index 000000000..50e2f64b0 --- /dev/null +++ b/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/test_webhooks.ts @@ -0,0 +1,12 @@ +import { Snippet } from '@/next/lib/docs/generated/snips/types'; + +const snippet: Snippet = { + language: 'python', + content: + 'import asyncio\nimport base64\nimport hashlib\nimport hmac\nimport json\nfrom collections.abc import AsyncGenerator\nfrom contextlib import asynccontextmanager\nfrom datetime import datetime, timezone\nfrom typing import Any\nfrom uuid import uuid4\n\nimport aiohttp\nimport pytest\n\nfrom examples.webhooks.worker import WebhookInput\nfrom hatchet_sdk import Hatchet\nfrom hatchet_sdk.clients.rest.api.webhook_api import WebhookApi\nfrom hatchet_sdk.clients.rest.models.v1_create_webhook_request import (\n V1CreateWebhookRequest,\n)\nfrom hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import (\n V1CreateWebhookRequestAPIKey,\n)\nfrom hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import (\n V1CreateWebhookRequestBasicAuth,\n)\nfrom hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import (\n V1CreateWebhookRequestHMAC,\n)\nfrom hatchet_sdk.clients.rest.models.v1_event import V1Event\nfrom hatchet_sdk.clients.rest.models.v1_task_status import V1TaskStatus\nfrom hatchet_sdk.clients.rest.models.v1_task_summary import V1TaskSummary\nfrom hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook\nfrom hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth\nfrom hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth\nfrom hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import (\n V1WebhookHMACAlgorithm,\n)\nfrom hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth\nfrom hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import (\n V1WebhookHMACEncoding,\n)\nfrom hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName\n\nTEST_BASIC_USERNAME = "test_user"\nTEST_BASIC_PASSWORD = "test_password"\nTEST_API_KEY_HEADER = "X-API-Key"\nTEST_API_KEY_VALUE = "test_api_key_123"\nTEST_HMAC_SIGNATURE_HEADER = "X-Signature"\nTEST_HMAC_SECRET = "test_hmac_secret"\n\n\n@pytest.fixture\ndef webhook_body() -> WebhookInput:\n return WebhookInput(type="test", message="Hello, world!")\n\n\n@pytest.fixture\ndef test_run_id() -> str:\n return str(uuid4())\n\n\n@pytest.fixture\ndef test_start() -> datetime:\n return datetime.now(timezone.utc)\n\n\ndef create_hmac_signature(\n payload: bytes,\n secret: str,\n algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256,\n encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX,\n) -> str:\n algorithm_map = {\n V1WebhookHMACAlgorithm.SHA1: hashlib.sha1,\n V1WebhookHMACAlgorithm.SHA256: hashlib.sha256,\n V1WebhookHMACAlgorithm.SHA512: hashlib.sha512,\n V1WebhookHMACAlgorithm.MD5: hashlib.md5,\n }\n\n hash_func = algorithm_map[algorithm]\n signature = hmac.new(secret.encode(), payload, hash_func).digest()\n\n if encoding == V1WebhookHMACEncoding.HEX:\n return signature.hex()\n if encoding == V1WebhookHMACEncoding.BASE64:\n return base64.b64encode(signature).decode()\n if encoding == V1WebhookHMACEncoding.BASE64URL:\n return base64.urlsafe_b64encode(signature).decode()\n\n raise ValueError(f"Unsupported encoding: {encoding}")\n\n\nasync def send_webhook_request(\n url: str,\n body: WebhookInput,\n auth_type: str,\n auth_data: dict[str, Any] | None = None,\n headers: dict[str, str] | None = None,\n) -> aiohttp.ClientResponse:\n request_headers = headers or {}\n auth = None\n\n if auth_type == "BASIC" and auth_data:\n auth = aiohttp.BasicAuth(auth_data["username"], auth_data["password"])\n elif auth_type == "API_KEY" and auth_data:\n request_headers[auth_data["header_name"]] = auth_data["api_key"]\n elif auth_type == "HMAC" and auth_data:\n payload = json.dumps(body.model_dump()).encode()\n signature = create_hmac_signature(\n payload,\n auth_data["secret"],\n auth_data.get("algorithm", V1WebhookHMACAlgorithm.SHA256),\n auth_data.get("encoding", V1WebhookHMACEncoding.HEX),\n )\n request_headers[auth_data["header_name"]] = signature\n\n async with aiohttp.ClientSession() as session:\n return await session.post(\n url, json=body.model_dump(), auth=auth, headers=request_headers\n )\n\n\nasync def wait_for_event(\n hatchet: Hatchet,\n webhook_name: str,\n test_start: datetime,\n) -> V1Event | None:\n await asyncio.sleep(5)\n\n events = await hatchet.event.aio_list(since=test_start)\n\n if events.rows is None:\n return None\n\n return next(\n (\n event\n for event in events.rows\n if event.triggering_webhook_name == webhook_name\n ),\n None,\n )\n\n\nasync def wait_for_workflow_run(\n hatchet: Hatchet, event_id: str, test_start: datetime\n) -> V1TaskSummary | None:\n await asyncio.sleep(5)\n\n runs = await hatchet.runs.aio_list(\n since=test_start,\n additional_metadata={\n "hatchet__event_id": event_id,\n },\n )\n\n if len(runs.rows) == 0:\n return None\n\n return runs.rows[0]\n\n\n@asynccontextmanager\nasync def basic_auth_webhook(\n hatchet: Hatchet,\n test_run_id: str,\n username: str = TEST_BASIC_USERNAME,\n password: str = TEST_BASIC_PASSWORD,\n source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,\n) -> AsyncGenerator[V1Webhook, None]:\n ## Hack to get the API client\n client = hatchet.metrics.client()\n webhook_api = WebhookApi(client)\n\n webhook_request = V1CreateWebhookRequestBasicAuth(\n sourceName=source_name,\n name=f"test-webhook-basic-{test_run_id}",\n eventKeyExpression=f"\'{hatchet.config.apply_namespace(\'webhook\')}:\' + input.type",\n authType="BASIC",\n auth=V1WebhookBasicAuth(\n username=username,\n password=password,\n ),\n )\n\n incoming_webhook = webhook_api.v1_webhook_create(\n tenant=hatchet.tenant_id,\n v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),\n )\n\n try:\n yield incoming_webhook\n finally:\n webhook_api.v1_webhook_delete(\n tenant=hatchet.tenant_id,\n v1_webhook=incoming_webhook.name,\n )\n\n\n@asynccontextmanager\nasync def api_key_webhook(\n hatchet: Hatchet,\n test_run_id: str,\n header_name: str = TEST_API_KEY_HEADER,\n api_key: str = TEST_API_KEY_VALUE,\n source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,\n) -> AsyncGenerator[V1Webhook, None]:\n client = hatchet.metrics.client()\n webhook_api = WebhookApi(client)\n\n webhook_request = V1CreateWebhookRequestAPIKey(\n sourceName=source_name,\n name=f"test-webhook-apikey-{test_run_id}",\n eventKeyExpression=f"\'{hatchet.config.apply_namespace(\'webhook\')}:\' + input.type",\n authType="API_KEY",\n auth=V1WebhookAPIKeyAuth(\n headerName=header_name,\n apiKey=api_key,\n ),\n )\n\n incoming_webhook = webhook_api.v1_webhook_create(\n tenant=hatchet.tenant_id,\n v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),\n )\n\n try:\n yield incoming_webhook\n finally:\n webhook_api.v1_webhook_delete(\n tenant=hatchet.tenant_id,\n v1_webhook=incoming_webhook.name,\n )\n\n\n@asynccontextmanager\nasync def hmac_webhook(\n hatchet: Hatchet,\n test_run_id: str,\n signature_header_name: str = TEST_HMAC_SIGNATURE_HEADER,\n signing_secret: str = TEST_HMAC_SECRET,\n algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256,\n encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX,\n source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,\n) -> AsyncGenerator[V1Webhook, None]:\n client = hatchet.metrics.client()\n webhook_api = WebhookApi(client)\n\n webhook_request = V1CreateWebhookRequestHMAC(\n sourceName=source_name,\n name=f"test-webhook-hmac-{test_run_id}",\n eventKeyExpression=f"\'{hatchet.config.apply_namespace(\'webhook\')}:\' + input.type",\n authType="HMAC",\n auth=V1WebhookHMACAuth(\n algorithm=algorithm,\n encoding=encoding,\n signatureHeaderName=signature_header_name,\n signingSecret=signing_secret,\n ),\n )\n\n incoming_webhook = webhook_api.v1_webhook_create(\n tenant=hatchet.tenant_id,\n v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),\n )\n\n try:\n yield incoming_webhook\n finally:\n webhook_api.v1_webhook_delete(\n tenant=hatchet.tenant_id,\n v1_webhook=incoming_webhook.name,\n )\n\n\ndef url(tenant_id: str, webhook_name: str) -> str:\n return f"http://localhost:8080/api/v1/stable/tenants/{tenant_id}/webhooks/{webhook_name}"\n\n\nasync def assert_has_runs(\n hatchet: Hatchet,\n test_start: datetime,\n webhook_body: WebhookInput,\n incoming_webhook: V1Webhook,\n) -> None:\n triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start)\n assert triggered_event is not None\n assert (\n triggered_event.key\n == f"{hatchet.config.apply_namespace(\'webhook\')}:{webhook_body.type}"\n )\n assert triggered_event.payload == webhook_body.model_dump()\n\n workflow_run = await wait_for_workflow_run(\n hatchet, triggered_event.metadata.id, test_start\n )\n assert workflow_run is not None\n assert workflow_run.status == V1TaskStatus.COMPLETED\n assert workflow_run.additional_metadata is not None\n\n assert (\n workflow_run.additional_metadata["hatchet__event_id"]\n == triggered_event.metadata.id\n )\n assert workflow_run.additional_metadata["hatchet__event_key"] == triggered_event.key\n assert workflow_run.status == V1TaskStatus.COMPLETED\n\n\nasync def assert_event_not_created(\n hatchet: Hatchet,\n test_start: datetime,\n incoming_webhook: V1Webhook,\n) -> None:\n triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start)\n assert triggered_event is None\n\n\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_basic_auth_success(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n "BASIC",\n {"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD},\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {"message": "ok"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n "username,password",\n [\n ("test_user", "incorrect_password"),\n ("incorrect_user", "test_password"),\n ("incorrect_user", "incorrect_password"),\n ("", ""),\n ],\n)\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_basic_auth_failure(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n username: str,\n password: str,\n) -> None:\n """Test basic authentication failures."""\n async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n "BASIC",\n {"username": username, "password": password},\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_basic_auth_missing_credentials(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_api_key_success(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n "API_KEY",\n {"header_name": TEST_API_KEY_HEADER, "api_key": TEST_API_KEY_VALUE},\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {"message": "ok"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n "api_key",\n [\n "incorrect_api_key",\n "",\n "partial_key",\n ],\n)\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_api_key_failure(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n api_key: str,\n) -> None:\n async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n "API_KEY",\n {"header_name": TEST_API_KEY_HEADER, "api_key": api_key},\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_api_key_missing_header(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_hmac_success(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n "HMAC",\n {\n "header_name": TEST_HMAC_SIGNATURE_HEADER,\n "secret": TEST_HMAC_SECRET,\n "algorithm": V1WebhookHMACAlgorithm.SHA256,\n "encoding": V1WebhookHMACEncoding.HEX,\n },\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {"message": "ok"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n "algorithm,encoding",\n [\n (V1WebhookHMACAlgorithm.SHA1, V1WebhookHMACEncoding.HEX),\n (V1WebhookHMACAlgorithm.SHA256, V1WebhookHMACEncoding.BASE64),\n (V1WebhookHMACAlgorithm.SHA512, V1WebhookHMACEncoding.BASE64URL),\n (V1WebhookHMACAlgorithm.MD5, V1WebhookHMACEncoding.HEX),\n ],\n)\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_hmac_different_algorithms_and_encodings(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n algorithm: V1WebhookHMACAlgorithm,\n encoding: V1WebhookHMACEncoding,\n) -> None:\n async with hmac_webhook(\n hatchet, test_run_id, algorithm=algorithm, encoding=encoding\n ) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n "HMAC",\n {\n "header_name": TEST_HMAC_SIGNATURE_HEADER,\n "secret": TEST_HMAC_SECRET,\n "algorithm": algorithm,\n "encoding": encoding,\n },\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {"message": "ok"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n "secret",\n [\n "incorrect_secret",\n "",\n "partial_secret",\n ],\n)\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_hmac_signature_failure(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n secret: str,\n) -> None:\n async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n "HMAC",\n {\n "header_name": TEST_HMAC_SIGNATURE_HEADER,\n "secret": secret,\n "algorithm": V1WebhookHMACAlgorithm.SHA256,\n "encoding": V1WebhookHMACEncoding.HEX,\n },\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_hmac_missing_signature_header(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE"\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n "source_name",\n [\n V1WebhookSourceName.GENERIC,\n V1WebhookSourceName.GITHUB,\n ],\n)\n@pytest.mark.asyncio(loop_scope="session")\nasync def test_different_source_types(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n source_name: V1WebhookSourceName,\n) -> None:\n async with basic_auth_webhook(\n hatchet, test_run_id, source_name=source_name\n ) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n "BASIC",\n {"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD},\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {"message": "ok"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n', + source: 'out/python/webhooks/test_webhooks.py', + blocks: {}, + highlights: {}, +}; + +export default snippet; diff --git a/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/worker.ts b/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/worker.ts new file mode 100644 index 000000000..9e1b7fefb --- /dev/null +++ b/frontend/app/src/next/lib/docs/generated/snips/python/webhooks/worker.ts @@ -0,0 +1,17 @@ +import { Snippet } from '@/next/lib/docs/generated/snips/types'; + +const snippet: Snippet = { + language: 'python', + content: + '# > Webhooks\n\nfrom pydantic import BaseModel\n\nfrom hatchet_sdk import Context, Hatchet\n\nhatchet = Hatchet(debug=True)\n\n\nclass WebhookInput(BaseModel):\n type: str\n message: str\n\n\n@hatchet.task(input_validator=WebhookInput, on_events=["webhook:test"])\ndef webhook(input: WebhookInput, ctx: Context) -> dict[str, str]:\n return input.model_dump()\n\n\ndef main() -> None:\n worker = hatchet.worker("webhook-worker", workflows=[webhook])\n worker.start()\n\n\n\nif __name__ == "__main__":\n main()\n', + source: 'out/python/webhooks/worker.py', + blocks: { + webhooks: { + start: 2, + stop: 24, + }, + }, + highlights: {}, +}; + +export default snippet; diff --git a/frontend/app/src/next/lib/docs/generated/snips/python/worker.ts b/frontend/app/src/next/lib/docs/generated/snips/python/worker.ts index f4241a481..da0116c95 100644 --- a/frontend/app/src/next/lib/docs/generated/snips/python/worker.ts +++ b/frontend/app/src/next/lib/docs/generated/snips/python/worker.ts @@ -3,7 +3,7 @@ import { Snippet } from '@/next/lib/docs/generated/snips/types'; const snippet: Snippet = { language: 'python', content: - 'from examples.affinity_workers.worker import affinity_worker_workflow\nfrom examples.bulk_fanout.worker import bulk_child_wf, bulk_parent_wf\nfrom examples.bulk_operations.worker import (\n bulk_replay_test_1,\n bulk_replay_test_2,\n bulk_replay_test_3,\n)\nfrom examples.cancellation.worker import cancellation_workflow\nfrom examples.concurrency_limit.worker import concurrency_limit_workflow\nfrom examples.concurrency_limit_rr.worker import concurrency_limit_rr_workflow\nfrom examples.concurrency_multiple_keys.worker import concurrency_multiple_keys_workflow\nfrom examples.concurrency_workflow_level.worker import (\n concurrency_workflow_level_workflow,\n)\nfrom examples.conditions.worker import task_condition_workflow\nfrom examples.dag.worker import dag_workflow\nfrom examples.dedupe.worker import dedupe_child_wf, dedupe_parent_wf\nfrom examples.durable.worker import durable_workflow, wait_for_sleep_twice\nfrom examples.events.worker import event_workflow\nfrom examples.fanout.worker import child_wf, parent_wf\nfrom examples.fanout_sync.worker import sync_fanout_child, sync_fanout_parent\nfrom examples.lifespans.simple import lifespan, lifespan_task\nfrom examples.logger.workflow import logging_workflow\nfrom examples.non_retryable.worker import non_retryable_workflow\nfrom examples.on_failure.worker import on_failure_wf, on_failure_wf_with_details\nfrom examples.return_exceptions.worker import return_exceptions_task\nfrom examples.simple.worker import simple, simple_durable\nfrom examples.timeout.worker import refresh_timeout_wf, timeout_wf\nfrom hatchet_sdk import Hatchet\n\nhatchet = Hatchet(debug=True)\n\n\ndef main() -> None:\n worker = hatchet.worker(\n "e2e-test-worker",\n slots=100,\n workflows=[\n affinity_worker_workflow,\n bulk_child_wf,\n bulk_parent_wf,\n concurrency_limit_workflow,\n concurrency_limit_rr_workflow,\n concurrency_multiple_keys_workflow,\n dag_workflow,\n dedupe_child_wf,\n dedupe_parent_wf,\n durable_workflow,\n child_wf,\n event_workflow,\n parent_wf,\n on_failure_wf,\n on_failure_wf_with_details,\n logging_workflow,\n timeout_wf,\n refresh_timeout_wf,\n task_condition_workflow,\n cancellation_workflow,\n sync_fanout_parent,\n sync_fanout_child,\n non_retryable_workflow,\n concurrency_workflow_level_workflow,\n lifespan_task,\n simple,\n simple_durable,\n bulk_replay_test_1,\n bulk_replay_test_2,\n bulk_replay_test_3,\n return_exceptions_task,\n wait_for_sleep_twice,\n ],\n lifespan=lifespan,\n )\n\n worker.start()\n\n\nif __name__ == "__main__":\n main()\n', + 'from examples.affinity_workers.worker import affinity_worker_workflow\nfrom examples.bulk_fanout.worker import bulk_child_wf, bulk_parent_wf\nfrom examples.bulk_operations.worker import (\n bulk_replay_test_1,\n bulk_replay_test_2,\n bulk_replay_test_3,\n)\nfrom examples.cancellation.worker import cancellation_workflow\nfrom examples.concurrency_limit.worker import concurrency_limit_workflow\nfrom examples.concurrency_limit_rr.worker import concurrency_limit_rr_workflow\nfrom examples.concurrency_multiple_keys.worker import concurrency_multiple_keys_workflow\nfrom examples.concurrency_workflow_level.worker import (\n concurrency_workflow_level_workflow,\n)\nfrom examples.conditions.worker import task_condition_workflow\nfrom examples.dag.worker import dag_workflow\nfrom examples.dedupe.worker import dedupe_child_wf, dedupe_parent_wf\nfrom examples.durable.worker import durable_workflow, wait_for_sleep_twice\nfrom examples.events.worker import event_workflow\nfrom examples.fanout.worker import child_wf, parent_wf\nfrom examples.fanout_sync.worker import sync_fanout_child, sync_fanout_parent\nfrom examples.lifespans.simple import lifespan, lifespan_task\nfrom examples.logger.workflow import logging_workflow\nfrom examples.non_retryable.worker import non_retryable_workflow\nfrom examples.on_failure.worker import on_failure_wf, on_failure_wf_with_details\nfrom examples.return_exceptions.worker import return_exceptions_task\nfrom examples.simple.worker import simple, simple_durable\nfrom examples.timeout.worker import refresh_timeout_wf, timeout_wf\nfrom examples.webhooks.worker import webhook\nfrom hatchet_sdk import Hatchet\n\nhatchet = Hatchet(debug=True)\n\n\ndef main() -> None:\n worker = hatchet.worker(\n "e2e-test-worker",\n slots=100,\n workflows=[\n affinity_worker_workflow,\n bulk_child_wf,\n bulk_parent_wf,\n concurrency_limit_workflow,\n concurrency_limit_rr_workflow,\n concurrency_multiple_keys_workflow,\n dag_workflow,\n dedupe_child_wf,\n dedupe_parent_wf,\n durable_workflow,\n child_wf,\n event_workflow,\n parent_wf,\n on_failure_wf,\n on_failure_wf_with_details,\n logging_workflow,\n timeout_wf,\n refresh_timeout_wf,\n task_condition_workflow,\n cancellation_workflow,\n sync_fanout_parent,\n sync_fanout_child,\n non_retryable_workflow,\n concurrency_workflow_level_workflow,\n lifespan_task,\n simple,\n simple_durable,\n bulk_replay_test_1,\n bulk_replay_test_2,\n bulk_replay_test_3,\n webhook,\n return_exceptions_task,\n wait_for_sleep_twice,\n ],\n lifespan=lifespan,\n )\n\n worker.start()\n\n\nif __name__ == "__main__":\n main()\n', source: 'out/python/worker.py', blocks: {}, highlights: {}, diff --git a/frontend/app/src/pages/main/v1/index.tsx b/frontend/app/src/pages/main/v1/index.tsx index 7b1799eae..e9978a359 100644 --- a/frontend/app/src/pages/main/v1/index.tsx +++ b/frontend/app/src/pages/main/v1/index.tsx @@ -27,7 +27,7 @@ import { import useCloudApiMeta from '@/pages/auth/hooks/use-cloud-api-meta'; import useCloudFeatureFlags from '@/pages/auth/hooks/use-cloud-feature-flags'; import { useSidebar } from '@/components/sidebar-provider'; -import { SquareActivityIcon } from 'lucide-react'; +import { SquareActivityIcon, WebhookIcon } from 'lucide-react'; import { useCurrentTenantId } from '@/hooks/use-tenant'; function Main() { @@ -165,6 +165,13 @@ function Sidebar({ className, memberships }: SidebarProps) { prefix={`/tenants/${tenantId}/workers`} collapsibleChildren={workers} /> + } + /> {featureFlags?.data['managed-worker'] && ( { + switch (authMethod) { + case V1WebhookAuthType.BASIC: + return ( + + + Basic + + ); + case V1WebhookAuthType.API_KEY: + return ( + + + API Key + + ); + case V1WebhookAuthType.HMAC: + return ( + + + HMAC + + ); + + default: + // eslint-disable-next-line no-case-declarations + const exhaustiveCheck: never = authMethod; + throw new Error(`Unhandled auth method: ${exhaustiveCheck}`); + } +}; diff --git a/frontend/app/src/pages/main/v1/webhooks/components/auth-setup.tsx b/frontend/app/src/pages/main/v1/webhooks/components/auth-setup.tsx new file mode 100644 index 000000000..82dd08ffb --- /dev/null +++ b/frontend/app/src/pages/main/v1/webhooks/components/auth-setup.tsx @@ -0,0 +1,251 @@ +import { Input } from '@/components/v1/ui/input'; +import { Label } from '@/components/v1/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/v1/ui/select'; +import { useForm } from 'react-hook-form'; +import { + V1WebhookAuthType, + V1WebhookHMACAlgorithm, + V1WebhookHMACEncoding, + V1WebhookSourceName, +} from '@/lib/api'; +import { WebhookFormData } from '../hooks/use-webhooks'; + +type BaseAuthMethodProps = { + register: ReturnType>['register']; +}; + +type HMACAuthProps = BaseAuthMethodProps & { + watch: ReturnType>['watch']; + setValue: ReturnType>['setValue']; +}; + +const BasicAuth = ({ register }: BaseAuthMethodProps) => ( +
+
+ + +
+ +
+ +
+ +
+
+
+); + +const APIKeyAuth = ({ register }: BaseAuthMethodProps) => ( +
+
+ + +
+ +
+ +
+ +
+
+
+); + +const HMACAuth = ({ register, watch, setValue }: HMACAuthProps) => ( +
+
+ +
+ +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+); + +const StripeAuth = ({ register }: BaseAuthMethodProps) => ( + // Stripe only requires a secret, as we know the header key and the encoding info (user doesn't need to provide them) + // See docs: https://docs.stripe.com/webhooks?verify=verify-manually#verify-manually +
+
+ +
+ +
+
+
+); + +const GithubAuth = ({ register }: BaseAuthMethodProps) => ( + // Github only requires a secret, as we know the header key and the encoding info (user doesn't need to provide them) + // See docs: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries#validating-webhook-deliveries +
+
+ +
+ +
+
+
+); + +export const AuthSetup = ({ + authMethod, + sourceName, + register, + watch, + setValue, +}: HMACAuthProps & { + authMethod: V1WebhookAuthType; + sourceName: V1WebhookSourceName; +}) => { + switch (sourceName) { + case V1WebhookSourceName.GENERIC: + switch (authMethod) { + case V1WebhookAuthType.BASIC: + return ; + case V1WebhookAuthType.API_KEY: + return ; + case V1WebhookAuthType.HMAC: + return ( + + ); + default: + // eslint-disable-next-line no-case-declarations + const exhaustiveCheck: never = authMethod; + throw new Error(`Unhandled auth method: ${exhaustiveCheck}`); + } + case V1WebhookSourceName.GITHUB: + return ; + case V1WebhookSourceName.STRIPE: + return ; + default: + // eslint-disable-next-line no-case-declarations + const exhaustiveCheck: never = sourceName; + throw new Error(`Unhandled source name: ${exhaustiveCheck}`); + } +}; diff --git a/frontend/app/src/pages/main/v1/webhooks/components/source-name.tsx b/frontend/app/src/pages/main/v1/webhooks/components/source-name.tsx new file mode 100644 index 000000000..6e6fe961a --- /dev/null +++ b/frontend/app/src/pages/main/v1/webhooks/components/source-name.tsx @@ -0,0 +1,39 @@ +import { V1WebhookSourceName } from '@/lib/api'; +import { GitHubLogoIcon } from '@radix-ui/react-icons'; +import { Webhook } from 'lucide-react'; +import { FaStripeS } from 'react-icons/fa'; + +export const SourceName = ({ + sourceName, +}: { + sourceName: V1WebhookSourceName; +}) => { + switch (sourceName) { + case V1WebhookSourceName.GENERIC: + return ( + + + Generic + + ); + case V1WebhookSourceName.GITHUB: + return ( + + + GitHub + + ); + case V1WebhookSourceName.STRIPE: + return ( + + + Stripe + + ); + + default: + // eslint-disable-next-line no-case-declarations + const exhaustiveCheck: never = sourceName; + throw new Error(`Unhandled source: ${exhaustiveCheck}`); + } +}; diff --git a/frontend/app/src/pages/main/v1/webhooks/components/webhook-columns.tsx b/frontend/app/src/pages/main/v1/webhooks/components/webhook-columns.tsx new file mode 100644 index 000000000..705b76fbe --- /dev/null +++ b/frontend/app/src/pages/main/v1/webhooks/components/webhook-columns.tsx @@ -0,0 +1,139 @@ +import { ColumnDef, Row } from '@tanstack/react-table'; +import { V1Webhook } from '@/lib/api'; +import { DataTableColumnHeader } from '@/components/v1/molecules/data-table/data-table-column-header'; +import { DotsVerticalIcon } from '@radix-ui/react-icons'; +import { Check, Copy, Loader, Trash2 } from 'lucide-react'; +import { Button } from '@/components/v1/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/v1/ui/dropdown-menu'; +import { useState } from 'react'; +import { useWebhooks } from '../hooks/use-webhooks'; +import { SourceName } from './source-name'; +import { AuthMethod } from './auth-method'; + +export const columns = (): ColumnDef[] => { + return [ + { + accessorKey: 'name', + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.name}
, + enableSorting: false, + enableHiding: true, + }, + { + accessorKey: 'sourceName', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ +
+ ), + enableSorting: false, + enableHiding: true, + }, + { + accessorKey: 'expression', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.eventKeyExpression} + + ), + enableSorting: false, + enableHiding: true, + }, + { + accessorKey: 'authType', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ +
+ ), + enableSorting: false, + enableHiding: true, + }, + { + accessorKey: 'actions', + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableSorting: false, + enableHiding: true, + }, + ]; +}; + +const WebhookActionsCell = ({ row }: { row: Row }) => { + const { mutations, createWebhookURL } = useWebhooks(() => + setIsDropdownOpen(false), + ); + + const [isCopied, setIsCopied] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const webhookUrl = createWebhookURL(row.original.name); + + const handleCopy = (url: string) => { + navigator.clipboard.writeText(url); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 1000); + setTimeout(() => setIsDropdownOpen(false), 400); + }; + + return ( + + + + + + { + e.stopPropagation(); + e.preventDefault(); + handleCopy(webhookUrl); + }} + > + {isCopied ? ( + + ) : ( + + )} + Copy Webhook URL + + { + e.stopPropagation(); + e.preventDefault(); + mutations.deleteWebhook({ webhookName: row.original.name }); + }} + disabled={mutations.isDeletePending} + > + {mutations.isDeletePending ? ( + + ) : ( + + )} + Delete + + + + ); +}; diff --git a/frontend/app/src/pages/main/v1/webhooks/hooks/use-webhooks.tsx b/frontend/app/src/pages/main/v1/webhooks/hooks/use-webhooks.tsx new file mode 100644 index 000000000..3223053db --- /dev/null +++ b/frontend/app/src/pages/main/v1/webhooks/hooks/use-webhooks.tsx @@ -0,0 +1,80 @@ +import { z } from 'zod'; +import { useCurrentTenantId } from '@/hooks/use-tenant'; +import api, { + queries, + V1CreateWebhookRequest, + V1WebhookAuthType, + V1WebhookHMACAlgorithm, + V1WebhookHMACEncoding, + V1WebhookSourceName, +} from '@/lib/api'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +export const useWebhooks = (onDeleteSuccess?: () => void) => { + const queryClient = useQueryClient(); + const { tenantId } = useCurrentTenantId(); + + const { data, isLoading, error } = useQuery({ + ...queries.v1Webhooks.list(tenantId), + }); + + const { mutate: deleteWebhook, isPending: isDeletePending } = useMutation({ + mutationFn: async ({ webhookName }: { webhookName: string }) => + api.v1WebhookDelete(tenantId, webhookName), + onSuccess: async () => { + if (onDeleteSuccess) { + onDeleteSuccess(); + } + + const queryKey = queries.v1Webhooks.list(tenantId).queryKey; + await queryClient.invalidateQueries({ + queryKey, + }); + }, + }); + + const { mutate: createWebhook, isPending: isCreatePending } = useMutation({ + mutationFn: async (webhookData: V1CreateWebhookRequest) => + api.v1WebhookCreate(tenantId, webhookData), + onSuccess: async () => { + const queryKey = queries.v1Webhooks.list(tenantId).queryKey; + await queryClient.invalidateQueries({ + queryKey, + }); + }, + }); + + const createWebhookURL = (name: string) => { + return `${window.location.protocol}//${window.location.hostname}/api/v1/stable/tenants/${tenantId}/webhooks/${name}`; + }; + + return { + data: data?.rows || [], + isLoading, + error, + createWebhookURL, + mutations: { + deleteWebhook, + isDeletePending, + createWebhook, + isCreatePending, + }, + }; +}; + +export const webhookFormSchema = z.object({ + sourceName: z.nativeEnum(V1WebhookSourceName), + name: z.string().min(1, 'Name expression is required'), + eventKeyExpression: z.string().min(1, 'Event key expression is required'), + authType: z.nativeEnum(V1WebhookAuthType), + username: z.string().optional(), + password: z.string().optional(), + headerName: z.string().optional(), + apiKey: z.string().optional(), + signingSecret: z.string().optional(), + algorithm: z.nativeEnum(V1WebhookHMACAlgorithm).optional(), + encoding: z.nativeEnum(V1WebhookHMACEncoding).optional(), + signatureHeaderName: z.string().optional(), +}); + +export type WebhookFormData = z.infer; diff --git a/frontend/app/src/pages/main/v1/webhooks/index.tsx b/frontend/app/src/pages/main/v1/webhooks/index.tsx new file mode 100644 index 000000000..d8db81798 --- /dev/null +++ b/frontend/app/src/pages/main/v1/webhooks/index.tsx @@ -0,0 +1,401 @@ +import { columns } from './components/webhook-columns'; +import { DataTable } from '@/components/v1/molecules/data-table/data-table'; +import { + useWebhooks, + WebhookFormData, + webhookFormSchema, +} from './hooks/use-webhooks'; +import { Button } from '@/components/v1/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/v1/ui/dialog'; +import { Input } from '@/components/v1/ui/input'; +import { Label } from '@/components/v1/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/v1/ui/select'; +import { useCallback, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + V1WebhookSourceName, + V1WebhookAuthType, + V1CreateWebhookRequest, + V1WebhookHMACAlgorithm, + V1WebhookHMACEncoding, +} from '@/lib/api'; +import { Webhook, Copy, Check } from 'lucide-react'; +import { Spinner } from '@/components/v1/ui/loading'; +import { SourceName } from './components/source-name'; +import { AuthMethod } from './components/auth-method'; +import { AuthSetup } from './components/auth-setup'; + +export default function Webhooks() { + const { data, isLoading, error } = useWebhooks(); + + return ( +
+
+ +
+ +
+ ); +} + +const buildWebhookPayload = (data: WebhookFormData): V1CreateWebhookRequest => { + switch (data.sourceName) { + case V1WebhookSourceName.GENERIC: + switch (data.authType) { + case V1WebhookAuthType.BASIC: + if (!data.username || !data.password) { + throw new Error( + 'Username and password are required for basic auth', + ); + } + + return { + sourceName: data.sourceName, + name: data.name, + eventKeyExpression: data.eventKeyExpression, + authType: data.authType, + auth: { + username: data.username, + password: data.password, + }, + }; + case V1WebhookAuthType.API_KEY: + if (!data.headerName || !data.apiKey) { + throw new Error( + 'Header name and API key are required for API key auth', + ); + } + + return { + sourceName: data.sourceName, + name: data.name, + eventKeyExpression: data.eventKeyExpression, + authType: data.authType, + auth: { + headerName: data.headerName, + apiKey: data.apiKey, + }, + }; + case V1WebhookAuthType.HMAC: + if ( + !data.algorithm || + !data.encoding || + !data.signatureHeaderName || + !data.signingSecret + ) { + throw new Error( + 'Algorithm, encoding, signature header name, and signing secret are required for HMAC auth', + ); + } + + return { + sourceName: data.sourceName, + name: data.name, + eventKeyExpression: data.eventKeyExpression, + authType: data.authType, + auth: { + algorithm: data.algorithm, + encoding: data.encoding, + signatureHeaderName: data.signatureHeaderName, + signingSecret: data.signingSecret, + }, + }; + default: + // eslint-disable-next-line no-case-declarations + const exhaustiveCheck: never = data.authType; + throw new Error(`Unhandled auth type: ${exhaustiveCheck}`); + } + case V1WebhookSourceName.GITHUB: + if (!data.signingSecret) { + throw new Error('Signing secret is required for GitHub webhooks'); + } + + return { + sourceName: data.sourceName, + name: data.name, + eventKeyExpression: data.eventKeyExpression, + authType: V1WebhookAuthType.HMAC, + auth: { + // Header name is 'X-Hub-Signature-256' + // Encoding algorithm is SHA256 + // Encoding type is HEX + // See GitHub docs: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries#validating-webhook-deliveries + algorithm: V1WebhookHMACAlgorithm.SHA256, + encoding: V1WebhookHMACEncoding.HEX, + signatureHeaderName: 'X-Hub-Signature-256', + signingSecret: data.signingSecret, + }, + }; + case V1WebhookSourceName.STRIPE: + if (!data.signingSecret) { + throw new Error('Signing secret is required for GitHub webhooks'); + } + + return { + sourceName: data.sourceName, + name: data.name, + eventKeyExpression: data.eventKeyExpression, + authType: V1WebhookAuthType.HMAC, + auth: { + // Header name is 'Stripe-Signature' + // Encoding algorithm is SHA256 + // Encoding type is HEX + // See Stripe docs: https://docs.stripe.com/webhooks?verify=verify-manually#verify-manually + algorithm: V1WebhookHMACAlgorithm.SHA256, + encoding: V1WebhookHMACEncoding.HEX, + signatureHeaderName: 'Stripe-Signature', + signingSecret: data.signingSecret, + }, + }; + default: + // eslint-disable-next-line no-case-declarations + const exhaustiveCheck: never = data.sourceName; + throw new Error(`Unhandled source name: ${exhaustiveCheck}`); + } +}; + +const CreateWebhookModal = () => { + const { mutations, createWebhookURL } = useWebhooks(); + const { createWebhook, isCreatePending } = mutations; + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const { + register, + handleSubmit, + watch, + setValue, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(webhookFormSchema), + defaultValues: { + sourceName: V1WebhookSourceName.GENERIC, + authType: V1WebhookAuthType.BASIC, + name: '', + eventKeyExpression: 'input.id', + username: '', + password: '', + }, + }); + + const sourceName = watch('sourceName'); + const authType = watch('authType'); + const webhookName = watch('name'); + + const copyToClipboard = useCallback(async () => { + if (webhookName) { + try { + await navigator.clipboard.writeText(createWebhookURL(webhookName)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy URL:', err); + } + } + }, [webhookName, createWebhookURL]); + + const onSubmit = useCallback( + (data: WebhookFormData) => { + const payload = buildWebhookPayload(data); + + createWebhook(payload, { + onSuccess: () => { + setOpen(false); + reset(); + }, + }); + }, + [createWebhook, reset], + ); + + return ( + { + setOpen(isOpen); + + if (!isOpen) { + reset(); + setCopied(false); + } + }} + > + + + + + + +
+ +
+ Create a webhook +
+
+ +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ Send incoming webhook requests to: +
+ + {createWebhookURL(webhookName)} + + +
+
+
+ +
+ + +

+ Represents the producer of your HTTP requests. +

+
+ +
+ + + {errors.eventKeyExpression && ( +

+ {errors.eventKeyExpression.message} +

+ )} +

+ CEL expression to extract the event key from the webhook payload. + Use `input` to refer to the payload. +

+
+ +
+
+ {sourceName === V1WebhookSourceName.GENERIC && ( +
+ + +
+ )} + + +
+
+ +
+ + + + +
+
+
+
+ ); +}; diff --git a/frontend/app/src/router.tsx b/frontend/app/src/router.tsx index 23d291362..4ee72ec1e 100644 --- a/frontend/app/src/router.tsx +++ b/frontend/app/src/router.tsx @@ -10,6 +10,7 @@ import Root from './pages/root.tsx'; export const tenantedPaths = [ '/tenants/:tenant/events', + '/tenants/:tenant/webhooks', '/tenants/:tenant/rate-limits', '/tenants/:tenant/scheduled', '/tenants/:tenant/cron-jobs', @@ -56,6 +57,16 @@ const createTenantedRoute = (path: TenantedPath): RouteObject => { }; }), }; + case '/tenants/:tenant/webhooks': + return { + path, + lazy: async () => + import('./pages/main/v1/webhooks').then((res) => { + return { + Component: res.default, + }; + }), + }; case '/tenants/:tenant/rate-limits': return { path, diff --git a/frontend/docs/lib/generated/snips/python/index.ts b/frontend/docs/lib/generated/snips/python/index.ts index c31d5dc1a..099d890e9 100644 --- a/frontend/docs/lib/generated/snips/python/index.ts +++ b/frontend/docs/lib/generated/snips/python/index.ts @@ -43,6 +43,7 @@ import * as sticky_workers from './sticky_workers'; import * as streaming from './streaming'; import * as timeout from './timeout'; import * as unit_testing from './unit_testing'; +import * as webhooks from './webhooks'; import * as worker_existing_loop from './worker_existing_loop'; import * as workflow_registration from './workflow_registration'; @@ -91,5 +92,6 @@ export { sticky_workers }; export { streaming }; export { timeout }; export { unit_testing }; +export { webhooks }; export { worker_existing_loop }; export { workflow_registration }; diff --git a/frontend/docs/lib/generated/snips/python/webhooks/index.ts b/frontend/docs/lib/generated/snips/python/webhooks/index.ts new file mode 100644 index 000000000..a43e7cad0 --- /dev/null +++ b/frontend/docs/lib/generated/snips/python/webhooks/index.ts @@ -0,0 +1,5 @@ +import test_webhooks from './test_webhooks'; +import worker from './worker'; + +export { test_webhooks } +export { worker } diff --git a/frontend/docs/lib/generated/snips/python/webhooks/test_webhooks.ts b/frontend/docs/lib/generated/snips/python/webhooks/test_webhooks.ts new file mode 100644 index 000000000..7743b1df9 --- /dev/null +++ b/frontend/docs/lib/generated/snips/python/webhooks/test_webhooks.ts @@ -0,0 +1,11 @@ +import { Snippet } from '@/lib/generated/snips/types'; + +const snippet: Snippet = { + "language": "python", + "content": "import asyncio\nimport base64\nimport hashlib\nimport hmac\nimport json\nfrom collections.abc import AsyncGenerator\nfrom contextlib import asynccontextmanager\nfrom datetime import datetime, timezone\nfrom typing import Any\nfrom uuid import uuid4\n\nimport aiohttp\nimport pytest\n\nfrom examples.webhooks.worker import WebhookInput\nfrom hatchet_sdk import Hatchet\nfrom hatchet_sdk.clients.rest.api.webhook_api import WebhookApi\nfrom hatchet_sdk.clients.rest.models.v1_create_webhook_request import (\n V1CreateWebhookRequest,\n)\nfrom hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import (\n V1CreateWebhookRequestAPIKey,\n)\nfrom hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import (\n V1CreateWebhookRequestBasicAuth,\n)\nfrom hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import (\n V1CreateWebhookRequestHMAC,\n)\nfrom hatchet_sdk.clients.rest.models.v1_event import V1Event\nfrom hatchet_sdk.clients.rest.models.v1_task_status import V1TaskStatus\nfrom hatchet_sdk.clients.rest.models.v1_task_summary import V1TaskSummary\nfrom hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook\nfrom hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth\nfrom hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth\nfrom hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import (\n V1WebhookHMACAlgorithm,\n)\nfrom hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth\nfrom hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import (\n V1WebhookHMACEncoding,\n)\nfrom hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName\n\nTEST_BASIC_USERNAME = \"test_user\"\nTEST_BASIC_PASSWORD = \"test_password\"\nTEST_API_KEY_HEADER = \"X-API-Key\"\nTEST_API_KEY_VALUE = \"test_api_key_123\"\nTEST_HMAC_SIGNATURE_HEADER = \"X-Signature\"\nTEST_HMAC_SECRET = \"test_hmac_secret\"\n\n\n@pytest.fixture\ndef webhook_body() -> WebhookInput:\n return WebhookInput(type=\"test\", message=\"Hello, world!\")\n\n\n@pytest.fixture\ndef test_run_id() -> str:\n return str(uuid4())\n\n\n@pytest.fixture\ndef test_start() -> datetime:\n return datetime.now(timezone.utc)\n\n\ndef create_hmac_signature(\n payload: bytes,\n secret: str,\n algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256,\n encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX,\n) -> str:\n algorithm_map = {\n V1WebhookHMACAlgorithm.SHA1: hashlib.sha1,\n V1WebhookHMACAlgorithm.SHA256: hashlib.sha256,\n V1WebhookHMACAlgorithm.SHA512: hashlib.sha512,\n V1WebhookHMACAlgorithm.MD5: hashlib.md5,\n }\n\n hash_func = algorithm_map[algorithm]\n signature = hmac.new(secret.encode(), payload, hash_func).digest()\n\n if encoding == V1WebhookHMACEncoding.HEX:\n return signature.hex()\n if encoding == V1WebhookHMACEncoding.BASE64:\n return base64.b64encode(signature).decode()\n if encoding == V1WebhookHMACEncoding.BASE64URL:\n return base64.urlsafe_b64encode(signature).decode()\n\n raise ValueError(f\"Unsupported encoding: {encoding}\")\n\n\nasync def send_webhook_request(\n url: str,\n body: WebhookInput,\n auth_type: str,\n auth_data: dict[str, Any] | None = None,\n headers: dict[str, str] | None = None,\n) -> aiohttp.ClientResponse:\n request_headers = headers or {}\n auth = None\n\n if auth_type == \"BASIC\" and auth_data:\n auth = aiohttp.BasicAuth(auth_data[\"username\"], auth_data[\"password\"])\n elif auth_type == \"API_KEY\" and auth_data:\n request_headers[auth_data[\"header_name\"]] = auth_data[\"api_key\"]\n elif auth_type == \"HMAC\" and auth_data:\n payload = json.dumps(body.model_dump()).encode()\n signature = create_hmac_signature(\n payload,\n auth_data[\"secret\"],\n auth_data.get(\"algorithm\", V1WebhookHMACAlgorithm.SHA256),\n auth_data.get(\"encoding\", V1WebhookHMACEncoding.HEX),\n )\n request_headers[auth_data[\"header_name\"]] = signature\n\n async with aiohttp.ClientSession() as session:\n return await session.post(\n url, json=body.model_dump(), auth=auth, headers=request_headers\n )\n\n\nasync def wait_for_event(\n hatchet: Hatchet,\n webhook_name: str,\n test_start: datetime,\n) -> V1Event | None:\n await asyncio.sleep(5)\n\n events = await hatchet.event.aio_list(since=test_start)\n\n if events.rows is None:\n return None\n\n return next(\n (\n event\n for event in events.rows\n if event.triggering_webhook_name == webhook_name\n ),\n None,\n )\n\n\nasync def wait_for_workflow_run(\n hatchet: Hatchet, event_id: str, test_start: datetime\n) -> V1TaskSummary | None:\n await asyncio.sleep(5)\n\n runs = await hatchet.runs.aio_list(\n since=test_start,\n additional_metadata={\n \"hatchet__event_id\": event_id,\n },\n )\n\n if len(runs.rows) == 0:\n return None\n\n return runs.rows[0]\n\n\n@asynccontextmanager\nasync def basic_auth_webhook(\n hatchet: Hatchet,\n test_run_id: str,\n username: str = TEST_BASIC_USERNAME,\n password: str = TEST_BASIC_PASSWORD,\n source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,\n) -> AsyncGenerator[V1Webhook, None]:\n ## Hack to get the API client\n client = hatchet.metrics.client()\n webhook_api = WebhookApi(client)\n\n webhook_request = V1CreateWebhookRequestBasicAuth(\n sourceName=source_name,\n name=f\"test-webhook-basic-{test_run_id}\",\n eventKeyExpression=f\"'{hatchet.config.apply_namespace('webhook')}:' + input.type\",\n authType=\"BASIC\",\n auth=V1WebhookBasicAuth(\n username=username,\n password=password,\n ),\n )\n\n incoming_webhook = webhook_api.v1_webhook_create(\n tenant=hatchet.tenant_id,\n v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),\n )\n\n try:\n yield incoming_webhook\n finally:\n webhook_api.v1_webhook_delete(\n tenant=hatchet.tenant_id,\n v1_webhook=incoming_webhook.name,\n )\n\n\n@asynccontextmanager\nasync def api_key_webhook(\n hatchet: Hatchet,\n test_run_id: str,\n header_name: str = TEST_API_KEY_HEADER,\n api_key: str = TEST_API_KEY_VALUE,\n source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,\n) -> AsyncGenerator[V1Webhook, None]:\n client = hatchet.metrics.client()\n webhook_api = WebhookApi(client)\n\n webhook_request = V1CreateWebhookRequestAPIKey(\n sourceName=source_name,\n name=f\"test-webhook-apikey-{test_run_id}\",\n eventKeyExpression=f\"'{hatchet.config.apply_namespace('webhook')}:' + input.type\",\n authType=\"API_KEY\",\n auth=V1WebhookAPIKeyAuth(\n headerName=header_name,\n apiKey=api_key,\n ),\n )\n\n incoming_webhook = webhook_api.v1_webhook_create(\n tenant=hatchet.tenant_id,\n v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),\n )\n\n try:\n yield incoming_webhook\n finally:\n webhook_api.v1_webhook_delete(\n tenant=hatchet.tenant_id,\n v1_webhook=incoming_webhook.name,\n )\n\n\n@asynccontextmanager\nasync def hmac_webhook(\n hatchet: Hatchet,\n test_run_id: str,\n signature_header_name: str = TEST_HMAC_SIGNATURE_HEADER,\n signing_secret: str = TEST_HMAC_SECRET,\n algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256,\n encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX,\n source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC,\n) -> AsyncGenerator[V1Webhook, None]:\n client = hatchet.metrics.client()\n webhook_api = WebhookApi(client)\n\n webhook_request = V1CreateWebhookRequestHMAC(\n sourceName=source_name,\n name=f\"test-webhook-hmac-{test_run_id}\",\n eventKeyExpression=f\"'{hatchet.config.apply_namespace('webhook')}:' + input.type\",\n authType=\"HMAC\",\n auth=V1WebhookHMACAuth(\n algorithm=algorithm,\n encoding=encoding,\n signatureHeaderName=signature_header_name,\n signingSecret=signing_secret,\n ),\n )\n\n incoming_webhook = webhook_api.v1_webhook_create(\n tenant=hatchet.tenant_id,\n v1_create_webhook_request=V1CreateWebhookRequest(webhook_request),\n )\n\n try:\n yield incoming_webhook\n finally:\n webhook_api.v1_webhook_delete(\n tenant=hatchet.tenant_id,\n v1_webhook=incoming_webhook.name,\n )\n\n\ndef url(tenant_id: str, webhook_name: str) -> str:\n return f\"http://localhost:8080/api/v1/stable/tenants/{tenant_id}/webhooks/{webhook_name}\"\n\n\nasync def assert_has_runs(\n hatchet: Hatchet,\n test_start: datetime,\n webhook_body: WebhookInput,\n incoming_webhook: V1Webhook,\n) -> None:\n triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start)\n assert triggered_event is not None\n assert (\n triggered_event.key\n == f\"{hatchet.config.apply_namespace('webhook')}:{webhook_body.type}\"\n )\n assert triggered_event.payload == webhook_body.model_dump()\n\n workflow_run = await wait_for_workflow_run(\n hatchet, triggered_event.metadata.id, test_start\n )\n assert workflow_run is not None\n assert workflow_run.status == V1TaskStatus.COMPLETED\n assert workflow_run.additional_metadata is not None\n\n assert (\n workflow_run.additional_metadata[\"hatchet__event_id\"]\n == triggered_event.metadata.id\n )\n assert workflow_run.additional_metadata[\"hatchet__event_key\"] == triggered_event.key\n assert workflow_run.status == V1TaskStatus.COMPLETED\n\n\nasync def assert_event_not_created(\n hatchet: Hatchet,\n test_start: datetime,\n incoming_webhook: V1Webhook,\n) -> None:\n triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start)\n assert triggered_event is None\n\n\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_basic_auth_success(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n \"BASIC\",\n {\"username\": TEST_BASIC_USERNAME, \"password\": TEST_BASIC_PASSWORD},\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {\"message\": \"ok\"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n \"username,password\",\n [\n (\"test_user\", \"incorrect_password\"),\n (\"incorrect_user\", \"test_password\"),\n (\"incorrect_user\", \"incorrect_password\"),\n (\"\", \"\"),\n ],\n)\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_basic_auth_failure(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n username: str,\n password: str,\n) -> None:\n \"\"\"Test basic authentication failures.\"\"\"\n async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n \"BASIC\",\n {\"username\": username, \"password\": password},\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_basic_auth_missing_credentials(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name), webhook_body, \"NONE\"\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_api_key_success(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n \"API_KEY\",\n {\"header_name\": TEST_API_KEY_HEADER, \"api_key\": TEST_API_KEY_VALUE},\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {\"message\": \"ok\"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n \"api_key\",\n [\n \"incorrect_api_key\",\n \"\",\n \"partial_key\",\n ],\n)\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_api_key_failure(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n api_key: str,\n) -> None:\n async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n \"API_KEY\",\n {\"header_name\": TEST_API_KEY_HEADER, \"api_key\": api_key},\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_api_key_missing_header(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with api_key_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name), webhook_body, \"NONE\"\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_hmac_success(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n \"HMAC\",\n {\n \"header_name\": TEST_HMAC_SIGNATURE_HEADER,\n \"secret\": TEST_HMAC_SECRET,\n \"algorithm\": V1WebhookHMACAlgorithm.SHA256,\n \"encoding\": V1WebhookHMACEncoding.HEX,\n },\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {\"message\": \"ok\"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n \"algorithm,encoding\",\n [\n (V1WebhookHMACAlgorithm.SHA1, V1WebhookHMACEncoding.HEX),\n (V1WebhookHMACAlgorithm.SHA256, V1WebhookHMACEncoding.BASE64),\n (V1WebhookHMACAlgorithm.SHA512, V1WebhookHMACEncoding.BASE64URL),\n (V1WebhookHMACAlgorithm.MD5, V1WebhookHMACEncoding.HEX),\n ],\n)\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_hmac_different_algorithms_and_encodings(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n algorithm: V1WebhookHMACAlgorithm,\n encoding: V1WebhookHMACEncoding,\n) -> None:\n async with hmac_webhook(\n hatchet, test_run_id, algorithm=algorithm, encoding=encoding\n ) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n \"HMAC\",\n {\n \"header_name\": TEST_HMAC_SIGNATURE_HEADER,\n \"secret\": TEST_HMAC_SECRET,\n \"algorithm\": algorithm,\n \"encoding\": encoding,\n },\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {\"message\": \"ok\"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n \"secret\",\n [\n \"incorrect_secret\",\n \"\",\n \"partial_secret\",\n ],\n)\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_hmac_signature_failure(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n secret: str,\n) -> None:\n async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n \"HMAC\",\n {\n \"header_name\": TEST_HMAC_SIGNATURE_HEADER,\n \"secret\": secret,\n \"algorithm\": V1WebhookHMACAlgorithm.SHA256,\n \"encoding\": V1WebhookHMACEncoding.HEX,\n },\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_hmac_missing_signature_header(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n) -> None:\n async with hmac_webhook(hatchet, test_run_id) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name), webhook_body, \"NONE\"\n ) as response:\n assert response.status == 403\n\n await assert_event_not_created(\n hatchet,\n test_start,\n incoming_webhook,\n )\n\n\n@pytest.mark.parametrize(\n \"source_name\",\n [\n V1WebhookSourceName.GENERIC,\n V1WebhookSourceName.GITHUB,\n ],\n)\n@pytest.mark.asyncio(loop_scope=\"session\")\nasync def test_different_source_types(\n hatchet: Hatchet,\n test_run_id: str,\n test_start: datetime,\n webhook_body: WebhookInput,\n source_name: V1WebhookSourceName,\n) -> None:\n async with basic_auth_webhook(\n hatchet, test_run_id, source_name=source_name\n ) as incoming_webhook:\n async with await send_webhook_request(\n url(hatchet.tenant_id, incoming_webhook.name),\n webhook_body,\n \"BASIC\",\n {\"username\": TEST_BASIC_USERNAME, \"password\": TEST_BASIC_PASSWORD},\n ) as response:\n assert response.status == 200\n data = await response.json()\n assert data == {\"message\": \"ok\"}\n\n await assert_has_runs(\n hatchet,\n test_start,\n webhook_body,\n incoming_webhook,\n )\n", + "source": "out/python/webhooks/test_webhooks.py", + "blocks": {}, + "highlights": {} +}; + +export default snippet; diff --git a/frontend/docs/lib/generated/snips/python/webhooks/worker.ts b/frontend/docs/lib/generated/snips/python/webhooks/worker.ts new file mode 100644 index 000000000..f060c334b --- /dev/null +++ b/frontend/docs/lib/generated/snips/python/webhooks/worker.ts @@ -0,0 +1,16 @@ +import { Snippet } from '@/lib/generated/snips/types'; + +const snippet: Snippet = { + "language": "python", + "content": "# > Webhooks\n\nfrom pydantic import BaseModel\n\nfrom hatchet_sdk import Context, Hatchet\n\nhatchet = Hatchet(debug=True)\n\n\nclass WebhookInput(BaseModel):\n type: str\n message: str\n\n\n@hatchet.task(input_validator=WebhookInput, on_events=[\"webhook:test\"])\ndef webhook(input: WebhookInput, ctx: Context) -> dict[str, str]:\n return input.model_dump()\n\n\ndef main() -> None:\n worker = hatchet.worker(\"webhook-worker\", workflows=[webhook])\n worker.start()\n\n\n\nif __name__ == \"__main__\":\n main()\n", + "source": "out/python/webhooks/worker.py", + "blocks": { + "webhooks": { + "start": 2, + "stop": 24 + } + }, + "highlights": {} +}; + +export default snippet; diff --git a/frontend/docs/lib/generated/snips/python/worker.ts b/frontend/docs/lib/generated/snips/python/worker.ts index d8f814a36..a07a5396c 100644 --- a/frontend/docs/lib/generated/snips/python/worker.ts +++ b/frontend/docs/lib/generated/snips/python/worker.ts @@ -2,7 +2,7 @@ import { Snippet } from '@/lib/generated/snips/types'; const snippet: Snippet = { "language": "python", - "content": "from examples.affinity_workers.worker import affinity_worker_workflow\nfrom examples.bulk_fanout.worker import bulk_child_wf, bulk_parent_wf\nfrom examples.bulk_operations.worker import (\n bulk_replay_test_1,\n bulk_replay_test_2,\n bulk_replay_test_3,\n)\nfrom examples.cancellation.worker import cancellation_workflow\nfrom examples.concurrency_limit.worker import concurrency_limit_workflow\nfrom examples.concurrency_limit_rr.worker import concurrency_limit_rr_workflow\nfrom examples.concurrency_multiple_keys.worker import concurrency_multiple_keys_workflow\nfrom examples.concurrency_workflow_level.worker import (\n concurrency_workflow_level_workflow,\n)\nfrom examples.conditions.worker import task_condition_workflow\nfrom examples.dag.worker import dag_workflow\nfrom examples.dedupe.worker import dedupe_child_wf, dedupe_parent_wf\nfrom examples.durable.worker import durable_workflow, wait_for_sleep_twice\nfrom examples.events.worker import event_workflow\nfrom examples.fanout.worker import child_wf, parent_wf\nfrom examples.fanout_sync.worker import sync_fanout_child, sync_fanout_parent\nfrom examples.lifespans.simple import lifespan, lifespan_task\nfrom examples.logger.workflow import logging_workflow\nfrom examples.non_retryable.worker import non_retryable_workflow\nfrom examples.on_failure.worker import on_failure_wf, on_failure_wf_with_details\nfrom examples.return_exceptions.worker import return_exceptions_task\nfrom examples.simple.worker import simple, simple_durable\nfrom examples.timeout.worker import refresh_timeout_wf, timeout_wf\nfrom hatchet_sdk import Hatchet\n\nhatchet = Hatchet(debug=True)\n\n\ndef main() -> None:\n worker = hatchet.worker(\n \"e2e-test-worker\",\n slots=100,\n workflows=[\n affinity_worker_workflow,\n bulk_child_wf,\n bulk_parent_wf,\n concurrency_limit_workflow,\n concurrency_limit_rr_workflow,\n concurrency_multiple_keys_workflow,\n dag_workflow,\n dedupe_child_wf,\n dedupe_parent_wf,\n durable_workflow,\n child_wf,\n event_workflow,\n parent_wf,\n on_failure_wf,\n on_failure_wf_with_details,\n logging_workflow,\n timeout_wf,\n refresh_timeout_wf,\n task_condition_workflow,\n cancellation_workflow,\n sync_fanout_parent,\n sync_fanout_child,\n non_retryable_workflow,\n concurrency_workflow_level_workflow,\n lifespan_task,\n simple,\n simple_durable,\n bulk_replay_test_1,\n bulk_replay_test_2,\n bulk_replay_test_3,\n return_exceptions_task,\n wait_for_sleep_twice,\n ],\n lifespan=lifespan,\n )\n\n worker.start()\n\n\nif __name__ == \"__main__\":\n main()\n", + "content": "from examples.affinity_workers.worker import affinity_worker_workflow\nfrom examples.bulk_fanout.worker import bulk_child_wf, bulk_parent_wf\nfrom examples.bulk_operations.worker import (\n bulk_replay_test_1,\n bulk_replay_test_2,\n bulk_replay_test_3,\n)\nfrom examples.cancellation.worker import cancellation_workflow\nfrom examples.concurrency_limit.worker import concurrency_limit_workflow\nfrom examples.concurrency_limit_rr.worker import concurrency_limit_rr_workflow\nfrom examples.concurrency_multiple_keys.worker import concurrency_multiple_keys_workflow\nfrom examples.concurrency_workflow_level.worker import (\n concurrency_workflow_level_workflow,\n)\nfrom examples.conditions.worker import task_condition_workflow\nfrom examples.dag.worker import dag_workflow\nfrom examples.dedupe.worker import dedupe_child_wf, dedupe_parent_wf\nfrom examples.durable.worker import durable_workflow\nfrom examples.events.worker import event_workflow\nfrom examples.fanout.worker import child_wf, parent_wf\nfrom examples.fanout_sync.worker import sync_fanout_child, sync_fanout_parent\nfrom examples.lifespans.simple import lifespan, lifespan_task\nfrom examples.logger.workflow import logging_workflow\nfrom examples.non_retryable.worker import non_retryable_workflow\nfrom examples.on_failure.worker import on_failure_wf, on_failure_wf_with_details\nfrom examples.return_exceptions.worker import return_exceptions_task\nfrom examples.simple.worker import simple, simple_durable\nfrom examples.timeout.worker import refresh_timeout_wf, timeout_wf\nfrom examples.webhooks.worker import webhook\nfrom hatchet_sdk import Hatchet\n\nhatchet = Hatchet(debug=True)\n\n\ndef main() -> None:\n worker = hatchet.worker(\n \"e2e-test-worker\",\n slots=100,\n workflows=[\n affinity_worker_workflow,\n bulk_child_wf,\n bulk_parent_wf,\n concurrency_limit_workflow,\n concurrency_limit_rr_workflow,\n concurrency_multiple_keys_workflow,\n dag_workflow,\n dedupe_child_wf,\n dedupe_parent_wf,\n durable_workflow,\n child_wf,\n event_workflow,\n parent_wf,\n on_failure_wf,\n on_failure_wf_with_details,\n logging_workflow,\n timeout_wf,\n refresh_timeout_wf,\n task_condition_workflow,\n cancellation_workflow,\n sync_fanout_parent,\n sync_fanout_child,\n non_retryable_workflow,\n concurrency_workflow_level_workflow,\n lifespan_task,\n simple,\n simple_durable,\n bulk_replay_test_1,\n bulk_replay_test_2,\n bulk_replay_test_3,\n webhook,\n return_exceptions_task,\n ],\n lifespan=lifespan,\n )\n\n worker.start()\n\n\nif __name__ == \"__main__\":\n main()\n", "source": "out/python/worker.py", "blocks": {}, "highlights": {} diff --git a/frontend/docs/pages/home/webhooks.mdx b/frontend/docs/pages/home/webhooks.mdx new file mode 100644 index 000000000..4a8b82c76 --- /dev/null +++ b/frontend/docs/pages/home/webhooks.mdx @@ -0,0 +1,58 @@ +import snips from "@/lib/snips"; +import { Snippet } from "@/components/code"; +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +# Webhooks + +Webhooks allow external systems to trigger Hatchet workflows by sending HTTP requests to dedicated endpoints. This enables real-time integration with third-party services like GitHub, Stripe, or any system that can send webhook events. + +## Creating a webhook + +To create a webhook, you'll need to fill out some fields that tell Hatchet how to determine which workflows to trigger from your webhook, and how to validate it when it arrives from the sender. In particular, you'll need to provide the following fields: + +#### Name + +The **Webhook Name** is tenant-unique (meaning a single tenant can only use each name once), and is used to create the URL for where the incoming webhook request should be sent. For instance, if your tenant id was `d60181b7-da6c-4d4c-92ec-8aa0fc74b3e5` and your webhook name was `my-webhook`, then the URL might look like `https://cloud.onhatchet.run/api/v1/stable/tenants/d60181b7-da6c-4d4c-92ec-8aa0fc74b3e5/webhooks/my-webhook`. Note that you can copy this URL in the dashboard. + +#### Source + +The **Source** indicates the source of the webhook, which can be a pre-provided one for easy setup like Stripe or Github, or a "generic" one, which lets you configure all of the necessary fields for your webhook integration based on what the webhook sender provides. + +#### Event Key Expression + +The **Event Key Expression** is a [CEL](https://cel.dev/) expression that you can use to create a dynamic event key from the payload of the incoming webhook. You can either set this to a constant value, like `webhook`, or you could set it to something like `'stripe:' + input.type`, where `'stripe:'` is a prefix for all keys indicating the webhook came from Stripe, and `input.type` selects the `type` field off of the webhook payload and uses it to create the final event key, which would look something like `stripe:payment_intent.created`. + + + The result of the event key expression is what Hatchet will use as the event + key, so you'd need to set a matching event key as a trigger on your workflows + in order to trigger them from the webhooks you create. For instance, you might + add `on_events=["stripe:payment_intent.created"]` to listen for payment intent + created events in the previous example. + + +#### Authentication + +Finally, you'll need to specify how Hatchet should authenticate incoming webhook requests. For non-generic sources like Stripe and Github, Hatchet has presets for most of the fields, so in most cases you'd only need to provide a secret. + +If you're using a generic source, then you'll need to specify an authentication method (either basic auth, an API key, HMAC-based auth), and provide the required fields (such as a username and password in the basic auth case). + + + Hatchet encrypts any secrets you provide for validating incoming webhooks. + + +The different authentication methods require different fields to be provided: + +- **Pre-configured sources** (Stripe, GitHub): Only require a webhook secret +- **Generic sources** require different fields depending on the selected authentication method: + - **Basic Auth**: Requires a username and password + - **API Key**: Requires header name containing the key on incoming requests, and secret key itself + - **HMAC**: Requires a header name containing the secret on incoming requests, the secret itself, an encoding method (e.g. hex, base64), and an algorithm (e.g. `SHA256`, `SHA1`, etc.). + +## Usage + +While you're creating your webhook (and also after you've created it), you can copy the webhook URL, which is what you'll provide to the webhook _sender_. + +Once you've done that, the last thing to do is register the event keys you want your workers to listen for so that they can be triggered by incoming webhooks. + +For examples on how to do this, see the [documentation on event triggers](./run-on-event.mdx). diff --git a/internal/cel/cel.go b/internal/cel/cel.go index a1028a9d6..c4d248675 100644 --- a/internal/cel/cel.go +++ b/internal/cel/cel.go @@ -17,9 +17,10 @@ import ( ) type CELParser struct { - workflowStrEnv *cel.Env - stepRunEnv *cel.Env - eventEnv *cel.Env + workflowStrEnv *cel.Env + stepRunEnv *cel.Env + eventEnv *cel.Env + incomingWebhookEnv *cel.Env } var checksumDecl = decls.NewFunction("checksum", @@ -83,10 +84,18 @@ func NewCELParser() *CELParser { ext.Strings(), ) + incomingWebhookEnv, _ := cel.NewEnv( + cel.Declarations( + decls.NewVar("input", decls.NewMapType(decls.String, decls.Dyn)), + checksumDecl, + ), + ) + return &CELParser{ - workflowStrEnv: workflowStrEnv, - stepRunEnv: stepRunEnv, - eventEnv: eventEnv, + workflowStrEnv: workflowStrEnv, + stepRunEnv: stepRunEnv, + eventEnv: eventEnv, + incomingWebhookEnv: incomingWebhookEnv, } } @@ -355,3 +364,29 @@ func (p *CELParser) EvaluateEventExpression(expr string, input Input) (bool, err return out.Value().(bool), nil } + +func (p *CELParser) EvaluateIncomingWebhookExpression(expr string, input Input) (string, error) { + ast, issues := p.eventEnv.Compile(expr) + + if issues != nil && issues.Err() != nil { + return "", fmt.Errorf("failed to compile expression: %w", issues.Err()) + } + + program, err := p.eventEnv.Program(ast) + if err != nil { + return "", fmt.Errorf("failed to create program: %w", err) + } + + var inMap map[string]interface{} = input + + out, _, err := program.Eval(inMap) + if err != nil { + return "", fmt.Errorf("failed to evaluate expression: %w", err) + } + + if out.Type() != types.StringType { + return "", fmt.Errorf("expression did not evaluate to a string: got %s", out.Type().TypeName()) + } + + return out.Value().(string), nil +} diff --git a/internal/services/controllers/v1/olap/controller.go b/internal/services/controllers/v1/olap/controller.go index 9c3dcb3b5..b7d61230d 100644 --- a/internal/services/controllers/v1/olap/controller.go +++ b/internal/services/controllers/v1/olap/controller.go @@ -337,11 +337,34 @@ func (tc *OLAPControllerImpl) handleBufferedMsgs(tenantId, msgId string, payload return tc.handleCreateMonitoringEvent(context.Background(), tenantId, payloads) case "created-event-trigger": return tc.handleCreateEventTriggers(context.Background(), tenantId, payloads) + case "failed-webhook-validation": + return tc.handleFailedWebhookValidation(context.Background(), tenantId, payloads) + case "cel-evaluation-failure": + return tc.handleCelEvaluationFailure(context.Background(), tenantId, payloads) } return fmt.Errorf("unknown message id: %s", msgId) } +func (tc *OLAPControllerImpl) handleCelEvaluationFailure(ctx context.Context, tenantId string, payloads [][]byte) error { + failures := make([]v1.CELEvaluationFailure, 0) + + msgs := msgqueue.JSONConvert[tasktypes.CELEvaluationFailures](payloads) + + for _, msg := range msgs { + for _, failure := range msg.Failures { + if !tc.sample(failure.ErrorMessage) { + tc.l.Debug().Msgf("skipping CEL evaluation failure %s for source %s", failure.ErrorMessage, failure.Source) + continue + } + + failures = append(failures, failure) + } + } + + return tc.repo.OLAP().StoreCELEvaluationFailures(ctx, tenantId, failures) +} + // handleCreatedTask is responsible for flushing a created task to the OLAP repository func (tc *OLAPControllerImpl) handleCreatedTask(ctx context.Context, tenantId string, payloads [][]byte) error { createTaskOpts := make([]*sqlcv1.V1Task, 0) @@ -390,7 +413,8 @@ func (tc *OLAPControllerImpl) handleCreateEventTriggers(ctx context.Context, ten keys := make([]string, 0) payloadstoInsert := make([][]byte, 0) additionalMetadatas := make([][]byte, 0) - scopes := make([]*string, 0) + scopes := make([]pgtype.Text, 0) + triggeringWebhookNames := make([]pgtype.Text, 0) for _, msg := range msgs { for _, payload := range msg.Payloads { @@ -423,18 +447,32 @@ func (tc *OLAPControllerImpl) handleCreateEventTriggers(ctx context.Context, ten keys = append(keys, payload.EventKey) payloadstoInsert = append(payloadstoInsert, payload.EventPayload) additionalMetadatas = append(additionalMetadatas, payload.EventAdditionalMetadata) - scopes = append(scopes, payload.EventScope) + + var scope pgtype.Text + if payload.EventScope != nil { + scope = sqlchelpers.TextFromStr(*payload.EventScope) + } + + scopes = append(scopes, scope) + + var triggeringWebhookName pgtype.Text + if payload.TriggeringWebhookName != nil { + triggeringWebhookName = sqlchelpers.TextFromStr(*payload.TriggeringWebhookName) + } + + triggeringWebhookNames = append(triggeringWebhookNames, triggeringWebhookName) } } bulkCreateEventParams := sqlcv1.BulkCreateEventsParams{ - Tenantids: tenantIds, - Externalids: externalIds, - Seenats: seenAts, - Keys: keys, - Payloads: payloadstoInsert, - Additionalmetadatas: additionalMetadatas, - Scopes: scopes, + Tenantids: tenantIds, + Externalids: externalIds, + Seenats: seenAts, + Keys: keys, + Payloads: payloadstoInsert, + Additionalmetadatas: additionalMetadatas, + Scopes: scopes, + TriggeringWebhookNames: triggeringWebhookNames, } return tc.repo.OLAP().BulkCreateEventsAndTriggers( @@ -588,6 +626,26 @@ func (tc *OLAPControllerImpl) handleCreateMonitoringEvent(ctx context.Context, t return tc.repo.OLAP().CreateTaskEvents(ctx, tenantId, opts) } +func (tc *OLAPControllerImpl) handleFailedWebhookValidation(ctx context.Context, tenantId string, payloads [][]byte) error { + createFailedWebhookValidationOpts := make([]v1.CreateIncomingWebhookFailureLogOpts, 0) + + msgs := msgqueue.JSONConvert[tasktypes.FailedWebhookValidationPayload](payloads) + + for _, msg := range msgs { + if !tc.sample(msg.ErrorText) { + tc.l.Debug().Msgf("skipping failure logging for webhook %s", msg.WebhookName) + continue + } + + createFailedWebhookValidationOpts = append(createFailedWebhookValidationOpts, v1.CreateIncomingWebhookFailureLogOpts{ + WebhookName: msg.WebhookName, + ErrorText: msg.ErrorText, + }) + } + + return tc.repo.OLAP().CreateIncomingWebhookValidationFailureLogs(ctx, tenantId, createFailedWebhookValidationOpts) +} + func (tc *OLAPControllerImpl) sample(workflowRunID string) bool { if tc.samplingHashThreshold == nil { return true diff --git a/internal/services/controllers/v1/task/controller.go b/internal/services/controllers/v1/task/controller.go index 2ea30aa26..85283864d 100644 --- a/internal/services/controllers/v1/task/controller.go +++ b/internal/services/controllers/v1/task/controller.go @@ -911,12 +911,13 @@ func (tc *TasksControllerImpl) handleProcessUserEventTrigger(ctx context.Context for _, msg := range msgs { opt := v1.EventTriggerOpts{ - ExternalId: msg.EventExternalId, - Key: msg.EventKey, - Data: msg.EventData, - AdditionalMetadata: msg.EventAdditionalMetadata, - Priority: msg.EventPriority, - Scope: msg.EventScope, + ExternalId: msg.EventExternalId, + Key: msg.EventKey, + Data: msg.EventData, + AdditionalMetadata: msg.EventAdditionalMetadata, + Priority: msg.EventPriority, + Scope: msg.EventScope, + TriggeringWebhookName: msg.TriggeringWebhookName, } opts = append(opts, opt) @@ -945,6 +946,7 @@ func (tc *TasksControllerImpl) handleProcessUserEventTrigger(ctx context.Context EventExternalId: opts.ExternalId, EventPayload: opts.Data, EventAdditionalMetadata: opts.AdditionalMetadata, + TriggeringWebhookName: opts.TriggeringWebhookName, }) } else { for _, run := range runs { @@ -958,6 +960,7 @@ func (tc *TasksControllerImpl) handleProcessUserEventTrigger(ctx context.Context EventAdditionalMetadata: opts.AdditionalMetadata, EventScope: opts.Scope, FilterId: run.FilterId, + TriggeringWebhookName: opts.TriggeringWebhookName, }) } } @@ -980,6 +983,21 @@ func (tc *TasksControllerImpl) handleProcessUserEventTrigger(ctx context.Context return fmt.Errorf("could not trigger tasks from events: %w", err) } + evalFailuresMsg, err := tasktypes.CELEvaluationFailureMessage( + tenantId, + result.CELEvaluationFailures, + ) + + if err != nil { + return fmt.Errorf("could not create CEL evaluation failure message: %w", err) + } + + err = tc.pubBuffer.Pub(ctx, msgqueue.OLAP_QUEUE, evalFailuresMsg, false) + + if err != nil { + return fmt.Errorf("could not deliver CEL evaluation failure message: %w", err) + } + eg := &errgroup.Group{} eg.Go(func() error { diff --git a/internal/services/ingestor/ingestor.go b/internal/services/ingestor/ingestor.go index 4e09a0807..ca9240603 100644 --- a/internal/services/ingestor/ingestor.go +++ b/internal/services/ingestor/ingestor.go @@ -17,14 +17,17 @@ import ( "github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc" "github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers" v1 "github.com/hatchet-dev/hatchet/pkg/repository/v1" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" "github.com/hatchet-dev/hatchet/pkg/validator" ) type Ingestor interface { contracts.EventsServiceServer - IngestEvent(ctx context.Context, tenant *dbsqlc.Tenant, eventName string, data []byte, metadata []byte, priority *int32, scope *string) (*dbsqlc.Event, error) + IngestEvent(ctx context.Context, tenant *dbsqlc.Tenant, eventName string, data []byte, metadata []byte, priority *int32, scope, triggeringWebhookName *string) (*dbsqlc.Event, error) + IngestWebhookValidationFailure(ctx context.Context, tenant *dbsqlc.Tenant, webhookName, errorText string) error BulkIngestEvent(ctx context.Context, tenant *dbsqlc.Tenant, eventOpts []*repository.CreateEventOpts) ([]*dbsqlc.Event, error) IngestReplayedEvent(ctx context.Context, tenant *dbsqlc.Tenant, replayedEvent *dbsqlc.Event) (*dbsqlc.Event, error) + IngestCELEvaluationFailure(ctx context.Context, tenantId, errorText string, source sqlcv1.V1CelEvaluationFailureSource) error } type IngestorOptFunc func(*IngestorOpts) @@ -175,17 +178,21 @@ func NewIngestor(fs ...IngestorOptFunc) (Ingestor, error) { }, nil } -func (i *IngestorImpl) IngestEvent(ctx context.Context, tenant *dbsqlc.Tenant, key string, data []byte, metadata []byte, priority *int32, scope *string) (*dbsqlc.Event, error) { +func (i *IngestorImpl) IngestEvent(ctx context.Context, tenant *dbsqlc.Tenant, key string, data []byte, metadata []byte, priority *int32, scope, triggeringWebhookName *string) (*dbsqlc.Event, error) { switch tenant.Version { case dbsqlc.TenantMajorEngineVersionV0: return i.ingestEventV0(ctx, tenant, key, data, metadata) case dbsqlc.TenantMajorEngineVersionV1: - return i.ingestEventV1(ctx, tenant, key, data, metadata, priority, scope) + return i.ingestEventV1(ctx, tenant, key, data, metadata, priority, scope, triggeringWebhookName) default: return nil, fmt.Errorf("unsupported tenant version: %s", tenant.Version) } } +func (i *IngestorImpl) IngestWebhookValidationFailure(ctx context.Context, tenant *dbsqlc.Tenant, webhookName, errorText string) error { + return i.ingestWebhookValidationFailure(tenant.ID.String(), webhookName, errorText) +} + func (i *IngestorImpl) ingestEventV0(ctx context.Context, tenant *dbsqlc.Tenant, key string, data []byte, metadata []byte) (*dbsqlc.Event, error) { ctx, span := telemetry.NewSpan(ctx, "ingest-event") defer span.End() @@ -339,3 +346,12 @@ func eventToTask(e *dbsqlc.Event) *msgqueue.Message { Retries: 3, } } + +func (i *IngestorImpl) IngestCELEvaluationFailure(ctx context.Context, tenantId, errorText string, source sqlcv1.V1CelEvaluationFailureSource) error { + return i.ingestCELEvaluationFailure( + ctx, + tenantId, + errorText, + source, + ) +} diff --git a/internal/services/ingestor/ingestor_v1.go b/internal/services/ingestor/ingestor_v1.go index e8fb02c88..fd0fb3d46 100644 --- a/internal/services/ingestor/ingestor_v1.go +++ b/internal/services/ingestor/ingestor_v1.go @@ -15,6 +15,8 @@ import ( "github.com/hatchet-dev/hatchet/pkg/repository" "github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc" "github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers" + v1 "github.com/hatchet-dev/hatchet/pkg/repository/v1" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" ) type EventResult struct { @@ -25,7 +27,7 @@ type EventResult struct { AdditionalMetadata string } -func (i *IngestorImpl) ingestEventV1(ctx context.Context, tenant *dbsqlc.Tenant, key string, data []byte, metadata []byte, priority *int32, scope *string) (*dbsqlc.Event, error) { +func (i *IngestorImpl) ingestEventV1(ctx context.Context, tenant *dbsqlc.Tenant, key string, data []byte, metadata []byte, priority *int32, scope, triggeringWebhookName *string) (*dbsqlc.Event, error) { ctx, span := telemetry.NewSpan(ctx, "ingest-event") defer span.End() @@ -49,10 +51,10 @@ func (i *IngestorImpl) ingestEventV1(ctx context.Context, tenant *dbsqlc.Tenant, ) } - return i.ingestSingleton(tenantId, key, data, metadata, priority, scope) + return i.ingestSingleton(tenantId, key, data, metadata, priority, scope, triggeringWebhookName) } -func (i *IngestorImpl) ingestSingleton(tenantId, key string, data []byte, metadata []byte, priority *int32, scope *string) (*dbsqlc.Event, error) { +func (i *IngestorImpl) ingestSingleton(tenantId, key string, data []byte, metadata []byte, priority *int32, scope, triggeringWebhookName *string) (*dbsqlc.Event, error) { eventId := uuid.New().String() msg, err := eventToTaskV1( @@ -63,6 +65,7 @@ func (i *IngestorImpl) ingestSingleton(tenantId, key string, data []byte, metada metadata, priority, scope, + triggeringWebhookName, ) if err != nil { @@ -117,7 +120,7 @@ func (i *IngestorImpl) bulkIngestEventV1(ctx context.Context, tenant *dbsqlc.Ten results := make([]*dbsqlc.Event, 0, len(eventOpts)) for _, event := range eventOpts { - res, err := i.ingestSingleton(tenantId, event.Key, event.Data, event.AdditionalMetadata, event.Priority, event.Scope) + res, err := i.ingestSingleton(tenantId, event.Key, event.Data, event.AdditionalMetadata, event.Priority, event.Scope, event.TriggeringWebhookName) if err != nil { return nil, fmt.Errorf("could not ingest event: %w", err) @@ -135,10 +138,10 @@ func (i *IngestorImpl) ingestReplayedEventV1(ctx context.Context, tenant *dbsqlc tenantId := sqlchelpers.UUIDToStr(tenant.ID) - return i.ingestSingleton(tenantId, replayedEvent.Key, replayedEvent.Data, replayedEvent.AdditionalMetadata, nil, nil) + return i.ingestSingleton(tenantId, replayedEvent.Key, replayedEvent.Data, replayedEvent.AdditionalMetadata, nil, nil, nil) } -func eventToTaskV1(tenantId, eventExternalId, key string, data, additionalMeta []byte, priority *int32, scope *string) (*msgqueue.Message, error) { +func eventToTaskV1(tenantId, eventExternalId, key string, data, additionalMeta []byte, priority *int32, scope *string, triggeringWebhookName *string) (*msgqueue.Message, error) { payloadTyped := tasktypes.UserEventTaskPayload{ EventExternalId: eventExternalId, EventKey: key, @@ -146,6 +149,7 @@ func eventToTaskV1(tenantId, eventExternalId, key string, data, additionalMeta [ EventAdditionalMetadata: additionalMeta, EventPriority: priority, EventScope: scope, + TriggeringWebhookName: triggeringWebhookName, } return msgqueue.NewTenantMessage( @@ -156,3 +160,62 @@ func eventToTaskV1(tenantId, eventExternalId, key string, data, additionalMeta [ payloadTyped, ) } + +func createWebhookValidationFailureMsg(tenantId, webhookName, errorText string) (*msgqueue.Message, error) { + payloadTyped := tasktypes.FailedWebhookValidationPayload{ + WebhookName: webhookName, + ErrorText: errorText, + } + + return msgqueue.NewTenantMessage( + tenantId, + "failed-webhook-validation", + false, + true, + payloadTyped, + ) +} + +func (i *IngestorImpl) ingestWebhookValidationFailure(tenantId, webhookName, errorText string) error { + msg, err := createWebhookValidationFailureMsg( + tenantId, + webhookName, + errorText, + ) + + if err != nil { + return fmt.Errorf("could not create failed webhook validation payload: %w", err) + } + + err = i.mqv1.SendMessage(context.Background(), msgqueue.OLAP_QUEUE, msg) + + if err != nil { + return fmt.Errorf("could not add failed webhook validation to olap queue: %w", err) + } + + return nil +} + +func (i *IngestorImpl) ingestCELEvaluationFailure(ctx context.Context, tenantId, errorText string, source sqlcv1.V1CelEvaluationFailureSource) error { + msg, err := tasktypes.CELEvaluationFailureMessage( + tenantId, + []v1.CELEvaluationFailure{ + { + Source: source, + ErrorMessage: errorText, + }, + }, + ) + + if err != nil { + return fmt.Errorf("failed to create CEL evaluation failure message: %w", err) + } + + err = i.mqv1.SendMessage(ctx, msgqueue.OLAP_QUEUE, msg) + + if err != nil { + return fmt.Errorf("failed to send CEL evaluation failure message: %w", err) + } + + return nil +} diff --git a/internal/services/ingestor/server.go b/internal/services/ingestor/server.go index 5096d9bbf..3746659fd 100644 --- a/internal/services/ingestor/server.go +++ b/internal/services/ingestor/server.go @@ -38,7 +38,7 @@ func (i *IngestorImpl) Push(ctx context.Context, req *contracts.PushEventRequest return nil, status.Errorf(codes.InvalidArgument, "Invalid request: %s", err) } - event, err := i.IngestEvent(ctx, tenant, req.Key, []byte(req.Payload), additionalMeta, req.Priority, req.Scope) + event, err := i.IngestEvent(ctx, tenant, req.Key, []byte(req.Payload), additionalMeta, req.Priority, req.Scope, nil) if err == metered.ErrResourceExhausted { return nil, status.Errorf(codes.ResourceExhausted, "resource exhausted: event limit exceeded for tenant") diff --git a/internal/services/shared/tasktypes/v1/event.go b/internal/services/shared/tasktypes/v1/event.go index 6d9fcbf9b..5f8e66f81 100644 --- a/internal/services/shared/tasktypes/v1/event.go +++ b/internal/services/shared/tasktypes/v1/event.go @@ -14,6 +14,7 @@ type UserEventTaskPayload struct { EventAdditionalMetadata []byte `json:"event_additional_metadata"` EventPriority *int32 `json:"event_priority,omitempty"` EventScope *string `json:"event_scope,omitempty"` + TriggeringWebhookName *string `json:"triggering_webhook_name,omitempty"` } func NewInternalEventMessage(tenantId string, timestamp time.Time, events ...v1.InternalTaskEvent) (*msgqueue.Message, error) { diff --git a/internal/services/shared/tasktypes/v1/olap.go b/internal/services/shared/tasktypes/v1/olap.go index 288e79d93..1799ca844 100644 --- a/internal/services/shared/tasktypes/v1/olap.go +++ b/internal/services/shared/tasktypes/v1/olap.go @@ -12,6 +12,22 @@ import ( "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" ) +type CELEvaluationFailures struct { + Failures []v1.CELEvaluationFailure +} + +func CELEvaluationFailureMessage(tenantId string, failures []v1.CELEvaluationFailure) (*msgqueue.Message, error) { + return msgqueue.NewTenantMessage( + tenantId, + "cel-evaluation-failure", + false, + true, + CELEvaluationFailures{ + Failures: failures, + }, + ) +} + type CreatedTaskPayload struct { *sqlcv1.V1Task } @@ -54,6 +70,7 @@ type CreatedEventTriggerPayloadSingleton struct { EventAdditionalMetadata []byte `json:"event_additional_metadata,omitempty"` EventScope *string `json:"event_scope,omitempty"` FilterId *string `json:"filter_id,omitempty"` + TriggeringWebhookName *string `json:"triggering_webhook_name,omitempty"` } type CreatedEventTriggerPayload struct { diff --git a/internal/services/shared/tasktypes/v1/webhook.go b/internal/services/shared/tasktypes/v1/webhook.go new file mode 100644 index 000000000..f0df173d9 --- /dev/null +++ b/internal/services/shared/tasktypes/v1/webhook.go @@ -0,0 +1,6 @@ +package v1 + +type FailedWebhookValidationPayload struct { + WebhookName string `json:"webhook_name" validate:"required"` + ErrorText string `json:"error_text" validate:"required"` +} diff --git a/pkg/client/rest/gen.go b/pkg/client/rest/gen.go index acf2d279a..ece28510a 100644 --- a/pkg/client/rest/gen.go +++ b/pkg/client/rest/gen.go @@ -197,6 +197,21 @@ const ( V1CELDebugResponseStatusSUCCESS V1CELDebugResponseStatus = "SUCCESS" ) +// Defines values for V1CreateWebhookRequestAPIKeyAuthType. +const ( + V1CreateWebhookRequestAPIKeyAuthTypeAPIKEY V1CreateWebhookRequestAPIKeyAuthType = "API_KEY" +) + +// Defines values for V1CreateWebhookRequestBasicAuthAuthType. +const ( + BASIC V1CreateWebhookRequestBasicAuthAuthType = "BASIC" +) + +// Defines values for V1CreateWebhookRequestHMACAuthType. +const ( + HMAC V1CreateWebhookRequestHMACAuthType = "HMAC" +) + // Defines values for V1LogLineLevel. const ( V1LogLineLevelDEBUG V1LogLineLevel = "DEBUG" @@ -238,6 +253,35 @@ const ( V1TaskStatusRUNNING V1TaskStatus = "RUNNING" ) +// Defines values for V1WebhookAuthType. +const ( + V1WebhookAuthTypeAPIKEY V1WebhookAuthType = "API_KEY" + V1WebhookAuthTypeBASIC V1WebhookAuthType = "BASIC" + V1WebhookAuthTypeHMAC V1WebhookAuthType = "HMAC" +) + +// Defines values for V1WebhookHMACAlgorithm. +const ( + MD5 V1WebhookHMACAlgorithm = "MD5" + SHA1 V1WebhookHMACAlgorithm = "SHA1" + SHA256 V1WebhookHMACAlgorithm = "SHA256" + SHA512 V1WebhookHMACAlgorithm = "SHA512" +) + +// Defines values for V1WebhookHMACEncoding. +const ( + BASE64 V1WebhookHMACEncoding = "BASE64" + BASE64URL V1WebhookHMACEncoding = "BASE64URL" + HEX V1WebhookHMACEncoding = "HEX" +) + +// Defines values for V1WebhookSourceName. +const ( + GENERIC V1WebhookSourceName = "GENERIC" + GITHUB V1WebhookSourceName = "GITHUB" + STRIPE V1WebhookSourceName = "STRIPE" +) + // Defines values for V1WorkflowType. const ( V1WorkflowTypeDAG V1WorkflowType = "DAG" @@ -1265,6 +1309,75 @@ type V1CreateFilterRequest struct { WorkflowId openapi_types.UUID `json:"workflowId"` } +// V1CreateWebhookRequest defines model for V1CreateWebhookRequest. +type V1CreateWebhookRequest struct { + union json.RawMessage +} + +// V1CreateWebhookRequestAPIKey defines model for V1CreateWebhookRequestAPIKey. +type V1CreateWebhookRequestAPIKey struct { + Auth V1WebhookAPIKeyAuth `json:"auth"` + + // AuthType The type of authentication to use for the webhook + AuthType V1CreateWebhookRequestAPIKeyAuthType `json:"authType"` + + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` +} + +// V1CreateWebhookRequestAPIKeyAuthType The type of authentication to use for the webhook +type V1CreateWebhookRequestAPIKeyAuthType string + +// V1CreateWebhookRequestBase defines model for V1CreateWebhookRequestBase. +type V1CreateWebhookRequestBase struct { + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` +} + +// V1CreateWebhookRequestBasicAuth defines model for V1CreateWebhookRequestBasicAuth. +type V1CreateWebhookRequestBasicAuth struct { + Auth V1WebhookBasicAuth `json:"auth"` + + // AuthType The type of authentication to use for the webhook + AuthType V1CreateWebhookRequestBasicAuthAuthType `json:"authType"` + + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` +} + +// V1CreateWebhookRequestBasicAuthAuthType The type of authentication to use for the webhook +type V1CreateWebhookRequestBasicAuthAuthType string + +// V1CreateWebhookRequestHMAC defines model for V1CreateWebhookRequestHMAC. +type V1CreateWebhookRequestHMAC struct { + Auth V1WebhookHMACAuth `json:"auth"` + + // AuthType The type of authentication to use for the webhook + AuthType V1CreateWebhookRequestHMACAuthType `json:"authType"` + + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` +} + +// V1CreateWebhookRequestHMACAuthType The type of authentication to use for the webhook +type V1CreateWebhookRequestHMACAuthType string + // V1DagChildren defines model for V1DagChildren. type V1DagChildren struct { Children *[]V1TaskSummary `json:"children,omitempty"` @@ -1294,8 +1407,11 @@ type V1Event struct { TenantId string `json:"tenantId"` // TriggeredRuns The external IDs of the runs that were triggered by this event. - TriggeredRuns *[]V1EventTriggeredRun `json:"triggeredRuns,omitempty"` - WorkflowRunSummary V1EventWorkflowRunSummary `json:"workflowRunSummary"` + TriggeredRuns *[]V1EventTriggeredRun `json:"triggeredRuns,omitempty"` + + // TriggeringWebhookName The name of the webhook that triggered this event, if applicable. + TriggeringWebhookName *string `json:"triggeringWebhookName,omitempty"` + WorkflowRunSummary V1EventWorkflowRunSummary `json:"workflowRunSummary"` } // V1EventList defines model for V1EventList. @@ -1616,6 +1732,70 @@ type V1UpdateFilterRequest struct { Scope *string `json:"scope,omitempty"` } +// V1Webhook defines model for V1Webhook. +type V1Webhook struct { + AuthType V1WebhookAuthType `json:"authType"` + + // EventKeyExpression The CEL expression to use for the event key. This is used to create the event key from the webhook payload. + EventKeyExpression string `json:"eventKeyExpression"` + Metadata APIResourceMeta `json:"metadata"` + + // Name The name of the webhook + Name string `json:"name"` + SourceName V1WebhookSourceName `json:"sourceName"` + + // TenantId The ID of the tenant associated with this webhook. + TenantId string `json:"tenantId"` +} + +// V1WebhookAPIKeyAuth defines model for V1WebhookAPIKeyAuth. +type V1WebhookAPIKeyAuth struct { + // ApiKey The API key to use for authentication + ApiKey string `json:"apiKey"` + + // HeaderName The name of the header to use for the API key + HeaderName string `json:"headerName"` +} + +// V1WebhookAuthType defines model for V1WebhookAuthType. +type V1WebhookAuthType string + +// V1WebhookBasicAuth defines model for V1WebhookBasicAuth. +type V1WebhookBasicAuth struct { + // Password The password for basic auth + Password string `json:"password"` + + // Username The username for basic auth + Username string `json:"username"` +} + +// V1WebhookHMACAlgorithm defines model for V1WebhookHMACAlgorithm. +type V1WebhookHMACAlgorithm string + +// V1WebhookHMACAuth defines model for V1WebhookHMACAuth. +type V1WebhookHMACAuth struct { + Algorithm V1WebhookHMACAlgorithm `json:"algorithm"` + Encoding V1WebhookHMACEncoding `json:"encoding"` + + // SignatureHeaderName The name of the header to use for the HMAC signature + SignatureHeaderName string `json:"signatureHeaderName"` + + // SigningSecret The secret key used to sign the HMAC signature + SigningSecret string `json:"signingSecret"` +} + +// V1WebhookHMACEncoding defines model for V1WebhookHMACEncoding. +type V1WebhookHMACEncoding string + +// V1WebhookList defines model for V1WebhookList. +type V1WebhookList struct { + Pagination *PaginationResponse `json:"pagination,omitempty"` + Rows *[]V1Webhook `json:"rows,omitempty"` +} + +// V1WebhookSourceName defines model for V1WebhookSourceName. +type V1WebhookSourceName string + // V1WorkflowRun defines model for V1WorkflowRun. type V1WorkflowRun struct { // AdditionalMetadata Additional metadata for the task run. @@ -2157,6 +2337,21 @@ type V1TaskGetPointMetricsParams struct { FinishedBefore *time.Time `form:"finishedBefore,omitempty" json:"finishedBefore,omitempty"` } +// V1WebhookListParams defines parameters for V1WebhookList. +type V1WebhookListParams struct { + // Offset The number to skip + Offset *int64 `form:"offset,omitempty" json:"offset,omitempty"` + + // Limit The number to limit by + Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"` + + // SourceNames The source names to filter by + SourceNames *[]V1WebhookSourceName `form:"sourceNames,omitempty" json:"sourceNames,omitempty"` + + // WebhookNames The webhook names to filter by + WebhookNames *[]string `form:"webhookNames,omitempty" json:"webhookNames,omitempty"` +} + // V1WorkflowRunListParams defines parameters for V1WorkflowRunList. type V1WorkflowRunListParams struct { // Offset The number to skip @@ -2502,6 +2697,9 @@ type V1TaskCancelJSONRequestBody = V1CancelTaskRequest // V1TaskReplayJSONRequestBody defines body for V1TaskReplay for application/json ContentType. type V1TaskReplayJSONRequestBody = V1ReplayTaskRequest +// V1WebhookCreateJSONRequestBody defines body for V1WebhookCreate for application/json ContentType. +type V1WebhookCreateJSONRequestBody = V1CreateWebhookRequest + // V1WorkflowRunCreateJSONRequestBody defines body for V1WorkflowRunCreate for application/json ContentType. type V1WorkflowRunCreateJSONRequestBody = V1TriggerWorkflowRunRequest @@ -2580,6 +2778,94 @@ type WorkflowUpdateJSONRequestBody = WorkflowUpdateRequest // WorkflowRunCreateJSONRequestBody defines body for WorkflowRunCreate for application/json ContentType. type WorkflowRunCreateJSONRequestBody = TriggerWorkflowRunRequest +// AsV1CreateWebhookRequestBasicAuth returns the union data inside the V1CreateWebhookRequest as a V1CreateWebhookRequestBasicAuth +func (t V1CreateWebhookRequest) AsV1CreateWebhookRequestBasicAuth() (V1CreateWebhookRequestBasicAuth, error) { + var body V1CreateWebhookRequestBasicAuth + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1CreateWebhookRequestBasicAuth overwrites any union data inside the V1CreateWebhookRequest as the provided V1CreateWebhookRequestBasicAuth +func (t *V1CreateWebhookRequest) FromV1CreateWebhookRequestBasicAuth(v V1CreateWebhookRequestBasicAuth) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1CreateWebhookRequestBasicAuth performs a merge with any union data inside the V1CreateWebhookRequest, using the provided V1CreateWebhookRequestBasicAuth +func (t *V1CreateWebhookRequest) MergeV1CreateWebhookRequestBasicAuth(v V1CreateWebhookRequestBasicAuth) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +// AsV1CreateWebhookRequestAPIKey returns the union data inside the V1CreateWebhookRequest as a V1CreateWebhookRequestAPIKey +func (t V1CreateWebhookRequest) AsV1CreateWebhookRequestAPIKey() (V1CreateWebhookRequestAPIKey, error) { + var body V1CreateWebhookRequestAPIKey + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1CreateWebhookRequestAPIKey overwrites any union data inside the V1CreateWebhookRequest as the provided V1CreateWebhookRequestAPIKey +func (t *V1CreateWebhookRequest) FromV1CreateWebhookRequestAPIKey(v V1CreateWebhookRequestAPIKey) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1CreateWebhookRequestAPIKey performs a merge with any union data inside the V1CreateWebhookRequest, using the provided V1CreateWebhookRequestAPIKey +func (t *V1CreateWebhookRequest) MergeV1CreateWebhookRequestAPIKey(v V1CreateWebhookRequestAPIKey) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +// AsV1CreateWebhookRequestHMAC returns the union data inside the V1CreateWebhookRequest as a V1CreateWebhookRequestHMAC +func (t V1CreateWebhookRequest) AsV1CreateWebhookRequestHMAC() (V1CreateWebhookRequestHMAC, error) { + var body V1CreateWebhookRequestHMAC + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1CreateWebhookRequestHMAC overwrites any union data inside the V1CreateWebhookRequest as the provided V1CreateWebhookRequestHMAC +func (t *V1CreateWebhookRequest) FromV1CreateWebhookRequestHMAC(v V1CreateWebhookRequestHMAC) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1CreateWebhookRequestHMAC performs a merge with any union data inside the V1CreateWebhookRequest, using the provided V1CreateWebhookRequestHMAC +func (t *V1CreateWebhookRequest) MergeV1CreateWebhookRequestHMAC(v V1CreateWebhookRequestHMAC) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +func (t V1CreateWebhookRequest) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *V1CreateWebhookRequest) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -2755,6 +3041,23 @@ type ClientInterface interface { V1TaskReplay(ctx context.Context, tenant openapi_types.UUID, body V1TaskReplayJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1WebhookList request + V1WebhookList(ctx context.Context, tenant openapi_types.UUID, params *V1WebhookListParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // V1WebhookCreateWithBody request with any body + V1WebhookCreateWithBody(ctx context.Context, tenant openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + V1WebhookCreate(ctx context.Context, tenant openapi_types.UUID, body V1WebhookCreateJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // V1WebhookDelete request + V1WebhookDelete(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // V1WebhookGet request + V1WebhookGet(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // V1WebhookReceiveWithBody request with any body + V1WebhookReceiveWithBody(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1WorkflowRunList request V1WorkflowRunList(ctx context.Context, tenant openapi_types.UUID, params *V1WorkflowRunListParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -3507,6 +3810,78 @@ func (c *Client) V1TaskReplay(ctx context.Context, tenant openapi_types.UUID, bo return c.Client.Do(req) } +func (c *Client) V1WebhookList(ctx context.Context, tenant openapi_types.UUID, params *V1WebhookListParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1WebhookListRequest(c.Server, tenant, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) V1WebhookCreateWithBody(ctx context.Context, tenant openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1WebhookCreateRequestWithBody(c.Server, tenant, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) V1WebhookCreate(ctx context.Context, tenant openapi_types.UUID, body V1WebhookCreateJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1WebhookCreateRequest(c.Server, tenant, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) V1WebhookDelete(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1WebhookDeleteRequest(c.Server, tenant, v1Webhook) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) V1WebhookGet(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1WebhookGetRequest(c.Server, tenant, v1Webhook) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) V1WebhookReceiveWithBody(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1WebhookReceiveRequestWithBody(c.Server, tenant, v1Webhook, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) V1WorkflowRunList(ctx context.Context, tenant openapi_types.UUID, params *V1WorkflowRunListParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewV1WorkflowRunListRequest(c.Server, tenant, params) if err != nil { @@ -6417,6 +6792,282 @@ func NewV1TaskReplayRequestWithBody(server string, tenant openapi_types.UUID, co return req, nil } +// NewV1WebhookListRequest generates requests for V1WebhookList +func NewV1WebhookListRequest(server string, tenant openapi_types.UUID, params *V1WebhookListParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "tenant", runtime.ParamLocationPath, tenant) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/stable/tenants/%s/webhooks", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Offset != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Limit != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.SourceNames != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "sourceNames", runtime.ParamLocationQuery, *params.SourceNames); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.WebhookNames != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "webhookNames", runtime.ParamLocationQuery, *params.WebhookNames); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewV1WebhookCreateRequest calls the generic V1WebhookCreate builder with application/json body +func NewV1WebhookCreateRequest(server string, tenant openapi_types.UUID, body V1WebhookCreateJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewV1WebhookCreateRequestWithBody(server, tenant, "application/json", bodyReader) +} + +// NewV1WebhookCreateRequestWithBody generates requests for V1WebhookCreate with any type of body +func NewV1WebhookCreateRequestWithBody(server string, tenant openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "tenant", runtime.ParamLocationPath, tenant) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/stable/tenants/%s/webhooks", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewV1WebhookDeleteRequest generates requests for V1WebhookDelete +func NewV1WebhookDeleteRequest(server string, tenant openapi_types.UUID, v1Webhook string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "tenant", runtime.ParamLocationPath, tenant) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "v1-webhook", runtime.ParamLocationPath, v1Webhook) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/stable/tenants/%s/webhooks/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewV1WebhookGetRequest generates requests for V1WebhookGet +func NewV1WebhookGetRequest(server string, tenant openapi_types.UUID, v1Webhook string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "tenant", runtime.ParamLocationPath, tenant) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "v1-webhook", runtime.ParamLocationPath, v1Webhook) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/stable/tenants/%s/webhooks/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewV1WebhookReceiveRequestWithBody generates requests for V1WebhookReceive with any type of body +func NewV1WebhookReceiveRequestWithBody(server string, tenant openapi_types.UUID, v1Webhook string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "tenant", runtime.ParamLocationPath, tenant) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "v1-webhook", runtime.ParamLocationPath, v1Webhook) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/stable/tenants/%s/webhooks/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewV1WorkflowRunListRequest generates requests for V1WorkflowRunList func NewV1WorkflowRunListRequest(server string, tenant openapi_types.UUID, params *V1WorkflowRunListParams) (*http.Request, error) { var err error @@ -11482,6 +12133,23 @@ type ClientWithResponsesInterface interface { V1TaskReplayWithResponse(ctx context.Context, tenant openapi_types.UUID, body V1TaskReplayJSONRequestBody, reqEditors ...RequestEditorFn) (*V1TaskReplayResponse, error) + // V1WebhookListWithResponse request + V1WebhookListWithResponse(ctx context.Context, tenant openapi_types.UUID, params *V1WebhookListParams, reqEditors ...RequestEditorFn) (*V1WebhookListResponse, error) + + // V1WebhookCreateWithBodyWithResponse request with any body + V1WebhookCreateWithBodyWithResponse(ctx context.Context, tenant openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1WebhookCreateResponse, error) + + V1WebhookCreateWithResponse(ctx context.Context, tenant openapi_types.UUID, body V1WebhookCreateJSONRequestBody, reqEditors ...RequestEditorFn) (*V1WebhookCreateResponse, error) + + // V1WebhookDeleteWithResponse request + V1WebhookDeleteWithResponse(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, reqEditors ...RequestEditorFn) (*V1WebhookDeleteResponse, error) + + // V1WebhookGetWithResponse request + V1WebhookGetWithResponse(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, reqEditors ...RequestEditorFn) (*V1WebhookGetResponse, error) + + // V1WebhookReceiveWithBodyWithResponse request with any body + V1WebhookReceiveWithBodyWithResponse(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1WebhookReceiveResponse, error) + // V1WorkflowRunListWithResponse request V1WorkflowRunListWithResponse(ctx context.Context, tenant openapi_types.UUID, params *V1WorkflowRunListParams, reqEditors ...RequestEditorFn) (*V1WorkflowRunListResponse, error) @@ -12523,6 +13191,130 @@ func (r V1TaskReplayResponse) StatusCode() int { return 0 } +type V1WebhookListResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *V1WebhookList + JSON400 *APIErrors + JSON403 *APIErrors +} + +// Status returns HTTPResponse.Status +func (r V1WebhookListResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1WebhookListResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type V1WebhookCreateResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *V1Webhook + JSON400 *APIErrors + JSON403 *APIErrors + JSON404 *APIErrors +} + +// Status returns HTTPResponse.Status +func (r V1WebhookCreateResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1WebhookCreateResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type V1WebhookDeleteResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *V1Webhook + JSON400 *APIErrors + JSON403 *APIErrors + JSON404 *APIErrors +} + +// Status returns HTTPResponse.Status +func (r V1WebhookDeleteResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1WebhookDeleteResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type V1WebhookGetResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *V1Webhook + JSON400 *APIErrors + JSON403 *APIErrors +} + +// Status returns HTTPResponse.Status +func (r V1WebhookGetResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1WebhookGetResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type V1WebhookReceiveResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *struct { + Message *string `json:"message,omitempty"` + } + JSON400 *APIErrors + JSON403 *APIErrors +} + +// Status returns HTTPResponse.Status +func (r V1WebhookReceiveResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1WebhookReceiveResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type V1WorkflowRunListResponse struct { Body []byte HTTPResponse *http.Response @@ -15001,6 +15793,59 @@ func (c *ClientWithResponses) V1TaskReplayWithResponse(ctx context.Context, tena return ParseV1TaskReplayResponse(rsp) } +// V1WebhookListWithResponse request returning *V1WebhookListResponse +func (c *ClientWithResponses) V1WebhookListWithResponse(ctx context.Context, tenant openapi_types.UUID, params *V1WebhookListParams, reqEditors ...RequestEditorFn) (*V1WebhookListResponse, error) { + rsp, err := c.V1WebhookList(ctx, tenant, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1WebhookListResponse(rsp) +} + +// V1WebhookCreateWithBodyWithResponse request with arbitrary body returning *V1WebhookCreateResponse +func (c *ClientWithResponses) V1WebhookCreateWithBodyWithResponse(ctx context.Context, tenant openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1WebhookCreateResponse, error) { + rsp, err := c.V1WebhookCreateWithBody(ctx, tenant, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1WebhookCreateResponse(rsp) +} + +func (c *ClientWithResponses) V1WebhookCreateWithResponse(ctx context.Context, tenant openapi_types.UUID, body V1WebhookCreateJSONRequestBody, reqEditors ...RequestEditorFn) (*V1WebhookCreateResponse, error) { + rsp, err := c.V1WebhookCreate(ctx, tenant, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1WebhookCreateResponse(rsp) +} + +// V1WebhookDeleteWithResponse request returning *V1WebhookDeleteResponse +func (c *ClientWithResponses) V1WebhookDeleteWithResponse(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, reqEditors ...RequestEditorFn) (*V1WebhookDeleteResponse, error) { + rsp, err := c.V1WebhookDelete(ctx, tenant, v1Webhook, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1WebhookDeleteResponse(rsp) +} + +// V1WebhookGetWithResponse request returning *V1WebhookGetResponse +func (c *ClientWithResponses) V1WebhookGetWithResponse(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, reqEditors ...RequestEditorFn) (*V1WebhookGetResponse, error) { + rsp, err := c.V1WebhookGet(ctx, tenant, v1Webhook, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1WebhookGetResponse(rsp) +} + +// V1WebhookReceiveWithBodyWithResponse request with arbitrary body returning *V1WebhookReceiveResponse +func (c *ClientWithResponses) V1WebhookReceiveWithBodyWithResponse(ctx context.Context, tenant openapi_types.UUID, v1Webhook string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*V1WebhookReceiveResponse, error) { + rsp, err := c.V1WebhookReceiveWithBody(ctx, tenant, v1Webhook, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1WebhookReceiveResponse(rsp) +} + // V1WorkflowRunListWithResponse request returning *V1WorkflowRunListResponse func (c *ClientWithResponses) V1WorkflowRunListWithResponse(ctx context.Context, tenant openapi_types.UUID, params *V1WorkflowRunListParams, reqEditors ...RequestEditorFn) (*V1WorkflowRunListResponse, error) { rsp, err := c.V1WorkflowRunList(ctx, tenant, params, reqEditors...) @@ -17211,6 +18056,222 @@ func ParseV1TaskReplayResponse(rsp *http.Response) (*V1TaskReplayResponse, error return response, nil } +// ParseV1WebhookListResponse parses an HTTP response from a V1WebhookListWithResponse call +func ParseV1WebhookListResponse(rsp *http.Response) (*V1WebhookListResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1WebhookListResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest V1WebhookList + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + } + + return response, nil +} + +// ParseV1WebhookCreateResponse parses an HTTP response from a V1WebhookCreateWithResponse call +func ParseV1WebhookCreateResponse(rsp *http.Response) (*V1WebhookCreateResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1WebhookCreateResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest V1Webhook + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + return response, nil +} + +// ParseV1WebhookDeleteResponse parses an HTTP response from a V1WebhookDeleteWithResponse call +func ParseV1WebhookDeleteResponse(rsp *http.Response) (*V1WebhookDeleteResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1WebhookDeleteResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest V1Webhook + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + return response, nil +} + +// ParseV1WebhookGetResponse parses an HTTP response from a V1WebhookGetWithResponse call +func ParseV1WebhookGetResponse(rsp *http.Response) (*V1WebhookGetResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1WebhookGetResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest V1Webhook + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + } + + return response, nil +} + +// ParseV1WebhookReceiveResponse parses an HTTP response from a V1WebhookReceiveWithResponse call +func ParseV1WebhookReceiveResponse(rsp *http.Response) (*V1WebhookReceiveResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1WebhookReceiveResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Message *string `json:"message,omitempty"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest APIErrors + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + } + + return response, nil +} + // ParseV1WorkflowRunListResponse parses an HTTP response from a V1WorkflowRunListWithResponse call func ParseV1WorkflowRunListResponse(rsp *http.Response) (*V1WorkflowRunListResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/pkg/config/server/server.go b/pkg/config/server/server.go index 64461148b..5ba906d74 100644 --- a/pkg/config/server/server.go +++ b/pkg/config/server/server.go @@ -134,6 +134,12 @@ type ConfigFileRuntime struct { // GRPCRateLimit is the rate limit for the grpc server. We count limits separately for the Workflow, Dispatcher and Events services. Workflow and Events service are set to this rate, Dispatcher is 10X this rate. The rate limit is per second, per engine, per api token. GRPCRateLimit float64 `mapstructure:"grpcRateLimit" json:"grpcRateLimit,omitempty" default:"1000"` + // WebhookRateLimit is the rate limit for webhook endpoints per second, per webhook + WebhookRateLimit float64 `mapstructure:"webhookRateLimit" json:"webhookRateLimit,omitempty" default:"50"` + + // WebhookRateLimitBurst is the burst size for webhook rate limiting + WebhookRateLimitBurst int `mapstructure:"webhookRateLimitBurst" json:"webhookRateLimitBurst,omitempty" default:"100"` + // ShutdownWait is the time between the readiness probe being offline when a shutdown is triggered and the actual start of cleaning up resources. ShutdownWait time.Duration `mapstructure:"shutdownWait" json:"shutdownWait,omitempty" default:"20s"` @@ -274,6 +280,9 @@ type LimitConfigFile struct { DefaultScheduleLimit int `mapstructure:"defaultScheduleLimit" json:"defaultScheduleLimit,omitempty" default:"1000"` DefaultScheduleAlarmLimit int `mapstructure:"defaultScheduleAlarmLimit" json:"defaultScheduleAlarmLimit,omitempty" default:"750"` + + DefaultIncomingWebhookLimit int `mapstructure:"defaultIncomingWebhookLimit" json:"defaultIncomingWebhookLimit,omitempty" default:"5"` + DefaultIncomingWebhookAlarmLimit int `mapstructure:"defaultIncomingWebhookAlarmLimit" json:"defaultIncomingWebhookALarmLimit,omitempty" default:"4"` } // Alerting options @@ -571,6 +580,8 @@ func BindAllEnv(v *viper.Viper) { _ = v.BindEnv("runtime.grpcInsecure", "SERVER_GRPC_INSECURE") _ = v.BindEnv("runtime.grpcMaxMsgSize", "SERVER_GRPC_MAX_MSG_SIZE") _ = v.BindEnv("runtime.grpcRateLimit", "SERVER_GRPC_RATE_LIMIT") + _ = v.BindEnv("runtime.webhookRateLimit", "SERVER_INCOMING_WEBHOOK_RATE_LIMIT") + _ = v.BindEnv("runtime.webhookRateLimitBurst", "SERVER_INCOMING_WEBHOOK_RATE_LIMIT_BURST") _ = v.BindEnv("runtime.schedulerConcurrencyRateLimit", "SCHEDULER_CONCURRENCY_RATE_LIMIT") _ = v.BindEnv("runtime.shutdownWait", "SERVER_SHUTDOWN_WAIT") _ = v.BindEnv("servicesString", "SERVER_SERVICES") @@ -621,6 +632,8 @@ func BindAllEnv(v *viper.Viper) { _ = v.BindEnv("runtime.limits.defaultScheduleLimit", "SERVER_LIMITS_DEFAULT_SCHEDULE_LIMIT") _ = v.BindEnv("runtime.limits.defaultScheduleAlarmLimit", "SERVER_LIMITS_DEFAULT_SCHEDULE_ALARM_LIMIT") + _ = v.BindEnv("runtime.limits.defaultIncomingWebhookLimit", "SERVER_LIMITS_DEFAULT_INCOMING_WEBHOOK_LIMIT") + // buffer options _ = v.BindEnv("runtime.workflowRunBuffer.waitForFlush", "SERVER_WORKFLOWRUNBUFFER_WAIT_FOR_FLUSH") _ = v.BindEnv("runtime.workflowRunBuffer.maxConcurrent", "SERVER_WORKFLOWRUNBUFFER_MAX_CONCURRENT") diff --git a/pkg/repository/event.go b/pkg/repository/event.go index 1774a649f..5eb1c5933 100644 --- a/pkg/repository/event.go +++ b/pkg/repository/event.go @@ -33,6 +33,9 @@ type CreateEventOpts struct { // (optional) the event scope Scope *string `validate:"omitempty"` + + // (optional) the triggering webhook name + TriggeringWebhookName *string `validate:"omitempty"` } type ListEventOpts struct { diff --git a/pkg/repository/postgres/dbsqlc/models.go b/pkg/repository/postgres/dbsqlc/models.go index f6bd95a56..3f0f19365 100644 --- a/pkg/repository/postgres/dbsqlc/models.go +++ b/pkg/repository/postgres/dbsqlc/models.go @@ -278,13 +278,14 @@ func (ns NullLeaseKind) Value() (driver.Value, error) { type LimitResource string const ( - LimitResourceWORKFLOWRUN LimitResource = "WORKFLOW_RUN" - LimitResourceTASKRUN LimitResource = "TASK_RUN" - LimitResourceEVENT LimitResource = "EVENT" - LimitResourceWORKER LimitResource = "WORKER" - LimitResourceWORKERSLOT LimitResource = "WORKER_SLOT" - LimitResourceCRON LimitResource = "CRON" - LimitResourceSCHEDULE LimitResource = "SCHEDULE" + LimitResourceWORKFLOWRUN LimitResource = "WORKFLOW_RUN" + LimitResourceTASKRUN LimitResource = "TASK_RUN" + LimitResourceEVENT LimitResource = "EVENT" + LimitResourceWORKER LimitResource = "WORKER" + LimitResourceWORKERSLOT LimitResource = "WORKER_SLOT" + LimitResourceCRON LimitResource = "CRON" + LimitResourceSCHEDULE LimitResource = "SCHEDULE" + LimitResourceINCOMINGWEBHOOK LimitResource = "INCOMING_WEBHOOK" ) func (e *LimitResource) Scan(src interface{}) error { diff --git a/pkg/repository/postgres/dbsqlc/tenant_limits.sql b/pkg/repository/postgres/dbsqlc/tenant_limits.sql index 3aa46e1c8..9686adf3b 100644 --- a/pkg/repository/postgres/dbsqlc/tenant_limits.sql +++ b/pkg/repository/postgres/dbsqlc/tenant_limits.sql @@ -39,8 +39,7 @@ WITH existing AS ( SELECT * FROM "TenantResourceLimit" WHERE "tenantId" = @tenantId::uuid AND "resource" = sqlc.narg('resource')::"LimitResource" -) -, insert_row AS ( +), insert_row AS ( INSERT INTO "TenantResourceLimit" ("id", "tenantId", "resource", "value", "limitValue", "alarmValue", "window", "lastRefill", "customValueMeter") SELECT gen_random_uuid(), @tenantId::uuid, sqlc.narg('resource')::"LimitResource", 0, sqlc.narg('limitValue')::int, sqlc.narg('alarmValue')::int, sqlc.narg('window')::text, CURRENT_TIMESTAMP, COALESCE(sqlc.narg('customValueMeter')::boolean, false) WHERE NOT EXISTS (SELECT 1 FROM existing) diff --git a/pkg/repository/postgres/dbsqlc/tenant_limits.sql.go b/pkg/repository/postgres/dbsqlc/tenant_limits.sql.go index d59df1fea..a89b1272e 100644 --- a/pkg/repository/postgres/dbsqlc/tenant_limits.sql.go +++ b/pkg/repository/postgres/dbsqlc/tenant_limits.sql.go @@ -246,8 +246,7 @@ WITH existing AS ( SELECT id, "createdAt", "updatedAt", resource, "tenantId", "limitValue", "alarmValue", value, "window", "lastRefill", "customValueMeter" FROM "TenantResourceLimit" WHERE "tenantId" = $1::uuid AND "resource" = $2::"LimitResource" -) -, insert_row AS ( +), insert_row AS ( INSERT INTO "TenantResourceLimit" ("id", "tenantId", "resource", "value", "limitValue", "alarmValue", "window", "lastRefill", "customValueMeter") SELECT gen_random_uuid(), $1::uuid, $2::"LimitResource", 0, $3::int, $4::int, $5::text, CURRENT_TIMESTAMP, COALESCE($6::boolean, false) WHERE NOT EXISTS (SELECT 1 FROM existing) diff --git a/pkg/repository/postgres/tenant_limit.go b/pkg/repository/postgres/tenant_limit.go index be8e8bf44..0813fbcea 100644 --- a/pkg/repository/postgres/tenant_limit.go +++ b/pkg/repository/postgres/tenant_limit.go @@ -85,6 +85,13 @@ func (t *tenantLimitRepository) DefaultLimits() []repository.Limit { Window: nil, CustomValueMeter: true, }, + { + Resource: dbsqlc.LimitResourceINCOMINGWEBHOOK, + Limit: int32(t.config.Limits.DefaultIncomingWebhookLimit), // nolint: gosec + Alarm: int32(t.config.Limits.DefaultIncomingWebhookAlarmLimit), // nolint: gosec + Window: nil, + CustomValueMeter: true, + }, } } diff --git a/pkg/repository/v1/olap.go b/pkg/repository/v1/olap.go index fc34a4438..5132b787e 100644 --- a/pkg/repository/v1/olap.go +++ b/pkg/repository/v1/olap.go @@ -234,6 +234,9 @@ type OLAPRepository interface { GetDagDurationsByDagIds(ctx context.Context, tenantId string, dagIds []int64, dagInsertedAts []pgtype.Timestamptz, readableStatuses []sqlcv1.V1ReadableStatusOlap) (map[string]*sqlcv1.GetDagDurationsByDagIdsRow, error) GetTaskDurationsByTaskIds(ctx context.Context, tenantId string, taskIds []int64, taskInsertedAts []pgtype.Timestamptz, readableStatuses []sqlcv1.V1ReadableStatusOlap) (map[int64]*sqlcv1.GetTaskDurationsByTaskIdsRow, error) + + CreateIncomingWebhookValidationFailureLogs(ctx context.Context, tenantId string, opts []CreateIncomingWebhookFailureLogOpts) error + StoreCELEvaluationFailures(ctx context.Context, tenantId string, failures []CELEvaluationFailure) error } type OLAPRepositoryImpl struct { @@ -1588,6 +1591,7 @@ type ListEventsRow struct { CancelledCount int64 `json:"cancelled_count"` FailedCount int64 `json:"failed_count"` TriggeredRuns []byte `json:"triggered_runs"` + TriggeringWebhookName *string `json:"triggering_webhook_name,omitempty"` } func (r *OLAPRepositoryImpl) ListEvents(ctx context.Context, opts sqlcv1.ListEventsParams) ([]*ListEventsRow, *int64, error) { @@ -1638,8 +1642,14 @@ func (r *OLAPRepositoryImpl) ListEvents(ctx context.Context, opts sqlcv1.ListEve for _, event := range events { data, exists := externalIdToEventData[event.ExternalID] + var triggeringWebhookName *string + + if event.TriggeringWebhookName.Valid { + triggeringWebhookName = &event.TriggeringWebhookName.String + } if !exists || len(data) == 0 { + result = append(result, &ListEventsRow{ TenantID: event.TenantID, EventID: event.ID, @@ -1654,6 +1664,7 @@ func (r *OLAPRepositoryImpl) ListEvents(ctx context.Context, opts sqlcv1.ListEve CompletedCount: 0, CancelledCount: 0, FailedCount: 0, + TriggeringWebhookName: triggeringWebhookName, }) } else { for _, d := range data { @@ -1672,6 +1683,7 @@ func (r *OLAPRepositoryImpl) ListEvents(ctx context.Context, opts sqlcv1.ListEve CancelledCount: d.CancelledCount, FailedCount: d.FailedCount, TriggeredRuns: d.TriggeredRuns, + TriggeringWebhookName: triggeringWebhookName, }) } } @@ -1730,3 +1742,47 @@ func (r *OLAPRepositoryImpl) GetTaskDurationsByTaskIds(ctx context.Context, tena return taskDurations, nil } + +type CreateIncomingWebhookFailureLogOpts struct { + WebhookName string + ErrorText string +} + +func (r *OLAPRepositoryImpl) CreateIncomingWebhookValidationFailureLogs(ctx context.Context, tenantId string, opts []CreateIncomingWebhookFailureLogOpts) error { + incomingWebhookNames := make([]string, len(opts)) + errors := make([]string, len(opts)) + + for i, opt := range opts { + incomingWebhookNames[i] = opt.WebhookName + errors[i] = opt.ErrorText + } + + params := sqlcv1.CreateIncomingWebhookValidationFailureLogsParams{ + Tenantid: sqlchelpers.UUIDFromStr(tenantId), + Incomingwebhooknames: incomingWebhookNames, + Errors: errors, + } + + return r.queries.CreateIncomingWebhookValidationFailureLogs(ctx, r.pool, params) +} + +type CELEvaluationFailure struct { + Source sqlcv1.V1CelEvaluationFailureSource `json:"source"` + ErrorMessage string `json:"error_message"` +} + +func (r *OLAPRepositoryImpl) StoreCELEvaluationFailures(ctx context.Context, tenantId string, failures []CELEvaluationFailure) error { + errorMessages := make([]string, len(failures)) + sources := make([]string, len(failures)) + + for i, failure := range failures { + errorMessages[i] = failure.ErrorMessage + sources[i] = string(failure.Source) + } + + return r.queries.StoreCELEvaluationFailures(ctx, r.pool, sqlcv1.StoreCELEvaluationFailuresParams{ + Tenantid: sqlchelpers.UUIDFromStr(tenantId), + Sources: sources, + Errors: errorMessages, + }) +} diff --git a/pkg/repository/v1/repository.go b/pkg/repository/v1/repository.go index 08cb99dd1..aa6be691e 100644 --- a/pkg/repository/v1/repository.go +++ b/pkg/repository/v1/repository.go @@ -23,6 +23,7 @@ type Repository interface { Workflows() WorkflowRepository Ticker() TickerRepository Filters() FilterRepository + Webhooks() WebhookRepository } type repositoryImpl struct { @@ -36,6 +37,7 @@ type repositoryImpl struct { workflows WorkflowRepository ticker TickerRepository filters FilterRepository + webhooks WebhookRepository } func NewRepository(pool *pgxpool.Pool, l *zerolog.Logger, taskRetentionPeriod, olapRetentionPeriod time.Duration, maxInternalRetryCount int32, entitlements repository.EntitlementsRepository) (Repository, func() error) { @@ -60,6 +62,7 @@ func NewRepository(pool *pgxpool.Pool, l *zerolog.Logger, taskRetentionPeriod, o workflows: newWorkflowRepository(shared), ticker: newTickerRepository(shared), filters: newFilterRepository(shared), + webhooks: newWebhookRepository(shared), } return impl, func() error { @@ -114,3 +117,7 @@ func (r *repositoryImpl) Ticker() TickerRepository { func (r *repositoryImpl) Filters() FilterRepository { return r.filters } + +func (r *repositoryImpl) Webhooks() WebhookRepository { + return r.webhooks +} diff --git a/pkg/repository/v1/sqlcv1/models.go b/pkg/repository/v1/sqlcv1/models.go index d12d45ebc..9fbab641c 100644 --- a/pkg/repository/v1/sqlcv1/models.go +++ b/pkg/repository/v1/sqlcv1/models.go @@ -278,13 +278,14 @@ func (ns NullLeaseKind) Value() (driver.Value, error) { type LimitResource string const ( - LimitResourceWORKFLOWRUN LimitResource = "WORKFLOW_RUN" - LimitResourceTASKRUN LimitResource = "TASK_RUN" - LimitResourceEVENT LimitResource = "EVENT" - LimitResourceWORKER LimitResource = "WORKER" - LimitResourceWORKERSLOT LimitResource = "WORKER_SLOT" - LimitResourceCRON LimitResource = "CRON" - LimitResourceSCHEDULE LimitResource = "SCHEDULE" + LimitResourceWORKFLOWRUN LimitResource = "WORKFLOW_RUN" + LimitResourceTASKRUN LimitResource = "TASK_RUN" + LimitResourceEVENT LimitResource = "EVENT" + LimitResourceWORKER LimitResource = "WORKER" + LimitResourceWORKERSLOT LimitResource = "WORKER_SLOT" + LimitResourceCRON LimitResource = "CRON" + LimitResourceSCHEDULE LimitResource = "SCHEDULE" + LimitResourceINCOMINGWEBHOOK LimitResource = "INCOMING_WEBHOOK" ) func (e *LimitResource) Scan(src interface{}) error { @@ -856,6 +857,48 @@ func (ns NullTenantResourceLimitAlertType) Value() (driver.Value, error) { return string(ns.TenantResourceLimitAlertType), nil } +type V1CelEvaluationFailureSource string + +const ( + V1CelEvaluationFailureSourceFILTER V1CelEvaluationFailureSource = "FILTER" + V1CelEvaluationFailureSourceWEBHOOK V1CelEvaluationFailureSource = "WEBHOOK" +) + +func (e *V1CelEvaluationFailureSource) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = V1CelEvaluationFailureSource(s) + case string: + *e = V1CelEvaluationFailureSource(s) + default: + return fmt.Errorf("unsupported scan type for V1CelEvaluationFailureSource: %T", src) + } + return nil +} + +type NullV1CelEvaluationFailureSource struct { + V1CelEvaluationFailureSource V1CelEvaluationFailureSource `json:"v1_cel_evaluation_failure_source"` + Valid bool `json:"valid"` // Valid is true if V1CelEvaluationFailureSource is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullV1CelEvaluationFailureSource) Scan(value interface{}) error { + if value == nil { + ns.V1CelEvaluationFailureSource, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.V1CelEvaluationFailureSource.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullV1CelEvaluationFailureSource) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.V1CelEvaluationFailureSource), nil +} + type V1ConcurrencyStrategy string const ( @@ -1002,6 +1045,179 @@ func (ns NullV1EventTypeOlap) Value() (driver.Value, error) { return string(ns.V1EventTypeOlap), nil } +type V1IncomingWebhookAuthType string + +const ( + V1IncomingWebhookAuthTypeBASIC V1IncomingWebhookAuthType = "BASIC" + V1IncomingWebhookAuthTypeAPIKEY V1IncomingWebhookAuthType = "API_KEY" + V1IncomingWebhookAuthTypeHMAC V1IncomingWebhookAuthType = "HMAC" +) + +func (e *V1IncomingWebhookAuthType) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = V1IncomingWebhookAuthType(s) + case string: + *e = V1IncomingWebhookAuthType(s) + default: + return fmt.Errorf("unsupported scan type for V1IncomingWebhookAuthType: %T", src) + } + return nil +} + +type NullV1IncomingWebhookAuthType struct { + V1IncomingWebhookAuthType V1IncomingWebhookAuthType `json:"v1_incoming_webhook_auth_type"` + Valid bool `json:"valid"` // Valid is true if V1IncomingWebhookAuthType is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullV1IncomingWebhookAuthType) Scan(value interface{}) error { + if value == nil { + ns.V1IncomingWebhookAuthType, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.V1IncomingWebhookAuthType.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullV1IncomingWebhookAuthType) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.V1IncomingWebhookAuthType), nil +} + +type V1IncomingWebhookHmacAlgorithm string + +const ( + V1IncomingWebhookHmacAlgorithmSHA1 V1IncomingWebhookHmacAlgorithm = "SHA1" + V1IncomingWebhookHmacAlgorithmSHA256 V1IncomingWebhookHmacAlgorithm = "SHA256" + V1IncomingWebhookHmacAlgorithmSHA512 V1IncomingWebhookHmacAlgorithm = "SHA512" + V1IncomingWebhookHmacAlgorithmMD5 V1IncomingWebhookHmacAlgorithm = "MD5" +) + +func (e *V1IncomingWebhookHmacAlgorithm) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = V1IncomingWebhookHmacAlgorithm(s) + case string: + *e = V1IncomingWebhookHmacAlgorithm(s) + default: + return fmt.Errorf("unsupported scan type for V1IncomingWebhookHmacAlgorithm: %T", src) + } + return nil +} + +type NullV1IncomingWebhookHmacAlgorithm struct { + V1IncomingWebhookHmacAlgorithm V1IncomingWebhookHmacAlgorithm `json:"v1_incoming_webhook_hmac_algorithm"` + Valid bool `json:"valid"` // Valid is true if V1IncomingWebhookHmacAlgorithm is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullV1IncomingWebhookHmacAlgorithm) Scan(value interface{}) error { + if value == nil { + ns.V1IncomingWebhookHmacAlgorithm, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.V1IncomingWebhookHmacAlgorithm.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullV1IncomingWebhookHmacAlgorithm) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.V1IncomingWebhookHmacAlgorithm), nil +} + +type V1IncomingWebhookHmacEncoding string + +const ( + V1IncomingWebhookHmacEncodingHEX V1IncomingWebhookHmacEncoding = "HEX" + V1IncomingWebhookHmacEncodingBASE64 V1IncomingWebhookHmacEncoding = "BASE64" + V1IncomingWebhookHmacEncodingBASE64URL V1IncomingWebhookHmacEncoding = "BASE64URL" +) + +func (e *V1IncomingWebhookHmacEncoding) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = V1IncomingWebhookHmacEncoding(s) + case string: + *e = V1IncomingWebhookHmacEncoding(s) + default: + return fmt.Errorf("unsupported scan type for V1IncomingWebhookHmacEncoding: %T", src) + } + return nil +} + +type NullV1IncomingWebhookHmacEncoding struct { + V1IncomingWebhookHmacEncoding V1IncomingWebhookHmacEncoding `json:"v1_incoming_webhook_hmac_encoding"` + Valid bool `json:"valid"` // Valid is true if V1IncomingWebhookHmacEncoding is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullV1IncomingWebhookHmacEncoding) Scan(value interface{}) error { + if value == nil { + ns.V1IncomingWebhookHmacEncoding, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.V1IncomingWebhookHmacEncoding.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullV1IncomingWebhookHmacEncoding) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.V1IncomingWebhookHmacEncoding), nil +} + +type V1IncomingWebhookSourceName string + +const ( + V1IncomingWebhookSourceNameGENERIC V1IncomingWebhookSourceName = "GENERIC" + V1IncomingWebhookSourceNameGITHUB V1IncomingWebhookSourceName = "GITHUB" + V1IncomingWebhookSourceNameSTRIPE V1IncomingWebhookSourceName = "STRIPE" +) + +func (e *V1IncomingWebhookSourceName) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = V1IncomingWebhookSourceName(s) + case string: + *e = V1IncomingWebhookSourceName(s) + default: + return fmt.Errorf("unsupported scan type for V1IncomingWebhookSourceName: %T", src) + } + return nil +} + +type NullV1IncomingWebhookSourceName struct { + V1IncomingWebhookSourceName V1IncomingWebhookSourceName `json:"v1_incoming_webhook_source_name"` + Valid bool `json:"valid"` // Valid is true if V1IncomingWebhookSourceName is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullV1IncomingWebhookSourceName) Scan(value interface{}) error { + if value == nil { + ns.V1IncomingWebhookSourceName, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.V1IncomingWebhookSourceName.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullV1IncomingWebhookSourceName) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.V1IncomingWebhookSourceName), nil +} + type V1LogLineLevel string const ( @@ -2446,6 +2662,15 @@ type UserSession struct { ExpiresAt pgtype.Timestamp `json:"expiresAt"` } +type V1CelEvaluationFailuresOlap struct { + ID int64 `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + Source V1CelEvaluationFailureSource `json:"source"` + Error string `json:"error"` + InsertedAt pgtype.Timestamptz `json:"inserted_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type V1ConcurrencySlot struct { SortID pgtype.Int8 `json:"sort_id"` TaskID int64 `json:"task_id"` @@ -2538,14 +2763,15 @@ type V1EventToRunOlap struct { } type V1EventsOlap struct { - TenantID pgtype.UUID `json:"tenant_id"` - ID int64 `json:"id"` - ExternalID pgtype.UUID `json:"external_id"` - SeenAt pgtype.Timestamptz `json:"seen_at"` - Key string `json:"key"` - Payload []byte `json:"payload"` - AdditionalMetadata []byte `json:"additional_metadata"` - Scope pgtype.Text `json:"scope"` + TenantID pgtype.UUID `json:"tenant_id"` + ID int64 `json:"id"` + ExternalID pgtype.UUID `json:"external_id"` + SeenAt pgtype.Timestamptz `json:"seen_at"` + Key string `json:"key"` + Payload []byte `json:"payload"` + AdditionalMetadata []byte `json:"additional_metadata"` + Scope pgtype.Text `json:"scope"` + TriggeringWebhookName pgtype.Text `json:"triggering_webhook_name"` } type V1Filter struct { @@ -2561,6 +2787,33 @@ type V1Filter struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type V1IncomingWebhook struct { + TenantID pgtype.UUID `json:"tenant_id"` + Name string `json:"name"` + SourceName V1IncomingWebhookSourceName `json:"source_name"` + EventKeyExpression string `json:"event_key_expression"` + AuthMethod V1IncomingWebhookAuthType `json:"auth_method"` + AuthBasicUsername pgtype.Text `json:"auth__basic__username"` + AuthBasicPassword []byte `json:"auth__basic__password"` + AuthApiKeyHeaderName pgtype.Text `json:"auth__api_key__header_name"` + AuthApiKeyKey []byte `json:"auth__api_key__key"` + AuthHmacAlgorithm NullV1IncomingWebhookHmacAlgorithm `json:"auth__hmac__algorithm"` + AuthHmacEncoding NullV1IncomingWebhookHmacEncoding `json:"auth__hmac__encoding"` + AuthHmacSignatureHeaderName pgtype.Text `json:"auth__hmac__signature_header_name"` + AuthHmacWebhookSigningSecret []byte `json:"auth__hmac__webhook_signing_secret"` + InsertedAt pgtype.Timestamptz `json:"inserted_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type V1IncomingWebhookValidationFailuresOlap struct { + ID int64 `json:"id"` + TenantID pgtype.UUID `json:"tenant_id"` + IncomingWebhookName string `json:"incoming_webhook_name"` + Error string `json:"error"` + InsertedAt pgtype.Timestamptz `json:"inserted_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type V1LogLine struct { ID int64 `json:"id"` CreatedAt pgtype.Timestamptz `json:"created_at"` diff --git a/pkg/repository/v1/sqlcv1/olap-overwrite.sql b/pkg/repository/v1/sqlcv1/olap-overwrite.sql index 162fe39d7..e81ee0325 100644 --- a/pkg/repository/v1/sqlcv1/olap-overwrite.sql +++ b/pkg/repository/v1/sqlcv1/olap-overwrite.sql @@ -204,7 +204,9 @@ WITH to_insert AS ( UNNEST(@payloads::JSONB[]) AS payload, UNNEST(@additionalMetadatas::JSONB[]) AS additional_metadata, -- Scopes are nullable - UNNEST(@scopes::TEXT[]) AS scope + UNNEST(@scopes::TEXT[]) AS scope, + -- Webhook names are nullable + UNNEST(@triggeringWebhookName::TEXT[]) AS triggering_webhook_name ) INSERT INTO v1_events_olap ( tenant_id, @@ -213,7 +215,8 @@ INSERT INTO v1_events_olap ( key, payload, additional_metadata, - scope + scope, + triggering_webhook_name ) SELECT * FROM to_insert diff --git a/pkg/repository/v1/sqlcv1/olap-overwrite.sql.go b/pkg/repository/v1/sqlcv1/olap-overwrite.sql.go index dbb687369..4c3c8b6bb 100644 --- a/pkg/repository/v1/sqlcv1/olap-overwrite.sql.go +++ b/pkg/repository/v1/sqlcv1/olap-overwrite.sql.go @@ -376,7 +376,10 @@ WITH to_insert AS ( UNNEST($5::JSONB[]) AS payload, UNNEST($6::JSONB[]) AS additional_metadata, -- Scopes are nullable - UNNEST($7::TEXT[]) AS scope + UNNEST($7::TEXT[]) AS scope, + -- Webhook names are nullable + UNNEST($8::TEXT[]) AS triggering_webhook_name + ) INSERT INTO v1_events_olap ( tenant_id, @@ -385,21 +388,23 @@ INSERT INTO v1_events_olap ( key, payload, additional_metadata, - scope + scope, + triggering_webhook_name ) -SELECT tenant_id, external_id, seen_at, key, payload, additional_metadata, scope +SELECT tenant_id, external_id, seen_at, key, payload, additional_metadata, scope, triggering_webhook_name FROM to_insert -RETURNING tenant_id, id, external_id, seen_at, key, payload, additional_metadata, scope +RETURNING tenant_id, id, external_id, seen_at, key, payload, additional_metadata, scope, triggering_webhook_name ` type BulkCreateEventsParams struct { - Tenantids []pgtype.UUID `json:"tenantids"` - Externalids []pgtype.UUID `json:"externalids"` - Seenats []pgtype.Timestamptz `json:"seenats"` - Keys []string `json:"keys"` - Payloads [][]byte `json:"payloads"` - Additionalmetadatas [][]byte `json:"additionalmetadatas"` - Scopes []*string `json:"scopes"` + Tenantids []pgtype.UUID `json:"tenantids"` + Externalids []pgtype.UUID `json:"externalids"` + Seenats []pgtype.Timestamptz `json:"seenats"` + Keys []string `json:"keys"` + Payloads [][]byte `json:"payloads"` + Additionalmetadatas [][]byte `json:"additionalmetadatas"` + Scopes []pgtype.Text `json:"scopes"` + TriggeringWebhookNames []pgtype.Text `json:"triggeringWebhookName"` } func (q *Queries) BulkCreateEvents(ctx context.Context, db DBTX, arg BulkCreateEventsParams) ([]*V1EventsOlap, error) { @@ -411,6 +416,7 @@ func (q *Queries) BulkCreateEvents(ctx context.Context, db DBTX, arg BulkCreateE arg.Payloads, arg.Additionalmetadatas, arg.Scopes, + arg.TriggeringWebhookNames, ) if err != nil { return nil, err @@ -428,6 +434,7 @@ func (q *Queries) BulkCreateEvents(ctx context.Context, db DBTX, arg BulkCreateE &i.Payload, &i.AdditionalMetadata, &i.Scope, + &i.TriggeringWebhookName, ); err != nil { return nil, err } diff --git a/pkg/repository/v1/sqlcv1/olap.sql b/pkg/repository/v1/sqlcv1/olap.sql index 38bdabba5..ef5d8547c 100644 --- a/pkg/repository/v1/sqlcv1/olap.sql +++ b/pkg/repository/v1/sqlcv1/olap.sql @@ -11,7 +11,9 @@ SELECT -- name: CreateOLAPEventPartitions :exec SELECT create_v1_range_partition('v1_events_olap'::text, @date::date), - create_v1_range_partition('v1_event_to_run_olap'::text, @date::date) + create_v1_range_partition('v1_event_to_run_olap'::text, @date::date), + create_v1_range_partition('v1_incoming_webhook_validation_failures_olap'::text, @date::date), + create_v1_range_partition('v1_cel_evaluation_failures_olap'::text, @date::date) ; -- name: ListOLAPPartitionsBeforeDate :many @@ -27,6 +29,10 @@ WITH task_partitions AS ( SELECT 'v1_event_to_run_olap' AS parent_table, p::TEXT AS partition_name FROM get_v1_partitions_before_date('v1_event_to_run_olap', @date::date) AS p ), events_lookup_table_partitions AS ( SELECT 'v1_event_lookup_table_olap' AS parent_table, p::TEXT AS partition_name FROM get_v1_partitions_before_date('v1_event_lookup_table_olap', @date::date) AS p +), incoming_webhook_validation_failure_partitions AS ( + SELECT 'v1_incoming_webhook_validation_failures_olap' AS parent_table, p::TEXT AS partition_name FROM get_v1_partitions_before_date('v1_incoming_webhook_validation_failures_olap', @date::date) AS p +), cel_evaluation_failures_partitions AS ( + SELECT 'v1_cel_evaluation_failures_olap' AS parent_table, p::TEXT AS partition_name FROM get_v1_partitions_before_date('v1_cel_evaluation_failures_olap', @date::date) AS p ), candidates AS ( SELECT * @@ -67,6 +73,20 @@ WITH task_partitions AS ( * FROM events_lookup_table_partitions + + UNION ALL + + SELECT + * + FROM + incoming_webhook_validation_failure_partitions + + UNION ALL + + SELECT + * + FROM + cel_evaluation_failures_partitions ) SELECT * @@ -74,7 +94,7 @@ FROM candidates WHERE CASE WHEN @shouldPartitionEventsTables::BOOLEAN THEN TRUE - ELSE parent_table NOT IN ('v1_events_olap', 'v1_event_to_run_olap') + ELSE parent_table NOT IN ('v1_events_olap', 'v1_event_to_run_olap', 'v1_cel_evaluation_failures_olap', 'v1_incoming_webhook_validation_failures_olap') END ; @@ -1684,3 +1704,35 @@ FROM LEFT JOIN task_times tt ON (td.task_id, td.inserted_at) = (tt.task_id, tt.inserted_at) ORDER BY td.task_id, td.inserted_at; + +-- name: CreateIncomingWebhookValidationFailureLogs :exec +WITH inputs AS ( + SELECT + UNNEST(@incomingWebhookNames::TEXT[]) AS incoming_webhook_name, + UNNEST(@errors::TEXT[]) AS error +) +INSERT INTO v1_incoming_webhook_validation_failures_olap( + tenant_id, + incoming_webhook_name, + error +) +SELECT + @tenantId::UUID, + i.incoming_webhook_name, + i.error +FROM inputs i; + +-- name: StoreCELEvaluationFailures :exec +WITH inputs AS ( + SELECT + UNNEST(CAST(@sources::TEXT[] AS v1_cel_evaluation_failure_source[])) AS source, + UNNEST(@errors::TEXT[]) AS error +) +INSERT INTO v1_cel_evaluation_failures_olap ( + tenant_id, + source, + error +) +SELECT @tenantId::UUID, source, error +FROM inputs +; diff --git a/pkg/repository/v1/sqlcv1/olap.sql.go b/pkg/repository/v1/sqlcv1/olap.sql.go index cf6f614c4..414e78d9e 100644 --- a/pkg/repository/v1/sqlcv1/olap.sql.go +++ b/pkg/repository/v1/sqlcv1/olap.sql.go @@ -21,7 +21,7 @@ type BulkCreateEventTriggersParams struct { const countEvents = `-- name: CountEvents :one WITH included_events AS ( - SELECT e.tenant_id, e.id, e.external_id, e.seen_at, e.key, e.payload, e.additional_metadata, e.scope + SELECT e.tenant_id, e.id, e.external_id, e.seen_at, e.key, e.payload, e.additional_metadata, e.scope, e.triggering_webhook_name FROM v1_event_lookup_table_olap elt JOIN v1_events_olap e ON (elt.tenant_id, elt.event_id, elt.event_seen_at) = (e.tenant_id, e.id, e.seen_at) WHERE @@ -120,10 +120,41 @@ type CreateDAGsOLAPParams struct { TotalTasks int32 `json:"total_tasks"` } +const createIncomingWebhookValidationFailureLogs = `-- name: CreateIncomingWebhookValidationFailureLogs :exec +WITH inputs AS ( + SELECT + UNNEST($2::TEXT[]) AS incoming_webhook_name, + UNNEST($3::TEXT[]) AS error +) +INSERT INTO v1_incoming_webhook_validation_failures_olap( + tenant_id, + incoming_webhook_name, + error +) +SELECT + $1::UUID, + i.incoming_webhook_name, + i.error +FROM inputs i +` + +type CreateIncomingWebhookValidationFailureLogsParams struct { + Tenantid pgtype.UUID `json:"tenantid"` + Incomingwebhooknames []string `json:"incomingwebhooknames"` + Errors []string `json:"errors"` +} + +func (q *Queries) CreateIncomingWebhookValidationFailureLogs(ctx context.Context, db DBTX, arg CreateIncomingWebhookValidationFailureLogsParams) error { + _, err := db.Exec(ctx, createIncomingWebhookValidationFailureLogs, arg.Tenantid, arg.Incomingwebhooknames, arg.Errors) + return err +} + const createOLAPEventPartitions = `-- name: CreateOLAPEventPartitions :exec SELECT create_v1_range_partition('v1_events_olap'::text, $1::date), - create_v1_range_partition('v1_event_to_run_olap'::text, $1::date) + create_v1_range_partition('v1_event_to_run_olap'::text, $1::date), + create_v1_range_partition('v1_incoming_webhook_validation_failures_olap'::text, $1::date), + create_v1_range_partition('v1_cel_evaluation_failures_olap'::text, $1::date) ` func (q *Queries) CreateOLAPEventPartitions(ctx context.Context, db DBTX, date pgtype.Date) error { @@ -879,7 +910,7 @@ func (q *Queries) ListEventKeys(ctx context.Context, db DBTX, tenantid pgtype.UU } const listEvents = `-- name: ListEvents :many -SELECT e.tenant_id, e.id, e.external_id, e.seen_at, e.key, e.payload, e.additional_metadata, e.scope +SELECT e.tenant_id, e.id, e.external_id, e.seen_at, e.key, e.payload, e.additional_metadata, e.scope, e.triggering_webhook_name FROM v1_event_lookup_table_olap elt JOIN v1_events_olap e ON (elt.tenant_id, elt.event_id, elt.event_seen_at) = (e.tenant_id, e.id, e.seen_at) WHERE @@ -978,6 +1009,7 @@ func (q *Queries) ListEvents(ctx context.Context, db DBTX, arg ListEventsParams) &i.Payload, &i.AdditionalMetadata, &i.Scope, + &i.TriggeringWebhookName, ); err != nil { return nil, err } @@ -1002,6 +1034,10 @@ WITH task_partitions AS ( SELECT 'v1_event_to_run_olap' AS parent_table, p::TEXT AS partition_name FROM get_v1_partitions_before_date('v1_event_to_run_olap', $2::date) AS p ), events_lookup_table_partitions AS ( SELECT 'v1_event_lookup_table_olap' AS parent_table, p::TEXT AS partition_name FROM get_v1_partitions_before_date('v1_event_lookup_table_olap', $2::date) AS p +), incoming_webhook_validation_failure_partitions AS ( + SELECT 'v1_incoming_webhook_validation_failures_olap' AS parent_table, p::TEXT AS partition_name FROM get_v1_partitions_before_date('v1_incoming_webhook_validation_failures_olap', $2::date) AS p +), cel_evaluation_failures_partitions AS ( + SELECT 'v1_cel_evaluation_failures_olap' AS parent_table, p::TEXT AS partition_name FROM get_v1_partitions_before_date('v1_cel_evaluation_failures_olap', $2::date) AS p ), candidates AS ( SELECT parent_table, partition_name @@ -1042,6 +1078,20 @@ WITH task_partitions AS ( parent_table, partition_name FROM events_lookup_table_partitions + + UNION ALL + + SELECT + parent_table, partition_name + FROM + incoming_webhook_validation_failure_partitions + + UNION ALL + + SELECT + parent_table, partition_name + FROM + cel_evaluation_failures_partitions ) SELECT parent_table, partition_name @@ -1049,7 +1099,7 @@ FROM candidates WHERE CASE WHEN $1::BOOLEAN THEN TRUE - ELSE parent_table NOT IN ('v1_events_olap', 'v1_event_to_run_olap') + ELSE parent_table NOT IN ('v1_events_olap', 'v1_event_to_run_olap', 'v1_cel_evaluation_failures_olap', 'v1_incoming_webhook_validation_failures_olap') END ` @@ -2405,6 +2455,32 @@ func (q *Queries) ReadWorkflowRunByExternalId(ctx context.Context, db DBTX, work return &i, err } +const storeCELEvaluationFailures = `-- name: StoreCELEvaluationFailures :exec +WITH inputs AS ( + SELECT + UNNEST(CAST($2::TEXT[] AS v1_cel_evaluation_failure_source[])) AS source, + UNNEST($3::TEXT[]) AS error +) +INSERT INTO v1_cel_evaluation_failures_olap ( + tenant_id, + source, + error +) +SELECT $1::UUID, source, error +FROM inputs +` + +type StoreCELEvaluationFailuresParams struct { + Tenantid pgtype.UUID `json:"tenantid"` + Sources []string `json:"sources"` + Errors []string `json:"errors"` +} + +func (q *Queries) StoreCELEvaluationFailures(ctx context.Context, db DBTX, arg StoreCELEvaluationFailuresParams) error { + _, err := db.Exec(ctx, storeCELEvaluationFailures, arg.Tenantid, arg.Sources, arg.Errors) + return err +} + const updateDAGStatuses = `-- name: UpdateDAGStatuses :many WITH tenants AS ( SELECT UNNEST( diff --git a/pkg/repository/v1/sqlcv1/sqlc.yaml b/pkg/repository/v1/sqlcv1/sqlc.yaml index a0d313f2b..3532bf191 100644 --- a/pkg/repository/v1/sqlcv1/sqlc.yaml +++ b/pkg/repository/v1/sqlcv1/sqlc.yaml @@ -19,6 +19,7 @@ sql: - sleep.sql - ticker.sql - filters.sql + - webhooks.sql schema: - ../../../../sql/schema/v0.sql - ../../../../sql/schema/v1-core.sql diff --git a/pkg/repository/v1/sqlcv1/webhooks.sql b/pkg/repository/v1/sqlcv1/webhooks.sql new file mode 100644 index 000000000..0478bc3a8 --- /dev/null +++ b/pkg/repository/v1/sqlcv1/webhooks.sql @@ -0,0 +1,71 @@ +-- name: CreateWebhook :one +INSERT INTO v1_incoming_webhook ( + tenant_id, + name, + source_name, + event_key_expression, + auth_method, + auth__basic__username, + auth__basic__password, + auth__api_key__header_name, + auth__api_key__key, + auth__hmac__algorithm, + auth__hmac__encoding, + auth__hmac__signature_header_name, + auth__hmac__webhook_signing_secret + +) VALUES ( + @tenantId::UUID, + @name::TEXT, + @sourceName::v1_incoming_webhook_source_name, + @eventKeyExpression::TEXT, + @authMethod::v1_incoming_webhook_auth_type, + sqlc.narg('authBasicUsername')::TEXT, + @authBasicPassword::BYTEA, + sqlc.narg('authApiKeyHeaderName')::TEXT, + @authApiKeyKey::BYTEA, + sqlc.narg('authHmacAlgorithm')::v1_incoming_webhook_hmac_algorithm, + sqlc.narg('authHmacEncoding')::v1_incoming_webhook_hmac_encoding, + sqlc.narg('authHmacSignatureHeaderName')::TEXT, + @authHmacWebhookSigningSecret::BYTEA +) +RETURNING *; + +-- name: GetWebhook :one +SELECT * +FROM v1_incoming_webhook +WHERE + name = @name::TEXT + AND tenant_id = @tenantId::UUID; + +-- name: DeleteWebhook :one +DELETE FROM v1_incoming_webhook +WHERE + tenant_id = @tenantId::UUID + AND name = @name::TEXT +RETURNING *; + +-- name: ListWebhooks :many +SELECT * +FROM v1_incoming_webhook +WHERE + tenant_id = @tenantId::UUID + AND ( + @sourceNames::v1_incoming_webhook_source_name[] IS NULL + OR source_name = ANY(@sourceNames::v1_incoming_webhook_source_name[]) + ) + AND ( + @webhookNames::TEXT[] IS NULL + OR name = ANY(@webhookNames::TEXT[]) + ) +ORDER BY tenant_id, inserted_at DESC +LIMIT COALESCE(sqlc.narg('webhookLimit')::BIGINT, 20000) +OFFSET COALESCE(sqlc.narg('webhookOffset')::BIGINT, 0) +; + +-- name: CanCreateWebhook :one +SELECT COUNT(*) < @webhookLimit::INT AS can_create_webhook +FROM v1_incoming_webhook +WHERE + tenant_id = @tenantId::UUID +; diff --git a/pkg/repository/v1/sqlcv1/webhooks.sql.go b/pkg/repository/v1/sqlcv1/webhooks.sql.go new file mode 100644 index 000000000..95585bc82 --- /dev/null +++ b/pkg/repository/v1/sqlcv1/webhooks.sql.go @@ -0,0 +1,258 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: webhooks.sql + +package sqlcv1 + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const canCreateWebhook = `-- name: CanCreateWebhook :one +SELECT COUNT(*) < $1::INT AS can_create_webhook +FROM v1_incoming_webhook +WHERE + tenant_id = $2::UUID +` + +type CanCreateWebhookParams struct { + Webhooklimit int32 `json:"webhooklimit"` + Tenantid pgtype.UUID `json:"tenantid"` +} + +func (q *Queries) CanCreateWebhook(ctx context.Context, db DBTX, arg CanCreateWebhookParams) (bool, error) { + row := db.QueryRow(ctx, canCreateWebhook, arg.Webhooklimit, arg.Tenantid) + var can_create_webhook bool + err := row.Scan(&can_create_webhook) + return can_create_webhook, err +} + +const createWebhook = `-- name: CreateWebhook :one +INSERT INTO v1_incoming_webhook ( + tenant_id, + name, + source_name, + event_key_expression, + auth_method, + auth__basic__username, + auth__basic__password, + auth__api_key__header_name, + auth__api_key__key, + auth__hmac__algorithm, + auth__hmac__encoding, + auth__hmac__signature_header_name, + auth__hmac__webhook_signing_secret + +) VALUES ( + $1::UUID, + $2::TEXT, + $3::v1_incoming_webhook_source_name, + $4::TEXT, + $5::v1_incoming_webhook_auth_type, + $6::TEXT, + $7::BYTEA, + $8::TEXT, + $9::BYTEA, + $10::v1_incoming_webhook_hmac_algorithm, + $11::v1_incoming_webhook_hmac_encoding, + $12::TEXT, + $13::BYTEA +) +RETURNING tenant_id, name, source_name, event_key_expression, auth_method, auth__basic__username, auth__basic__password, auth__api_key__header_name, auth__api_key__key, auth__hmac__algorithm, auth__hmac__encoding, auth__hmac__signature_header_name, auth__hmac__webhook_signing_secret, inserted_at, updated_at +` + +type CreateWebhookParams struct { + Tenantid pgtype.UUID `json:"tenantid"` + Name string `json:"name"` + Sourcename V1IncomingWebhookSourceName `json:"sourcename"` + Eventkeyexpression string `json:"eventkeyexpression"` + Authmethod V1IncomingWebhookAuthType `json:"authmethod"` + AuthBasicUsername pgtype.Text `json:"authBasicUsername"` + Authbasicpassword []byte `json:"authbasicpassword"` + AuthApiKeyHeaderName pgtype.Text `json:"authApiKeyHeaderName"` + Authapikeykey []byte `json:"authapikeykey"` + AuthHmacAlgorithm NullV1IncomingWebhookHmacAlgorithm `json:"authHmacAlgorithm"` + AuthHmacEncoding NullV1IncomingWebhookHmacEncoding `json:"authHmacEncoding"` + AuthHmacSignatureHeaderName pgtype.Text `json:"authHmacSignatureHeaderName"` + Authhmacwebhooksigningsecret []byte `json:"authhmacwebhooksigningsecret"` +} + +func (q *Queries) CreateWebhook(ctx context.Context, db DBTX, arg CreateWebhookParams) (*V1IncomingWebhook, error) { + row := db.QueryRow(ctx, createWebhook, + arg.Tenantid, + arg.Name, + arg.Sourcename, + arg.Eventkeyexpression, + arg.Authmethod, + arg.AuthBasicUsername, + arg.Authbasicpassword, + arg.AuthApiKeyHeaderName, + arg.Authapikeykey, + arg.AuthHmacAlgorithm, + arg.AuthHmacEncoding, + arg.AuthHmacSignatureHeaderName, + arg.Authhmacwebhooksigningsecret, + ) + var i V1IncomingWebhook + err := row.Scan( + &i.TenantID, + &i.Name, + &i.SourceName, + &i.EventKeyExpression, + &i.AuthMethod, + &i.AuthBasicUsername, + &i.AuthBasicPassword, + &i.AuthApiKeyHeaderName, + &i.AuthApiKeyKey, + &i.AuthHmacAlgorithm, + &i.AuthHmacEncoding, + &i.AuthHmacSignatureHeaderName, + &i.AuthHmacWebhookSigningSecret, + &i.InsertedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const deleteWebhook = `-- name: DeleteWebhook :one +DELETE FROM v1_incoming_webhook +WHERE + tenant_id = $1::UUID + AND name = $2::TEXT +RETURNING tenant_id, name, source_name, event_key_expression, auth_method, auth__basic__username, auth__basic__password, auth__api_key__header_name, auth__api_key__key, auth__hmac__algorithm, auth__hmac__encoding, auth__hmac__signature_header_name, auth__hmac__webhook_signing_secret, inserted_at, updated_at +` + +type DeleteWebhookParams struct { + Tenantid pgtype.UUID `json:"tenantid"` + Name string `json:"name"` +} + +func (q *Queries) DeleteWebhook(ctx context.Context, db DBTX, arg DeleteWebhookParams) (*V1IncomingWebhook, error) { + row := db.QueryRow(ctx, deleteWebhook, arg.Tenantid, arg.Name) + var i V1IncomingWebhook + err := row.Scan( + &i.TenantID, + &i.Name, + &i.SourceName, + &i.EventKeyExpression, + &i.AuthMethod, + &i.AuthBasicUsername, + &i.AuthBasicPassword, + &i.AuthApiKeyHeaderName, + &i.AuthApiKeyKey, + &i.AuthHmacAlgorithm, + &i.AuthHmacEncoding, + &i.AuthHmacSignatureHeaderName, + &i.AuthHmacWebhookSigningSecret, + &i.InsertedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const getWebhook = `-- name: GetWebhook :one +SELECT tenant_id, name, source_name, event_key_expression, auth_method, auth__basic__username, auth__basic__password, auth__api_key__header_name, auth__api_key__key, auth__hmac__algorithm, auth__hmac__encoding, auth__hmac__signature_header_name, auth__hmac__webhook_signing_secret, inserted_at, updated_at +FROM v1_incoming_webhook +WHERE + name = $1::TEXT + AND tenant_id = $2::UUID +` + +type GetWebhookParams struct { + Name string `json:"name"` + Tenantid pgtype.UUID `json:"tenantid"` +} + +func (q *Queries) GetWebhook(ctx context.Context, db DBTX, arg GetWebhookParams) (*V1IncomingWebhook, error) { + row := db.QueryRow(ctx, getWebhook, arg.Name, arg.Tenantid) + var i V1IncomingWebhook + err := row.Scan( + &i.TenantID, + &i.Name, + &i.SourceName, + &i.EventKeyExpression, + &i.AuthMethod, + &i.AuthBasicUsername, + &i.AuthBasicPassword, + &i.AuthApiKeyHeaderName, + &i.AuthApiKeyKey, + &i.AuthHmacAlgorithm, + &i.AuthHmacEncoding, + &i.AuthHmacSignatureHeaderName, + &i.AuthHmacWebhookSigningSecret, + &i.InsertedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const listWebhooks = `-- name: ListWebhooks :many +SELECT tenant_id, name, source_name, event_key_expression, auth_method, auth__basic__username, auth__basic__password, auth__api_key__header_name, auth__api_key__key, auth__hmac__algorithm, auth__hmac__encoding, auth__hmac__signature_header_name, auth__hmac__webhook_signing_secret, inserted_at, updated_at +FROM v1_incoming_webhook +WHERE + tenant_id = $1::UUID + AND ( + $2::v1_incoming_webhook_source_name[] IS NULL + OR source_name = ANY($2::v1_incoming_webhook_source_name[]) + ) + AND ( + $3::TEXT[] IS NULL + OR name = ANY($3::TEXT[]) + ) +ORDER BY tenant_id, inserted_at DESC +LIMIT COALESCE($5::BIGINT, 20000) +OFFSET COALESCE($4::BIGINT, 0) +` + +type ListWebhooksParams struct { + Tenantid pgtype.UUID `json:"tenantid"` + Sourcenames []V1IncomingWebhookSourceName `json:"sourcenames"` + Webhooknames []string `json:"webhooknames"` + WebhookOffset pgtype.Int8 `json:"webhookOffset"` + WebhookLimit pgtype.Int8 `json:"webhookLimit"` +} + +func (q *Queries) ListWebhooks(ctx context.Context, db DBTX, arg ListWebhooksParams) ([]*V1IncomingWebhook, error) { + rows, err := db.Query(ctx, listWebhooks, + arg.Tenantid, + arg.Sourcenames, + arg.Webhooknames, + arg.WebhookOffset, + arg.WebhookLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []*V1IncomingWebhook + for rows.Next() { + var i V1IncomingWebhook + if err := rows.Scan( + &i.TenantID, + &i.Name, + &i.SourceName, + &i.EventKeyExpression, + &i.AuthMethod, + &i.AuthBasicUsername, + &i.AuthBasicPassword, + &i.AuthApiKeyHeaderName, + &i.AuthApiKeyKey, + &i.AuthHmacAlgorithm, + &i.AuthHmacEncoding, + &i.AuthHmacSignatureHeaderName, + &i.AuthHmacWebhookSigningSecret, + &i.InsertedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/pkg/repository/v1/trigger.go b/pkg/repository/v1/trigger.go index e561d4ff8..64de78ae0 100644 --- a/pkg/repository/v1/trigger.go +++ b/pkg/repository/v1/trigger.go @@ -30,6 +30,8 @@ type EventTriggerOpts struct { Priority *int32 Scope *string + + TriggeringWebhookName *string } type TriggerTaskData struct { @@ -118,6 +120,7 @@ type TriggerFromEventsResult struct { Tasks []*sqlcv1.V1Task Dags []*DAGWithData EventExternalIdToRuns map[string][]*Run + CELEvaluationFailures []CELEvaluationFailure } type TriggerDecision struct { @@ -126,7 +129,9 @@ type TriggerDecision struct { FilterId *string } -func (r *TriggerRepositoryImpl) makeTriggerDecisions(ctx context.Context, filters []*sqlcv1.V1Filter, hasAnyFilters bool, opt EventTriggerOpts) []TriggerDecision { +func (r *TriggerRepositoryImpl) makeTriggerDecisions(ctx context.Context, filters []*sqlcv1.V1Filter, hasAnyFilters bool, opt EventTriggerOpts) ([]TriggerDecision, []CELEvaluationFailure) { + celEvaluationFailures := make([]CELEvaluationFailure, 0) + // Cases to handle: // 1. If there are no filters that exist for the workflow, we should trigger it. // 2. If there _are_ filters that exist, but the list is empty, then there were no scope matches so we should _not_ trigger. @@ -140,7 +145,7 @@ func (r *TriggerRepositoryImpl) makeTriggerDecisions(ctx context.Context, filter FilterPayload: nil, FilterId: nil, }, - } + }, celEvaluationFailures } // Case 2 - no filters were found matching the provided scope, @@ -152,7 +157,7 @@ func (r *TriggerRepositoryImpl) makeTriggerDecisions(ctx context.Context, filter FilterPayload: nil, FilterId: nil, }, - } + }, celEvaluationFailures } // Case 3 - we have filters, so we should evaluate each expression and return a list of decisions @@ -187,6 +192,11 @@ func (r *TriggerRepositoryImpl) makeTriggerDecisions(ctx context.Context, filter FilterPayload: filter.Payload, FilterId: &filterId, }) + + celEvaluationFailures = append(celEvaluationFailures, CELEvaluationFailure{ + Source: sqlcv1.V1CelEvaluationFailureSourceFILTER, + ErrorMessage: err.Error(), + }) } decisions = append(decisions, TriggerDecision{ @@ -197,7 +207,7 @@ func (r *TriggerRepositoryImpl) makeTriggerDecisions(ctx context.Context, filter } } - return decisions + return decisions, celEvaluationFailures } type EventExternalIdFilterId struct { @@ -315,6 +325,7 @@ func (r *TriggerRepositoryImpl) TriggerFromEvents(ctx context.Context, tenantId // each (workflowVersionId, eventKey, opt) is a separate workflow that we need to create triggerOpts := make([]triggerTuple, 0) + celEvaluationFailures := make([]CELEvaluationFailure, 0) for _, workflow := range workflowVersionIdsAndEventKeys { opts, ok := eventKeysToOpts[workflow.IncomingEventKey] @@ -339,7 +350,9 @@ func (r *TriggerRepositoryImpl) TriggerFromEvents(ctx context.Context, tenantId filters = workflowIdAndScopeToFilters[key] } - triggerDecisions := r.makeTriggerDecisions(ctx, filters, hasAnyFilters, opt) + triggerDecisions, evalFailures := r.makeTriggerDecisions(ctx, filters, hasAnyFilters, opt) + + celEvaluationFailures = append(celEvaluationFailures, evalFailures...) for _, decision := range triggerDecisions { if !decision.ShouldTrigger { @@ -418,6 +431,7 @@ func (r *TriggerRepositoryImpl) TriggerFromEvents(ctx context.Context, tenantId Tasks: tasks, Dags: dags, EventExternalIdToRuns: eventExternalIdToRuns, + CELEvaluationFailures: celEvaluationFailures, }, nil } diff --git a/pkg/repository/v1/webhooks.go b/pkg/repository/v1/webhooks.go new file mode 100644 index 000000000..f662d7950 --- /dev/null +++ b/pkg/repository/v1/webhooks.go @@ -0,0 +1,209 @@ +package v1 + +import ( + "context" + "fmt" + + "github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers" + "github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1" + "github.com/jackc/pgx/v5/pgtype" +) + +type WebhookRepository interface { + CreateWebhook(ctx context.Context, tenantId string, params CreateWebhookOpts) (*sqlcv1.V1IncomingWebhook, error) + ListWebhooks(ctx context.Context, tenantId string, params ListWebhooksOpts) ([]*sqlcv1.V1IncomingWebhook, error) + DeleteWebhook(ctx context.Context, tenantId, webhookId string) (*sqlcv1.V1IncomingWebhook, error) + GetWebhook(ctx context.Context, tenantId, webhookId string) (*sqlcv1.V1IncomingWebhook, error) + CanCreate(ctx context.Context, tenantId string, webhookLimit int32) (bool, error) +} + +type webhookRepository struct { + *sharedRepository +} + +func newWebhookRepository(shared *sharedRepository) WebhookRepository { + return &webhookRepository{ + sharedRepository: shared, + } +} + +type BasicAuthCredentials struct { + Username string `json:"username" validate:"required"` + EncryptedPassword []byte `json:"password" validate:"required"` +} + +type APIKeyAuthCredentials struct { + HeaderName string `json:"header_name" validate:"required"` + EncryptedKey []byte `json:"key" validate:"required"` +} + +type HMACAuthCredentials struct { + Algorithm sqlcv1.V1IncomingWebhookHmacAlgorithm `json:"algorithm" validate:"required"` + Encoding sqlcv1.V1IncomingWebhookHmacEncoding `json:"encoding" validate:"required"` + SignatureHeaderName string `json:"signature_header_name" validate:"required"` + EncryptedWebhookSigningSecret []byte `json:"webhook_signing_secret" validate:"required"` +} + +type AuthConfig struct { + Type sqlcv1.V1IncomingWebhookAuthType `json:"type" validate:"required"` + BasicAuth *BasicAuthCredentials `json:"basic_auth,omitempty"` + APIKeyAuth *APIKeyAuthCredentials `json:"api_key_auth,omitempty"` + HMACAuth *HMACAuthCredentials `json:"hmac_auth,omitempty"` +} + +func (ac *AuthConfig) Validate() error { + authMethodsSet := 0 + + if ac.BasicAuth != nil { + authMethodsSet++ + } + if ac.APIKeyAuth != nil { + authMethodsSet++ + } + if ac.HMACAuth != nil { + authMethodsSet++ + } + + if authMethodsSet != 1 { + return fmt.Errorf("exactly one auth method must be set, but %d were provided", authMethodsSet) + } + + switch ac.Type { + case sqlcv1.V1IncomingWebhookAuthTypeBASIC: + if ac.BasicAuth == nil { + return fmt.Errorf("basic auth credentials must be provided when type is BASIC") + } + case sqlcv1.V1IncomingWebhookAuthTypeAPIKEY: + if ac.APIKeyAuth == nil { + return fmt.Errorf("api key auth credentials must be provided when type is API_KEY") + } + case sqlcv1.V1IncomingWebhookAuthTypeHMAC: + if ac.HMACAuth == nil { + return fmt.Errorf("hmac auth credentials must be provided when type is HMAC") + } + default: + return fmt.Errorf("unsupported auth type: %s", ac.Type) + } + + return nil +} + +type CreateWebhookOpts struct { + Tenantid pgtype.UUID `json:"tenantid"` + Sourcename sqlcv1.V1IncomingWebhookSourceName `json:"sourcename"` + Name string `json:"name" validate:"required"` + Eventkeyexpression string `json:"eventkeyexpression"` + AuthConfig AuthConfig `json:"auth_config,omitempty"` +} + +func (r *webhookRepository) CreateWebhook(ctx context.Context, tenantId string, opts CreateWebhookOpts) (*sqlcv1.V1IncomingWebhook, error) { + if err := r.v.Validate(opts); err != nil { + return nil, err + } + + if err := opts.AuthConfig.Validate(); err != nil { + return nil, err + } + + params := sqlcv1.CreateWebhookParams{ + Tenantid: sqlchelpers.UUIDFromStr(tenantId), + Sourcename: sqlcv1.V1IncomingWebhookSourceName(opts.Sourcename), + Name: opts.Name, + Eventkeyexpression: opts.Eventkeyexpression, + Authmethod: sqlcv1.V1IncomingWebhookAuthType(opts.AuthConfig.Type), + } + + switch opts.AuthConfig.Type { + case sqlcv1.V1IncomingWebhookAuthTypeBASIC: + params.AuthBasicUsername = pgtype.Text{ + String: opts.AuthConfig.BasicAuth.Username, + Valid: true, + } + params.Authbasicpassword = opts.AuthConfig.BasicAuth.EncryptedPassword + case sqlcv1.V1IncomingWebhookAuthTypeAPIKEY: + params.AuthApiKeyHeaderName = pgtype.Text{ + String: opts.AuthConfig.APIKeyAuth.HeaderName, + Valid: true, + } + + params.Authapikeykey = opts.AuthConfig.APIKeyAuth.EncryptedKey + case sqlcv1.V1IncomingWebhookAuthTypeHMAC: + params.AuthHmacAlgorithm = sqlcv1.NullV1IncomingWebhookHmacAlgorithm{ + V1IncomingWebhookHmacAlgorithm: opts.AuthConfig.HMACAuth.Algorithm, + Valid: true, + } + params.AuthHmacEncoding = sqlcv1.NullV1IncomingWebhookHmacEncoding{ + V1IncomingWebhookHmacEncoding: opts.AuthConfig.HMACAuth.Encoding, + Valid: true, + } + params.AuthHmacSignatureHeaderName = pgtype.Text{ + String: opts.AuthConfig.HMACAuth.SignatureHeaderName, + Valid: true, + } + params.Authhmacwebhooksigningsecret = opts.AuthConfig.HMACAuth.EncryptedWebhookSigningSecret + default: + return nil, fmt.Errorf("unsupported auth type: %s", opts.AuthConfig.Type) + } + + return r.queries.CreateWebhook(ctx, r.pool, params) +} + +type ListWebhooksOpts struct { + WebhookNames []string `json:"webhook_names"` + WebhookSourceNames []sqlcv1.V1IncomingWebhookSourceName `json:"webhook_source_names"` + Limit *int64 `json:"limit" validate:"omitnil,min=1"` + Offset *int64 `json:"offset" validate:"omitnil,min=0"` +} + +func (r *webhookRepository) ListWebhooks(ctx context.Context, tenantId string, opts ListWebhooksOpts) ([]*sqlcv1.V1IncomingWebhook, error) { + if err := r.v.Validate(opts); err != nil { + return nil, err + } + + var limit pgtype.Int8 + var offset pgtype.Int8 + + if opts.Limit != nil { + limit = pgtype.Int8{ + Int64: *opts.Limit, + Valid: true, + } + } + + if opts.Offset != nil { + offset = pgtype.Int8{ + Int64: *opts.Offset, + Valid: true, + } + } + + return r.queries.ListWebhooks(ctx, r.pool, sqlcv1.ListWebhooksParams{ + Tenantid: sqlchelpers.UUIDFromStr(tenantId), + Webhooknames: opts.WebhookNames, + Sourcenames: opts.WebhookSourceNames, + WebhookLimit: limit, + WebhookOffset: offset, + }) + +} + +func (r *webhookRepository) DeleteWebhook(ctx context.Context, tenantId, name string) (*sqlcv1.V1IncomingWebhook, error) { + return r.queries.DeleteWebhook(ctx, r.pool, sqlcv1.DeleteWebhookParams{ + Tenantid: sqlchelpers.UUIDFromStr(tenantId), + Name: name, + }) +} + +func (r *webhookRepository) GetWebhook(ctx context.Context, tenantId, name string) (*sqlcv1.V1IncomingWebhook, error) { + return r.queries.GetWebhook(ctx, r.pool, sqlcv1.GetWebhookParams{ + Tenantid: sqlchelpers.UUIDFromStr(tenantId), + Name: name, + }) +} + +func (r *webhookRepository) CanCreate(ctx context.Context, tenantId string, webhookLimit int32) (bool, error) { + return r.queries.CanCreateWebhook(ctx, r.pool, sqlcv1.CanCreateWebhookParams{ + Tenantid: sqlchelpers.UUIDFromStr(tenantId), + Webhooklimit: webhookLimit, + }) +} diff --git a/sdks/python/examples/webhooks/test_webhooks.py b/sdks/python/examples/webhooks/test_webhooks.py new file mode 100644 index 000000000..317ce0657 --- /dev/null +++ b/sdks/python/examples/webhooks/test_webhooks.py @@ -0,0 +1,643 @@ +import asyncio +import base64 +import hashlib +import hmac +import json +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +import aiohttp +import pytest + +from examples.webhooks.worker import WebhookInput +from hatchet_sdk import Hatchet +from hatchet_sdk.clients.rest.api.webhook_api import WebhookApi +from hatchet_sdk.clients.rest.models.v1_create_webhook_request import ( + V1CreateWebhookRequest, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import ( + V1CreateWebhookRequestAPIKey, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import ( + V1CreateWebhookRequestBasicAuth, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import ( + V1CreateWebhookRequestHMAC, +) +from hatchet_sdk.clients.rest.models.v1_event import V1Event +from hatchet_sdk.clients.rest.models.v1_task_status import V1TaskStatus +from hatchet_sdk.clients.rest.models.v1_task_summary import V1TaskSummary +from hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook +from hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth +from hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import ( + V1WebhookHMACAlgorithm, +) +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import ( + V1WebhookHMACEncoding, +) +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName + +TEST_BASIC_USERNAME = "test_user" +TEST_BASIC_PASSWORD = "test_password" +TEST_API_KEY_HEADER = "X-API-Key" +TEST_API_KEY_VALUE = "test_api_key_123" +TEST_HMAC_SIGNATURE_HEADER = "X-Signature" +TEST_HMAC_SECRET = "test_hmac_secret" + + +@pytest.fixture +def webhook_body() -> WebhookInput: + return WebhookInput(type="test", message="Hello, world!") + + +@pytest.fixture +def test_run_id() -> str: + return str(uuid4()) + + +@pytest.fixture +def test_start() -> datetime: + return datetime.now(timezone.utc) + + +def create_hmac_signature( + payload: bytes, + secret: str, + algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256, + encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX, +) -> str: + algorithm_map = { + V1WebhookHMACAlgorithm.SHA1: hashlib.sha1, + V1WebhookHMACAlgorithm.SHA256: hashlib.sha256, + V1WebhookHMACAlgorithm.SHA512: hashlib.sha512, + V1WebhookHMACAlgorithm.MD5: hashlib.md5, + } + + hash_func = algorithm_map[algorithm] + signature = hmac.new(secret.encode(), payload, hash_func).digest() + + if encoding == V1WebhookHMACEncoding.HEX: + return signature.hex() + if encoding == V1WebhookHMACEncoding.BASE64: + return base64.b64encode(signature).decode() + if encoding == V1WebhookHMACEncoding.BASE64URL: + return base64.urlsafe_b64encode(signature).decode() + + raise ValueError(f"Unsupported encoding: {encoding}") + + +async def send_webhook_request( + url: str, + body: WebhookInput, + auth_type: str, + auth_data: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +) -> aiohttp.ClientResponse: + request_headers = headers or {} + auth = None + + if auth_type == "BASIC" and auth_data: + auth = aiohttp.BasicAuth(auth_data["username"], auth_data["password"]) + elif auth_type == "API_KEY" and auth_data: + request_headers[auth_data["header_name"]] = auth_data["api_key"] + elif auth_type == "HMAC" and auth_data: + payload = json.dumps(body.model_dump()).encode() + signature = create_hmac_signature( + payload, + auth_data["secret"], + auth_data.get("algorithm", V1WebhookHMACAlgorithm.SHA256), + auth_data.get("encoding", V1WebhookHMACEncoding.HEX), + ) + request_headers[auth_data["header_name"]] = signature + + async with aiohttp.ClientSession() as session: + return await session.post( + url, json=body.model_dump(), auth=auth, headers=request_headers + ) + + +async def wait_for_event( + hatchet: Hatchet, + webhook_name: str, + test_start: datetime, +) -> V1Event | None: + await asyncio.sleep(5) + + events = await hatchet.event.aio_list(since=test_start) + + if events.rows is None: + return None + + return next( + ( + event + for event in events.rows + if event.triggering_webhook_name == webhook_name + ), + None, + ) + + +async def wait_for_workflow_run( + hatchet: Hatchet, event_id: str, test_start: datetime +) -> V1TaskSummary | None: + await asyncio.sleep(5) + + runs = await hatchet.runs.aio_list( + since=test_start, + additional_metadata={ + "hatchet__event_id": event_id, + }, + ) + + if len(runs.rows) == 0: + return None + + return runs.rows[0] + + +@asynccontextmanager +async def basic_auth_webhook( + hatchet: Hatchet, + test_run_id: str, + username: str = TEST_BASIC_USERNAME, + password: str = TEST_BASIC_PASSWORD, + source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC, +) -> AsyncGenerator[V1Webhook, None]: + ## Hack to get the API client + client = hatchet.metrics.client() + webhook_api = WebhookApi(client) + + webhook_request = V1CreateWebhookRequestBasicAuth( + sourceName=source_name, + name=f"test-webhook-basic-{test_run_id}", + eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type", + authType="BASIC", + auth=V1WebhookBasicAuth( + username=username, + password=password, + ), + ) + + incoming_webhook = webhook_api.v1_webhook_create( + tenant=hatchet.tenant_id, + v1_create_webhook_request=V1CreateWebhookRequest(webhook_request), + ) + + try: + yield incoming_webhook + finally: + webhook_api.v1_webhook_delete( + tenant=hatchet.tenant_id, + v1_webhook=incoming_webhook.name, + ) + + +@asynccontextmanager +async def api_key_webhook( + hatchet: Hatchet, + test_run_id: str, + header_name: str = TEST_API_KEY_HEADER, + api_key: str = TEST_API_KEY_VALUE, + source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC, +) -> AsyncGenerator[V1Webhook, None]: + client = hatchet.metrics.client() + webhook_api = WebhookApi(client) + + webhook_request = V1CreateWebhookRequestAPIKey( + sourceName=source_name, + name=f"test-webhook-apikey-{test_run_id}", + eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type", + authType="API_KEY", + auth=V1WebhookAPIKeyAuth( + headerName=header_name, + apiKey=api_key, + ), + ) + + incoming_webhook = webhook_api.v1_webhook_create( + tenant=hatchet.tenant_id, + v1_create_webhook_request=V1CreateWebhookRequest(webhook_request), + ) + + try: + yield incoming_webhook + finally: + webhook_api.v1_webhook_delete( + tenant=hatchet.tenant_id, + v1_webhook=incoming_webhook.name, + ) + + +@asynccontextmanager +async def hmac_webhook( + hatchet: Hatchet, + test_run_id: str, + signature_header_name: str = TEST_HMAC_SIGNATURE_HEADER, + signing_secret: str = TEST_HMAC_SECRET, + algorithm: V1WebhookHMACAlgorithm = V1WebhookHMACAlgorithm.SHA256, + encoding: V1WebhookHMACEncoding = V1WebhookHMACEncoding.HEX, + source_name: V1WebhookSourceName = V1WebhookSourceName.GENERIC, +) -> AsyncGenerator[V1Webhook, None]: + client = hatchet.metrics.client() + webhook_api = WebhookApi(client) + + webhook_request = V1CreateWebhookRequestHMAC( + sourceName=source_name, + name=f"test-webhook-hmac-{test_run_id}", + eventKeyExpression=f"'{hatchet.config.apply_namespace('webhook')}:' + input.type", + authType="HMAC", + auth=V1WebhookHMACAuth( + algorithm=algorithm, + encoding=encoding, + signatureHeaderName=signature_header_name, + signingSecret=signing_secret, + ), + ) + + incoming_webhook = webhook_api.v1_webhook_create( + tenant=hatchet.tenant_id, + v1_create_webhook_request=V1CreateWebhookRequest(webhook_request), + ) + + try: + yield incoming_webhook + finally: + webhook_api.v1_webhook_delete( + tenant=hatchet.tenant_id, + v1_webhook=incoming_webhook.name, + ) + + +def url(tenant_id: str, webhook_name: str) -> str: + return f"http://localhost:8080/api/v1/stable/tenants/{tenant_id}/webhooks/{webhook_name}" + + +async def assert_has_runs( + hatchet: Hatchet, + test_start: datetime, + webhook_body: WebhookInput, + incoming_webhook: V1Webhook, +) -> None: + triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start) + assert triggered_event is not None + assert ( + triggered_event.key + == f"{hatchet.config.apply_namespace('webhook')}:{webhook_body.type}" + ) + assert triggered_event.payload == webhook_body.model_dump() + + workflow_run = await wait_for_workflow_run( + hatchet, triggered_event.metadata.id, test_start + ) + assert workflow_run is not None + assert workflow_run.status == V1TaskStatus.COMPLETED + assert workflow_run.additional_metadata is not None + + assert ( + workflow_run.additional_metadata["hatchet__event_id"] + == triggered_event.metadata.id + ) + assert workflow_run.additional_metadata["hatchet__event_key"] == triggered_event.key + assert workflow_run.status == V1TaskStatus.COMPLETED + + +async def assert_event_not_created( + hatchet: Hatchet, + test_start: datetime, + incoming_webhook: V1Webhook, +) -> None: + triggered_event = await wait_for_event(hatchet, incoming_webhook.name, test_start) + assert triggered_event is None + + +@pytest.mark.asyncio(loop_scope="session") +async def test_basic_auth_success( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "BASIC", + {"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD}, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "username,password", + [ + ("test_user", "incorrect_password"), + ("incorrect_user", "test_password"), + ("incorrect_user", "incorrect_password"), + ("", ""), + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_basic_auth_failure( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + username: str, + password: str, +) -> None: + """Test basic authentication failures.""" + async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "BASIC", + {"username": username, "password": password}, + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_basic_auth_missing_credentials( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with basic_auth_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE" + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_api_key_success( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with api_key_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "API_KEY", + {"header_name": TEST_API_KEY_HEADER, "api_key": TEST_API_KEY_VALUE}, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "api_key", + [ + "incorrect_api_key", + "", + "partial_key", + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_api_key_failure( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + api_key: str, +) -> None: + async with api_key_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "API_KEY", + {"header_name": TEST_API_KEY_HEADER, "api_key": api_key}, + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_api_key_missing_header( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with api_key_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE" + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_hmac_success( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with hmac_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "HMAC", + { + "header_name": TEST_HMAC_SIGNATURE_HEADER, + "secret": TEST_HMAC_SECRET, + "algorithm": V1WebhookHMACAlgorithm.SHA256, + "encoding": V1WebhookHMACEncoding.HEX, + }, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "algorithm,encoding", + [ + (V1WebhookHMACAlgorithm.SHA1, V1WebhookHMACEncoding.HEX), + (V1WebhookHMACAlgorithm.SHA256, V1WebhookHMACEncoding.BASE64), + (V1WebhookHMACAlgorithm.SHA512, V1WebhookHMACEncoding.BASE64URL), + (V1WebhookHMACAlgorithm.MD5, V1WebhookHMACEncoding.HEX), + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_hmac_different_algorithms_and_encodings( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + algorithm: V1WebhookHMACAlgorithm, + encoding: V1WebhookHMACEncoding, +) -> None: + async with hmac_webhook( + hatchet, test_run_id, algorithm=algorithm, encoding=encoding + ) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "HMAC", + { + "header_name": TEST_HMAC_SIGNATURE_HEADER, + "secret": TEST_HMAC_SECRET, + "algorithm": algorithm, + "encoding": encoding, + }, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "secret", + [ + "incorrect_secret", + "", + "partial_secret", + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_hmac_signature_failure( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + secret: str, +) -> None: + async with hmac_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "HMAC", + { + "header_name": TEST_HMAC_SIGNATURE_HEADER, + "secret": secret, + "algorithm": V1WebhookHMACAlgorithm.SHA256, + "encoding": V1WebhookHMACEncoding.HEX, + }, + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_hmac_missing_signature_header( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, +) -> None: + async with hmac_webhook(hatchet, test_run_id) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), webhook_body, "NONE" + ) as response: + assert response.status == 403 + + await assert_event_not_created( + hatchet, + test_start, + incoming_webhook, + ) + + +@pytest.mark.parametrize( + "source_name", + [ + V1WebhookSourceName.GENERIC, + V1WebhookSourceName.GITHUB, + ], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_different_source_types( + hatchet: Hatchet, + test_run_id: str, + test_start: datetime, + webhook_body: WebhookInput, + source_name: V1WebhookSourceName, +) -> None: + async with basic_auth_webhook( + hatchet, test_run_id, source_name=source_name + ) as incoming_webhook: + async with await send_webhook_request( + url(hatchet.tenant_id, incoming_webhook.name), + webhook_body, + "BASIC", + {"username": TEST_BASIC_USERNAME, "password": TEST_BASIC_PASSWORD}, + ) as response: + assert response.status == 200 + data = await response.json() + assert data == {"message": "ok"} + + await assert_has_runs( + hatchet, + test_start, + webhook_body, + incoming_webhook, + ) diff --git a/sdks/python/examples/webhooks/worker.py b/sdks/python/examples/webhooks/worker.py new file mode 100644 index 000000000..7f27def83 --- /dev/null +++ b/sdks/python/examples/webhooks/worker.py @@ -0,0 +1,28 @@ +# > Webhooks + +from pydantic import BaseModel + +from hatchet_sdk import Context, Hatchet + +hatchet = Hatchet(debug=True) + + +class WebhookInput(BaseModel): + type: str + message: str + + +@hatchet.task(input_validator=WebhookInput, on_events=["webhook:test"]) +def webhook(input: WebhookInput, ctx: Context) -> dict[str, str]: + return input.model_dump() + + +def main() -> None: + worker = hatchet.worker("webhook-worker", workflows=[webhook]) + worker.start() + + +# !! + +if __name__ == "__main__": + main() diff --git a/sdks/python/examples/worker.py b/sdks/python/examples/worker.py index 3bcdc68f6..dd8c3bd96 100644 --- a/sdks/python/examples/worker.py +++ b/sdks/python/examples/worker.py @@ -26,6 +26,7 @@ from examples.on_failure.worker import on_failure_wf, on_failure_wf_with_details from examples.return_exceptions.worker import return_exceptions_task from examples.simple.worker import simple, simple_durable from examples.timeout.worker import refresh_timeout_wf, timeout_wf +from examples.webhooks.worker import webhook from hatchet_sdk import Hatchet hatchet = Hatchet(debug=True) @@ -66,6 +67,7 @@ def main() -> None: bulk_replay_test_1, bulk_replay_test_2, bulk_replay_test_3, + webhook, return_exceptions_task, wait_for_sleep_twice, ], diff --git a/sdks/python/hatchet_sdk/clients/rest/__init__.py b/sdks/python/hatchet_sdk/clients/rest/__init__.py index 6c7b66644..3a809785b 100644 --- a/sdks/python/hatchet_sdk/clients/rest/__init__.py +++ b/sdks/python/hatchet_sdk/clients/rest/__init__.py @@ -33,6 +33,7 @@ from hatchet_sdk.clients.rest.api.step_run_api import StepRunApi from hatchet_sdk.clients.rest.api.task_api import TaskApi from hatchet_sdk.clients.rest.api.tenant_api import TenantApi from hatchet_sdk.clients.rest.api.user_api import UserApi +from hatchet_sdk.clients.rest.api.webhook_api import WebhookApi from hatchet_sdk.clients.rest.api.worker_api import WorkerApi from hatchet_sdk.clients.rest.api.workflow_api import WorkflowApi from hatchet_sdk.clients.rest.api.workflow_run_api import WorkflowRunApi @@ -241,6 +242,21 @@ from hatchet_sdk.clients.rest.models.v1_cel_debug_response_status import ( from hatchet_sdk.clients.rest.models.v1_create_filter_request import ( V1CreateFilterRequest, ) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request import ( + V1CreateWebhookRequest, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import ( + V1CreateWebhookRequestAPIKey, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_base import ( + V1CreateWebhookRequestBase, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import ( + V1CreateWebhookRequestBasicAuth, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import ( + V1CreateWebhookRequestHMAC, +) from hatchet_sdk.clients.rest.models.v1_dag_children import V1DagChildren from hatchet_sdk.clients.rest.models.v1_event import V1Event from hatchet_sdk.clients.rest.models.v1_event_list import V1EventList @@ -274,6 +290,22 @@ from hatchet_sdk.clients.rest.models.v1_trigger_workflow_run_request import ( from hatchet_sdk.clients.rest.models.v1_update_filter_request import ( V1UpdateFilterRequest, ) +from hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook +from hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth +from hatchet_sdk.clients.rest.models.v1_webhook_auth_type import V1WebhookAuthType +from hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import ( + V1WebhookHMACAlgorithm, +) +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import ( + V1WebhookHMACEncoding, +) +from hatchet_sdk.clients.rest.models.v1_webhook_list import V1WebhookList +from hatchet_sdk.clients.rest.models.v1_webhook_receive200_response import ( + V1WebhookReceive200Response, +) +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName from hatchet_sdk.clients.rest.models.v1_workflow_run import V1WorkflowRun from hatchet_sdk.clients.rest.models.v1_workflow_run_details import V1WorkflowRunDetails from hatchet_sdk.clients.rest.models.v1_workflow_run_display_name import ( diff --git a/sdks/python/hatchet_sdk/clients/rest/api/__init__.py b/sdks/python/hatchet_sdk/clients/rest/api/__init__.py index 73c87851c..3b61cdd66 100644 --- a/sdks/python/hatchet_sdk/clients/rest/api/__init__.py +++ b/sdks/python/hatchet_sdk/clients/rest/api/__init__.py @@ -17,6 +17,7 @@ from hatchet_sdk.clients.rest.api.step_run_api import StepRunApi from hatchet_sdk.clients.rest.api.task_api import TaskApi from hatchet_sdk.clients.rest.api.tenant_api import TenantApi from hatchet_sdk.clients.rest.api.user_api import UserApi +from hatchet_sdk.clients.rest.api.webhook_api import WebhookApi from hatchet_sdk.clients.rest.api.worker_api import WorkerApi from hatchet_sdk.clients.rest.api.workflow_api import WorkflowApi from hatchet_sdk.clients.rest.api.workflow_run_api import WorkflowRunApi diff --git a/sdks/python/hatchet_sdk/clients/rest/api/webhook_api.py b/sdks/python/hatchet_sdk/clients/rest/api/webhook_api.py new file mode 100644 index 000000000..e429aef41 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/api/webhook_api.py @@ -0,0 +1,1551 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + +import warnings +from typing import Any, Dict, List, Optional, Tuple, Union + +from pydantic import Field, StrictFloat, StrictInt, StrictStr, validate_call +from typing_extensions import Annotated + +from hatchet_sdk.clients.rest.api_client import ApiClient, RequestSerialized +from hatchet_sdk.clients.rest.api_response import ApiResponse +from hatchet_sdk.clients.rest.models.v1_create_webhook_request import ( + V1CreateWebhookRequest, +) +from hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook +from hatchet_sdk.clients.rest.models.v1_webhook_list import V1WebhookList +from hatchet_sdk.clients.rest.models.v1_webhook_receive200_response import ( + V1WebhookReceive200Response, +) +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName +from hatchet_sdk.clients.rest.rest import RESTResponseType + + +class WebhookApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + @validate_call + def v1_webhook_create( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_create_webhook_request: Annotated[ + V1CreateWebhookRequest, + Field(description="The input to the webhook creation"), + ], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> V1Webhook: + """Create a webhook + + Create a new webhook + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_create_webhook_request: The input to the webhook creation (required) + :type v1_create_webhook_request: V1CreateWebhookRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_create_serialize( + tenant=tenant, + v1_create_webhook_request=v1_create_webhook_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + "404": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + @validate_call + def v1_webhook_create_with_http_info( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_create_webhook_request: Annotated[ + V1CreateWebhookRequest, + Field(description="The input to the webhook creation"), + ], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[V1Webhook]: + """Create a webhook + + Create a new webhook + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_create_webhook_request: The input to the webhook creation (required) + :type v1_create_webhook_request: V1CreateWebhookRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_create_serialize( + tenant=tenant, + v1_create_webhook_request=v1_create_webhook_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + "404": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + @validate_call + def v1_webhook_create_without_preload_content( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_create_webhook_request: Annotated[ + V1CreateWebhookRequest, + Field(description="The input to the webhook creation"), + ], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Create a webhook + + Create a new webhook + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_create_webhook_request: The input to the webhook creation (required) + :type v1_create_webhook_request: V1CreateWebhookRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_create_serialize( + tenant=tenant, + v1_create_webhook_request=v1_create_webhook_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + "404": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + return response_data.response + + def _v1_webhook_create_serialize( + self, + tenant, + v1_create_webhook_request, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = {} + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if tenant is not None: + _path_params["tenant"] = tenant + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if v1_create_webhook_request is not None: + _body_params = v1_create_webhook_request + + # set the HTTP header `Accept` + if "Accept" not in _header_params: + _header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params["Content-Type"] = _content_type + else: + _default_content_type = self.api_client.select_header_content_type( + ["application/json"] + ) + if _default_content_type is not None: + _header_params["Content-Type"] = _default_content_type + + # authentication setting + _auth_settings: List[str] = ["cookieAuth", "bearerAuth"] + + return self.api_client.param_serialize( + method="POST", + resource_path="/api/v1/stable/tenants/{tenant}/webhooks", + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth, + ) + + @validate_call + def v1_webhook_delete( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[ + StrictStr, Field(description="The name of the webhook to delete") + ], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> V1Webhook: + """v1_webhook_delete + + Delete a webhook + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The name of the webhook to delete (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_delete_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + "404": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + @validate_call + def v1_webhook_delete_with_http_info( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[ + StrictStr, Field(description="The name of the webhook to delete") + ], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[V1Webhook]: + """v1_webhook_delete + + Delete a webhook + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The name of the webhook to delete (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_delete_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + "404": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + @validate_call + def v1_webhook_delete_without_preload_content( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[ + StrictStr, Field(description="The name of the webhook to delete") + ], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """v1_webhook_delete + + Delete a webhook + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The name of the webhook to delete (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_delete_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + "404": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + return response_data.response + + def _v1_webhook_delete_serialize( + self, + tenant, + v1_webhook, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = {} + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if tenant is not None: + _path_params["tenant"] = tenant + if v1_webhook is not None: + _path_params["v1-webhook"] = v1_webhook + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + # set the HTTP header `Accept` + if "Accept" not in _header_params: + _header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + + # authentication setting + _auth_settings: List[str] = ["cookieAuth", "bearerAuth"] + + return self.api_client.param_serialize( + method="DELETE", + resource_path="/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook}", + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth, + ) + + @validate_call + def v1_webhook_get( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[StrictStr, Field(description="The webhook name")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> V1Webhook: + """Get a webhook + + Get a webhook by its name + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The webhook name (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_get_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + @validate_call + def v1_webhook_get_with_http_info( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[StrictStr, Field(description="The webhook name")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[V1Webhook]: + """Get a webhook + + Get a webhook by its name + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The webhook name (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_get_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + @validate_call + def v1_webhook_get_without_preload_content( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[StrictStr, Field(description="The webhook name")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Get a webhook + + Get a webhook by its name + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The webhook name (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_get_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1Webhook", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + return response_data.response + + def _v1_webhook_get_serialize( + self, + tenant, + v1_webhook, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = {} + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if tenant is not None: + _path_params["tenant"] = tenant + if v1_webhook is not None: + _path_params["v1-webhook"] = v1_webhook + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + # set the HTTP header `Accept` + if "Accept" not in _header_params: + _header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + + # authentication setting + _auth_settings: List[str] = ["cookieAuth", "bearerAuth"] + + return self.api_client.param_serialize( + method="GET", + resource_path="/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook}", + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth, + ) + + @validate_call + def v1_webhook_list( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + offset: Annotated[ + Optional[StrictInt], Field(description="The number to skip") + ] = None, + limit: Annotated[ + Optional[StrictInt], Field(description="The number to limit by") + ] = None, + source_names: Annotated[ + Optional[List[V1WebhookSourceName]], + Field(description="The source names to filter by"), + ] = None, + webhook_names: Annotated[ + Optional[List[StrictStr]], + Field(description="The webhook names to filter by"), + ] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> V1WebhookList: + """List webhooks + + Lists all webhook for a tenant. + + :param tenant: The tenant id (required) + :type tenant: str + :param offset: The number to skip + :type offset: int + :param limit: The number to limit by + :type limit: int + :param source_names: The source names to filter by + :type source_names: List[V1WebhookSourceName] + :param webhook_names: The webhook names to filter by + :type webhook_names: List[str] + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_list_serialize( + tenant=tenant, + offset=offset, + limit=limit, + source_names=source_names, + webhook_names=webhook_names, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1WebhookList", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + @validate_call + def v1_webhook_list_with_http_info( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + offset: Annotated[ + Optional[StrictInt], Field(description="The number to skip") + ] = None, + limit: Annotated[ + Optional[StrictInt], Field(description="The number to limit by") + ] = None, + source_names: Annotated[ + Optional[List[V1WebhookSourceName]], + Field(description="The source names to filter by"), + ] = None, + webhook_names: Annotated[ + Optional[List[StrictStr]], + Field(description="The webhook names to filter by"), + ] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[V1WebhookList]: + """List webhooks + + Lists all webhook for a tenant. + + :param tenant: The tenant id (required) + :type tenant: str + :param offset: The number to skip + :type offset: int + :param limit: The number to limit by + :type limit: int + :param source_names: The source names to filter by + :type source_names: List[V1WebhookSourceName] + :param webhook_names: The webhook names to filter by + :type webhook_names: List[str] + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_list_serialize( + tenant=tenant, + offset=offset, + limit=limit, + source_names=source_names, + webhook_names=webhook_names, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1WebhookList", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + @validate_call + def v1_webhook_list_without_preload_content( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + offset: Annotated[ + Optional[StrictInt], Field(description="The number to skip") + ] = None, + limit: Annotated[ + Optional[StrictInt], Field(description="The number to limit by") + ] = None, + source_names: Annotated[ + Optional[List[V1WebhookSourceName]], + Field(description="The source names to filter by"), + ] = None, + webhook_names: Annotated[ + Optional[List[StrictStr]], + Field(description="The webhook names to filter by"), + ] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """List webhooks + + Lists all webhook for a tenant. + + :param tenant: The tenant id (required) + :type tenant: str + :param offset: The number to skip + :type offset: int + :param limit: The number to limit by + :type limit: int + :param source_names: The source names to filter by + :type source_names: List[V1WebhookSourceName] + :param webhook_names: The webhook names to filter by + :type webhook_names: List[str] + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_list_serialize( + tenant=tenant, + offset=offset, + limit=limit, + source_names=source_names, + webhook_names=webhook_names, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1WebhookList", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + return response_data.response + + def _v1_webhook_list_serialize( + self, + tenant, + offset, + limit, + source_names, + webhook_names, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + "sourceNames": "multi", + "webhookNames": "multi", + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if tenant is not None: + _path_params["tenant"] = tenant + # process the query parameters + if offset is not None: + + _query_params.append(("offset", offset)) + + if limit is not None: + + _query_params.append(("limit", limit)) + + if source_names is not None: + + _query_params.append(("sourceNames", source_names)) + + if webhook_names is not None: + + _query_params.append(("webhookNames", webhook_names)) + + # process the header parameters + # process the form parameters + # process the body parameter + + # set the HTTP header `Accept` + if "Accept" not in _header_params: + _header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + + # authentication setting + _auth_settings: List[str] = ["cookieAuth", "bearerAuth"] + + return self.api_client.param_serialize( + method="GET", + resource_path="/api/v1/stable/tenants/{tenant}/webhooks", + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth, + ) + + @validate_call + def v1_webhook_receive( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[StrictStr, Field(description="The webhook name")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> V1WebhookReceive200Response: + """Post a webhook message + + Post an incoming webhook message + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The webhook name (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_receive_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1WebhookReceive200Response", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + @validate_call + def v1_webhook_receive_with_http_info( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[StrictStr, Field(description="The webhook name")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[V1WebhookReceive200Response]: + """Post a webhook message + + Post an incoming webhook message + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The webhook name (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_receive_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1WebhookReceive200Response", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + @validate_call + def v1_webhook_receive_without_preload_content( + self, + tenant: Annotated[ + str, + Field( + min_length=36, strict=True, max_length=36, description="The tenant id" + ), + ], + v1_webhook: Annotated[StrictStr, Field(description="The webhook name")], + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] + ], + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Post a webhook message + + Post an incoming webhook message + + :param tenant: The tenant id (required) + :type tenant: str + :param v1_webhook: The webhook name (required) + :type v1_webhook: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._v1_webhook_receive_serialize( + tenant=tenant, + v1_webhook=v1_webhook, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index, + ) + + _response_types_map: Dict[str, Optional[str]] = { + "200": "V1WebhookReceive200Response", + "400": "APIErrors", + "403": "APIErrors", + } + response_data = self.api_client.call_api( + *_param, _request_timeout=_request_timeout + ) + return response_data.response + + def _v1_webhook_receive_serialize( + self, + tenant, + v1_webhook, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = {} + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if tenant is not None: + _path_params["tenant"] = tenant + if v1_webhook is not None: + _path_params["v1-webhook"] = v1_webhook + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + + # set the HTTP header `Accept` + if "Accept" not in _header_params: + _header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + + # authentication setting + _auth_settings: List[str] = [] + + return self.api_client.param_serialize( + method="POST", + resource_path="/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook}", + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth, + ) diff --git a/sdks/python/hatchet_sdk/clients/rest/models/__init__.py b/sdks/python/hatchet_sdk/clients/rest/models/__init__.py index 483bb47c1..910309aae 100644 --- a/sdks/python/hatchet_sdk/clients/rest/models/__init__.py +++ b/sdks/python/hatchet_sdk/clients/rest/models/__init__.py @@ -204,6 +204,21 @@ from hatchet_sdk.clients.rest.models.v1_cel_debug_response_status import ( from hatchet_sdk.clients.rest.models.v1_create_filter_request import ( V1CreateFilterRequest, ) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request import ( + V1CreateWebhookRequest, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import ( + V1CreateWebhookRequestAPIKey, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_base import ( + V1CreateWebhookRequestBase, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import ( + V1CreateWebhookRequestBasicAuth, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import ( + V1CreateWebhookRequestHMAC, +) from hatchet_sdk.clients.rest.models.v1_dag_children import V1DagChildren from hatchet_sdk.clients.rest.models.v1_event import V1Event from hatchet_sdk.clients.rest.models.v1_event_list import V1EventList @@ -237,6 +252,22 @@ from hatchet_sdk.clients.rest.models.v1_trigger_workflow_run_request import ( from hatchet_sdk.clients.rest.models.v1_update_filter_request import ( V1UpdateFilterRequest, ) +from hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook +from hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth +from hatchet_sdk.clients.rest.models.v1_webhook_auth_type import V1WebhookAuthType +from hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import ( + V1WebhookHMACAlgorithm, +) +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import ( + V1WebhookHMACEncoding, +) +from hatchet_sdk.clients.rest.models.v1_webhook_list import V1WebhookList +from hatchet_sdk.clients.rest.models.v1_webhook_receive200_response import ( + V1WebhookReceive200Response, +) +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName from hatchet_sdk.clients.rest.models.v1_workflow_run import V1WorkflowRun from hatchet_sdk.clients.rest.models.v1_workflow_run_details import V1WorkflowRunDetails from hatchet_sdk.clients.rest.models.v1_workflow_run_display_name import ( diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request.py new file mode 100644 index 000000000..95d5a6945 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request.py @@ -0,0 +1,215 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +from typing import Any, Dict, List, Optional, Set, Union + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictStr, + ValidationError, + field_validator, +) +from typing_extensions import Literal, Self + +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_api_key import ( + V1CreateWebhookRequestAPIKey, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_basic_auth import ( + V1CreateWebhookRequestBasicAuth, +) +from hatchet_sdk.clients.rest.models.v1_create_webhook_request_hmac import ( + V1CreateWebhookRequestHMAC, +) + +V1CREATEWEBHOOKREQUEST_ONE_OF_SCHEMAS = [ + "V1CreateWebhookRequestAPIKey", + "V1CreateWebhookRequestBasicAuth", + "V1CreateWebhookRequestHMAC", +] + + +class V1CreateWebhookRequest(BaseModel): + """ + V1CreateWebhookRequest + """ + + # data type: V1CreateWebhookRequestBasicAuth + oneof_schema_1_validator: Optional[V1CreateWebhookRequestBasicAuth] = None + # data type: V1CreateWebhookRequestAPIKey + oneof_schema_2_validator: Optional[V1CreateWebhookRequestAPIKey] = None + # data type: V1CreateWebhookRequestHMAC + oneof_schema_3_validator: Optional[V1CreateWebhookRequestHMAC] = None + actual_instance: Optional[ + Union[ + V1CreateWebhookRequestAPIKey, + V1CreateWebhookRequestBasicAuth, + V1CreateWebhookRequestHMAC, + ] + ] = None + one_of_schemas: Set[str] = { + "V1CreateWebhookRequestAPIKey", + "V1CreateWebhookRequestBasicAuth", + "V1CreateWebhookRequestHMAC", + } + + model_config = ConfigDict( + validate_assignment=True, + protected_namespaces=(), + ) + + def __init__(self, *args, **kwargs) -> None: + if args: + if len(args) > 1: + raise ValueError( + "If a position argument is used, only 1 is allowed to set `actual_instance`" + ) + if kwargs: + raise ValueError( + "If a position argument is used, keyword arguments cannot be used." + ) + super().__init__(actual_instance=args[0]) + else: + super().__init__(**kwargs) + + @field_validator("actual_instance") + def actual_instance_must_validate_oneof(cls, v): + instance = V1CreateWebhookRequest.model_construct() + error_messages = [] + match = 0 + # validate data type: V1CreateWebhookRequestBasicAuth + if not isinstance(v, V1CreateWebhookRequestBasicAuth): + error_messages.append( + f"Error! Input type `{type(v)}` is not `V1CreateWebhookRequestBasicAuth`" + ) + else: + match += 1 + # validate data type: V1CreateWebhookRequestAPIKey + if not isinstance(v, V1CreateWebhookRequestAPIKey): + error_messages.append( + f"Error! Input type `{type(v)}` is not `V1CreateWebhookRequestAPIKey`" + ) + else: + match += 1 + # validate data type: V1CreateWebhookRequestHMAC + if not isinstance(v, V1CreateWebhookRequestHMAC): + error_messages.append( + f"Error! Input type `{type(v)}` is not `V1CreateWebhookRequestHMAC`" + ) + else: + match += 1 + if match > 1: + # more than 1 match + raise ValueError( + "Multiple matches found when setting `actual_instance` in V1CreateWebhookRequest with oneOf schemas: V1CreateWebhookRequestAPIKey, V1CreateWebhookRequestBasicAuth, V1CreateWebhookRequestHMAC. Details: " + + ", ".join(error_messages) + ) + elif match == 0: + # no match + raise ValueError( + "No match found when setting `actual_instance` in V1CreateWebhookRequest with oneOf schemas: V1CreateWebhookRequestAPIKey, V1CreateWebhookRequestBasicAuth, V1CreateWebhookRequestHMAC. Details: " + + ", ".join(error_messages) + ) + else: + return v + + @classmethod + def from_dict(cls, obj: Union[str, Dict[str, Any]]) -> Self: + return cls.from_json(json.dumps(obj)) + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Returns the object represented by the json string""" + instance = cls.model_construct() + error_messages = [] + match = 0 + + # deserialize data into V1CreateWebhookRequestBasicAuth + try: + instance.actual_instance = V1CreateWebhookRequestBasicAuth.from_json( + json_str + ) + match += 1 + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + # deserialize data into V1CreateWebhookRequestAPIKey + try: + instance.actual_instance = V1CreateWebhookRequestAPIKey.from_json(json_str) + match += 1 + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + # deserialize data into V1CreateWebhookRequestHMAC + try: + instance.actual_instance = V1CreateWebhookRequestHMAC.from_json(json_str) + match += 1 + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + + if match > 1: + # more than 1 match + raise ValueError( + "Multiple matches found when deserializing the JSON string into V1CreateWebhookRequest with oneOf schemas: V1CreateWebhookRequestAPIKey, V1CreateWebhookRequestBasicAuth, V1CreateWebhookRequestHMAC. Details: " + + ", ".join(error_messages) + ) + elif match == 0: + # no match + raise ValueError( + "No match found when deserializing the JSON string into V1CreateWebhookRequest with oneOf schemas: V1CreateWebhookRequestAPIKey, V1CreateWebhookRequestBasicAuth, V1CreateWebhookRequestHMAC. Details: " + + ", ".join(error_messages) + ) + else: + return instance + + def to_json(self) -> str: + """Returns the JSON representation of the actual instance""" + if self.actual_instance is None: + return "null" + + if hasattr(self.actual_instance, "to_json") and callable( + self.actual_instance.to_json + ): + return self.actual_instance.to_json() + else: + return json.dumps(self.actual_instance) + + def to_dict( + self, + ) -> Optional[ + Union[ + Dict[str, Any], + V1CreateWebhookRequestAPIKey, + V1CreateWebhookRequestBasicAuth, + V1CreateWebhookRequestHMAC, + ] + ]: + """Returns the dict representation of the actual instance""" + if self.actual_instance is None: + return None + + if hasattr(self.actual_instance, "to_dict") and callable( + self.actual_instance.to_dict + ): + return self.actual_instance.to_dict() + else: + # primitive type + return self.actual_instance + + def to_str(self) -> str: + """Returns the string representation of the actual instance""" + return pprint.pformat(self.model_dump()) diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_api_key.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_api_key.py new file mode 100644 index 000000000..7eb1e35ec --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_api_key.py @@ -0,0 +1,126 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing_extensions import Self + +from hatchet_sdk.clients.rest.models.v1_webhook_api_key_auth import V1WebhookAPIKeyAuth +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName + + +class V1CreateWebhookRequestAPIKey(BaseModel): + """ + V1CreateWebhookRequestAPIKey + """ # noqa: E501 + + source_name: V1WebhookSourceName = Field( + description="The name of the source for this webhook", alias="sourceName" + ) + name: StrictStr = Field(description="The name of the webhook") + event_key_expression: StrictStr = Field( + description="The CEL expression to use for the event key. This is used to create the event key from the webhook payload.", + alias="eventKeyExpression", + ) + auth_type: StrictStr = Field( + description="The type of authentication to use for the webhook", + alias="authType", + ) + auth: V1WebhookAPIKeyAuth + __properties: ClassVar[List[str]] = [ + "sourceName", + "name", + "eventKeyExpression", + "authType", + "auth", + ] + + @field_validator("auth_type") + def auth_type_validate_enum(cls, value): + """Validates the enum""" + if value not in set(["API_KEY"]): + raise ValueError("must be one of enum values ('API_KEY')") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestAPIKey from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of auth + if self.auth: + _dict["auth"] = self.auth.to_dict() + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestAPIKey from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + { + "sourceName": obj.get("sourceName"), + "name": obj.get("name"), + "eventKeyExpression": obj.get("eventKeyExpression"), + "authType": obj.get("authType"), + "auth": ( + V1WebhookAPIKeyAuth.from_dict(obj["auth"]) + if obj.get("auth") is not None + else None + ), + } + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_api_key_all_of_auth_type.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_api_key_all_of_auth_type.py new file mode 100644 index 000000000..214f02b60 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_api_key_all_of_auth_type.py @@ -0,0 +1,82 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict +from typing_extensions import Self + + +class V1CreateWebhookRequestAPIKeyAllOfAuthType(BaseModel): + """ + V1CreateWebhookRequestAPIKeyAllOfAuthType + """ # noqa: E501 + + __properties: ClassVar[List[str]] = [] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestAPIKeyAllOfAuthType from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestAPIKeyAllOfAuthType from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({}) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_base.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_base.py new file mode 100644 index 000000000..1363e5ce7 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_base.py @@ -0,0 +1,98 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing_extensions import Self + +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName + + +class V1CreateWebhookRequestBase(BaseModel): + """ + V1CreateWebhookRequestBase + """ # noqa: E501 + + source_name: V1WebhookSourceName = Field( + description="The name of the source for this webhook", alias="sourceName" + ) + name: StrictStr = Field(description="The name of the webhook") + event_key_expression: StrictStr = Field( + description="The CEL expression to use for the event key. This is used to create the event key from the webhook payload.", + alias="eventKeyExpression", + ) + __properties: ClassVar[List[str]] = ["sourceName", "name", "eventKeyExpression"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestBase from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestBase from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + { + "sourceName": obj.get("sourceName"), + "name": obj.get("name"), + "eventKeyExpression": obj.get("eventKeyExpression"), + } + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_basic_auth.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_basic_auth.py new file mode 100644 index 000000000..092e2541f --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_basic_auth.py @@ -0,0 +1,126 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing_extensions import Self + +from hatchet_sdk.clients.rest.models.v1_webhook_basic_auth import V1WebhookBasicAuth +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName + + +class V1CreateWebhookRequestBasicAuth(BaseModel): + """ + V1CreateWebhookRequestBasicAuth + """ # noqa: E501 + + source_name: V1WebhookSourceName = Field( + description="The name of the source for this webhook", alias="sourceName" + ) + name: StrictStr = Field(description="The name of the webhook") + event_key_expression: StrictStr = Field( + description="The CEL expression to use for the event key. This is used to create the event key from the webhook payload.", + alias="eventKeyExpression", + ) + auth_type: StrictStr = Field( + description="The type of authentication to use for the webhook", + alias="authType", + ) + auth: V1WebhookBasicAuth + __properties: ClassVar[List[str]] = [ + "sourceName", + "name", + "eventKeyExpression", + "authType", + "auth", + ] + + @field_validator("auth_type") + def auth_type_validate_enum(cls, value): + """Validates the enum""" + if value not in set(["BASIC"]): + raise ValueError("must be one of enum values ('BASIC')") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestBasicAuth from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of auth + if self.auth: + _dict["auth"] = self.auth.to_dict() + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestBasicAuth from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + { + "sourceName": obj.get("sourceName"), + "name": obj.get("name"), + "eventKeyExpression": obj.get("eventKeyExpression"), + "authType": obj.get("authType"), + "auth": ( + V1WebhookBasicAuth.from_dict(obj["auth"]) + if obj.get("auth") is not None + else None + ), + } + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_basic_auth_all_of_auth_type.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_basic_auth_all_of_auth_type.py new file mode 100644 index 000000000..b4e74906d --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_basic_auth_all_of_auth_type.py @@ -0,0 +1,82 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict +from typing_extensions import Self + + +class V1CreateWebhookRequestBasicAuthAllOfAuthType(BaseModel): + """ + V1CreateWebhookRequestBasicAuthAllOfAuthType + """ # noqa: E501 + + __properties: ClassVar[List[str]] = [] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestBasicAuthAllOfAuthType from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestBasicAuthAllOfAuthType from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({}) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_hmac.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_hmac.py new file mode 100644 index 000000000..b58316836 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_hmac.py @@ -0,0 +1,126 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing_extensions import Self + +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_auth import V1WebhookHMACAuth +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName + + +class V1CreateWebhookRequestHMAC(BaseModel): + """ + V1CreateWebhookRequestHMAC + """ # noqa: E501 + + source_name: V1WebhookSourceName = Field( + description="The name of the source for this webhook", alias="sourceName" + ) + name: StrictStr = Field(description="The name of the webhook") + event_key_expression: StrictStr = Field( + description="The CEL expression to use for the event key. This is used to create the event key from the webhook payload.", + alias="eventKeyExpression", + ) + auth_type: StrictStr = Field( + description="The type of authentication to use for the webhook", + alias="authType", + ) + auth: V1WebhookHMACAuth + __properties: ClassVar[List[str]] = [ + "sourceName", + "name", + "eventKeyExpression", + "authType", + "auth", + ] + + @field_validator("auth_type") + def auth_type_validate_enum(cls, value): + """Validates the enum""" + if value not in set(["HMAC"]): + raise ValueError("must be one of enum values ('HMAC')") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestHMAC from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of auth + if self.auth: + _dict["auth"] = self.auth.to_dict() + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestHMAC from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + { + "sourceName": obj.get("sourceName"), + "name": obj.get("name"), + "eventKeyExpression": obj.get("eventKeyExpression"), + "authType": obj.get("authType"), + "auth": ( + V1WebhookHMACAuth.from_dict(obj["auth"]) + if obj.get("auth") is not None + else None + ), + } + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_hmac_all_of_auth_type.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_hmac_all_of_auth_type.py new file mode 100644 index 000000000..25f2b7122 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_create_webhook_request_hmac_all_of_auth_type.py @@ -0,0 +1,82 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict +from typing_extensions import Self + + +class V1CreateWebhookRequestHMACAllOfAuthType(BaseModel): + """ + V1CreateWebhookRequestHMACAllOfAuthType + """ # noqa: E501 + + __properties: ClassVar[List[str]] = [] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestHMACAllOfAuthType from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1CreateWebhookRequestHMACAllOfAuthType from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({}) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_event.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_event.py index a68863eb5..4f09c3985 100644 --- a/sdks/python/hatchet_sdk/clients/rest/models/v1_event.py +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_event.py @@ -71,6 +71,11 @@ class V1Event(BaseModel): description="The external IDs of the runs that were triggered by this event.", alias="triggeredRuns", ) + triggering_webhook_name: Optional[StrictStr] = Field( + default=None, + description="The name of the webhook that triggered this event, if applicable.", + alias="triggeringWebhookName", + ) __properties: ClassVar[List[str]] = [ "metadata", "key", @@ -82,6 +87,7 @@ class V1Event(BaseModel): "scope", "seenAt", "triggeredRuns", + "triggeringWebhookName", ] model_config = ConfigDict( @@ -179,6 +185,7 @@ class V1Event(BaseModel): if obj.get("triggeredRuns") is not None else None ), + "triggeringWebhookName": obj.get("triggeringWebhookName"), } ) return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook.py new file mode 100644 index 000000000..53f869c05 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook.py @@ -0,0 +1,126 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing_extensions import Self + +from hatchet_sdk.clients.rest.models.api_resource_meta import APIResourceMeta +from hatchet_sdk.clients.rest.models.v1_webhook_auth_type import V1WebhookAuthType +from hatchet_sdk.clients.rest.models.v1_webhook_source_name import V1WebhookSourceName + + +class V1Webhook(BaseModel): + """ + V1Webhook + """ # noqa: E501 + + metadata: APIResourceMeta + tenant_id: StrictStr = Field( + description="The ID of the tenant associated with this webhook.", + alias="tenantId", + ) + name: StrictStr = Field(description="The name of the webhook") + source_name: V1WebhookSourceName = Field( + description="The name of the source for this webhook", alias="sourceName" + ) + event_key_expression: StrictStr = Field( + description="The CEL expression to use for the event key. This is used to create the event key from the webhook payload.", + alias="eventKeyExpression", + ) + auth_type: V1WebhookAuthType = Field( + description="The type of authentication to use for the webhook", + alias="authType", + ) + __properties: ClassVar[List[str]] = [ + "metadata", + "tenantId", + "name", + "sourceName", + "eventKeyExpression", + "authType", + ] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1Webhook from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of metadata + if self.metadata: + _dict["metadata"] = self.metadata.to_dict() + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1Webhook from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + { + "metadata": ( + APIResourceMeta.from_dict(obj["metadata"]) + if obj.get("metadata") is not None + else None + ), + "tenantId": obj.get("tenantId"), + "name": obj.get("name"), + "sourceName": obj.get("sourceName"), + "eventKeyExpression": obj.get("eventKeyExpression"), + "authType": obj.get("authType"), + } + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_api_key_auth.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_api_key_auth.py new file mode 100644 index 000000000..ce4e5d477 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_api_key_auth.py @@ -0,0 +1,90 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing_extensions import Self + + +class V1WebhookAPIKeyAuth(BaseModel): + """ + V1WebhookAPIKeyAuth + """ # noqa: E501 + + header_name: StrictStr = Field( + description="The name of the header to use for the API key", alias="headerName" + ) + api_key: StrictStr = Field( + description="The API key to use for authentication", alias="apiKey" + ) + __properties: ClassVar[List[str]] = ["headerName", "apiKey"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1WebhookAPIKeyAuth from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1WebhookAPIKeyAuth from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + {"headerName": obj.get("headerName"), "apiKey": obj.get("apiKey")} + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_auth_type.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_auth_type.py new file mode 100644 index 000000000..db52e4eff --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_auth_type.py @@ -0,0 +1,38 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +from enum import Enum + +from typing_extensions import Self + + +class V1WebhookAuthType(str, Enum): + """ + V1WebhookAuthType + """ + + """ + allowed enum values + """ + BASIC = "BASIC" + API_KEY = "API_KEY" + HMAC = "HMAC" + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of V1WebhookAuthType from a JSON string""" + return cls(json.loads(json_str)) diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_basic_auth.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_basic_auth.py new file mode 100644 index 000000000..8a11fa27d --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_basic_auth.py @@ -0,0 +1,86 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing_extensions import Self + + +class V1WebhookBasicAuth(BaseModel): + """ + V1WebhookBasicAuth + """ # noqa: E501 + + username: StrictStr = Field(description="The username for basic auth") + password: StrictStr = Field(description="The password for basic auth") + __properties: ClassVar[List[str]] = ["username", "password"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1WebhookBasicAuth from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1WebhookBasicAuth from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + {"username": obj.get("username"), "password": obj.get("password")} + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_algorithm.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_algorithm.py new file mode 100644 index 000000000..a211d4875 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_algorithm.py @@ -0,0 +1,39 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +from enum import Enum + +from typing_extensions import Self + + +class V1WebhookHMACAlgorithm(str, Enum): + """ + V1WebhookHMACAlgorithm + """ + + """ + allowed enum values + """ + SHA1 = "SHA1" + SHA256 = "SHA256" + SHA512 = "SHA512" + MD5 = "MD5" + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of V1WebhookHMACAlgorithm from a JSON string""" + return cls(json.loads(json_str)) diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_auth.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_auth.py new file mode 100644 index 000000000..02c8e7f94 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_auth.py @@ -0,0 +1,115 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing_extensions import Self + +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_algorithm import ( + V1WebhookHMACAlgorithm, +) +from hatchet_sdk.clients.rest.models.v1_webhook_hmac_encoding import ( + V1WebhookHMACEncoding, +) + + +class V1WebhookHMACAuth(BaseModel): + """ + V1WebhookHMACAuth + """ # noqa: E501 + + algorithm: V1WebhookHMACAlgorithm = Field( + description="The HMAC algorithm to use for the webhook" + ) + encoding: V1WebhookHMACEncoding = Field( + description="The encoding to use for the HMAC signature" + ) + signature_header_name: StrictStr = Field( + description="The name of the header to use for the HMAC signature", + alias="signatureHeaderName", + ) + signing_secret: StrictStr = Field( + description="The secret key used to sign the HMAC signature", + alias="signingSecret", + ) + __properties: ClassVar[List[str]] = [ + "algorithm", + "encoding", + "signatureHeaderName", + "signingSecret", + ] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1WebhookHMACAuth from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1WebhookHMACAuth from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + { + "algorithm": obj.get("algorithm"), + "encoding": obj.get("encoding"), + "signatureHeaderName": obj.get("signatureHeaderName"), + "signingSecret": obj.get("signingSecret"), + } + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_encoding.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_encoding.py new file mode 100644 index 000000000..f0ba9991f --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_hmac_encoding.py @@ -0,0 +1,38 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +from enum import Enum + +from typing_extensions import Self + + +class V1WebhookHMACEncoding(str, Enum): + """ + V1WebhookHMACEncoding + """ + + """ + allowed enum values + """ + HEX = "HEX" + BASE64 = "BASE64" + BASE64URL = "BASE64URL" + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of V1WebhookHMACEncoding from a JSON string""" + return cls(json.loads(json_str)) diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_list.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_list.py new file mode 100644 index 000000000..284a7db15 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_list.py @@ -0,0 +1,110 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict +from typing_extensions import Self + +from hatchet_sdk.clients.rest.models.pagination_response import PaginationResponse +from hatchet_sdk.clients.rest.models.v1_webhook import V1Webhook + + +class V1WebhookList(BaseModel): + """ + V1WebhookList + """ # noqa: E501 + + pagination: Optional[PaginationResponse] = None + rows: Optional[List[V1Webhook]] = None + __properties: ClassVar[List[str]] = ["pagination", "rows"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1WebhookList from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of pagination + if self.pagination: + _dict["pagination"] = self.pagination.to_dict() + # override the default output from pydantic by calling `to_dict()` of each item in rows (list) + _items = [] + if self.rows: + for _item_rows in self.rows: + if _item_rows: + _items.append(_item_rows.to_dict()) + _dict["rows"] = _items + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1WebhookList from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate( + { + "pagination": ( + PaginationResponse.from_dict(obj["pagination"]) + if obj.get("pagination") is not None + else None + ), + "rows": ( + [V1Webhook.from_dict(_item) for _item in obj["rows"]] + if obj.get("rows") is not None + else None + ), + } + ) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_receive200_response.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_receive200_response.py new file mode 100644 index 000000000..9e45d8459 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_receive200_response.py @@ -0,0 +1,83 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +import pprint +import re # noqa: F401 +from typing import Any, ClassVar, Dict, List, Optional, Set + +from pydantic import BaseModel, ConfigDict, StrictStr +from typing_extensions import Self + + +class V1WebhookReceive200Response(BaseModel): + """ + V1WebhookReceive200Response + """ # noqa: E501 + + message: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["message"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of V1WebhookReceive200Response from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of V1WebhookReceive200Response from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({"message": obj.get("message")}) + return _obj diff --git a/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_source_name.py b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_source_name.py new file mode 100644 index 000000000..66c0ff480 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/rest/models/v1_webhook_source_name.py @@ -0,0 +1,38 @@ +# coding: utf-8 + +""" + Hatchet API + + The Hatchet API + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations + +import json +from enum import Enum + +from typing_extensions import Self + + +class V1WebhookSourceName(str, Enum): + """ + V1WebhookSourceName + """ + + """ + allowed enum values + """ + GENERIC = "GENERIC" + GITHUB = "GITHUB" + STRIPE = "STRIPE" + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of V1WebhookSourceName from a JSON string""" + return cls(json.loads(json_str)) diff --git a/sdks/typescript/src/clients/rest/generated/Api.ts b/sdks/typescript/src/clients/rest/generated/Api.ts index 9c3495033..cfc4bf389 100644 --- a/sdks/typescript/src/clients/rest/generated/Api.ts +++ b/sdks/typescript/src/clients/rest/generated/Api.ts @@ -89,6 +89,7 @@ import { V1CELDebugRequest, V1CELDebugResponse, V1CreateFilterRequest, + V1CreateWebhookRequest, V1DagChildren, V1EventList, V1Filter, @@ -105,6 +106,9 @@ import { V1TaskTimingList, V1TriggerWorkflowRunRequest, V1UpdateFilterRequest, + V1Webhook, + V1WebhookList, + V1WebhookSourceName, V1WorkflowRunDetails, V1WorkflowRunDisplayNameList, WebhookWorkerCreated, @@ -763,6 +767,117 @@ export class Api extends HttpClient + this.request({ + path: `/api/v1/stable/tenants/${tenant}/webhooks`, + method: 'GET', + query: query, + secure: true, + format: 'json', + ...params, + }); + /** + * @description Create a new webhook + * + * @tags Webhook + * @name V1WebhookCreate + * @summary Create a webhook + * @request POST:/api/v1/stable/tenants/{tenant}/webhooks + * @secure + */ + v1WebhookCreate = (tenant: string, data: V1CreateWebhookRequest, params: RequestParams = {}) => + this.request({ + path: `/api/v1/stable/tenants/${tenant}/webhooks`, + method: 'POST', + body: data, + secure: true, + type: ContentType.Json, + format: 'json', + ...params, + }); + /** + * @description Get a webhook by its name + * + * @tags Webhook + * @name V1WebhookGet + * @summary Get a webhook + * @request GET:/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook} + * @secure + */ + v1WebhookGet = (tenant: string, v1Webhook: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/stable/tenants/${tenant}/webhooks/${v1Webhook}`, + method: 'GET', + secure: true, + format: 'json', + ...params, + }); + /** + * @description Delete a webhook + * + * @tags Webhook + * @name V1WebhookDelete + * @request DELETE:/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook} + * @secure + */ + v1WebhookDelete = (tenant: string, v1Webhook: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/stable/tenants/${tenant}/webhooks/${v1Webhook}`, + method: 'DELETE', + secure: true, + format: 'json', + ...params, + }); + /** + * @description Post an incoming webhook message + * + * @tags Webhook + * @name V1WebhookReceive + * @summary Post a webhook message + * @request POST:/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook} + */ + v1WebhookReceive = (tenant: string, v1Webhook: string, data?: any, params: RequestParams = {}) => + this.request< + { + /** @example "OK" */ + message?: string; + }, + APIErrors + >({ + path: `/api/v1/stable/tenants/${tenant}/webhooks/${v1Webhook}`, + method: 'POST', + body: data, + format: 'json', + ...params, + }); /** * @description Evaluate a CEL expression against provided input data. * diff --git a/sdks/typescript/src/clients/rest/generated/data-contracts.ts b/sdks/typescript/src/clients/rest/generated/data-contracts.ts index e2d5c01e4..4d2899731 100644 --- a/sdks/typescript/src/clients/rest/generated/data-contracts.ts +++ b/sdks/typescript/src/clients/rest/generated/data-contracts.ts @@ -595,6 +595,8 @@ export interface V1EventList { */ filterId?: string; }[]; + /** The name of the webhook that triggered this event, if applicable. */ + triggeringWebhookName?: string; }[]; } @@ -622,6 +624,37 @@ export interface V1Filter { payload: object; } +export interface V1WebhookList { + pagination?: PaginationResponse; + rows?: V1Webhook[]; +} + +export interface V1Webhook { + metadata: APIResourceMeta; + /** The ID of the tenant associated with this webhook. */ + tenantId: string; + /** The name of the webhook */ + name: string; + /** The name of the source for this webhook */ + sourceName: V1WebhookSourceName; + /** The CEL expression to use for the event key. This is used to create the event key from the webhook payload. */ + eventKeyExpression: string; + /** The type of authentication to use for the webhook */ + authType: V1WebhookAuthType; +} + +export enum V1WebhookSourceName { + GENERIC = 'GENERIC', + GITHUB = 'GITHUB', + STRIPE = 'STRIPE', +} + +export enum V1WebhookAuthType { + BASIC = 'BASIC', + API_KEY = 'API_KEY', + HMAC = 'HMAC', +} + export interface RateLimit { /** The key for the rate limit. */ key: string; @@ -1967,6 +2000,49 @@ export interface V1CreateFilterRequest { payload?: object; } +export type V1CreateWebhookRequest = + | ({ + /** The name of the source for this webhook */ + sourceName: V1WebhookSourceName; + /** The name of the webhook */ + name: string; + /** The CEL expression to use for the event key. This is used to create the event key from the webhook payload. */ + eventKeyExpression: string; + } & { + /** The type of authentication to use for the webhook */ + authType: 'BASIC'; + auth: { + /** The username for basic auth */ + username: string; + /** The password for basic auth */ + password: string; + }; + }) + | { + /** The type of authentication to use for the webhook */ + authType: 'API_KEY'; + auth: { + /** The name of the header to use for the API key */ + headerName: string; + /** The API key to use for authentication */ + apiKey: string; + }; + } + | { + /** The type of authentication to use for the webhook */ + authType: 'HMAC'; + auth: { + /** The HMAC algorithm to use for the webhook */ + algorithm: 'SHA1' | 'SHA256' | 'SHA512' | 'MD5'; + /** The encoding to use for the HMAC signature */ + encoding: 'HEX' | 'BASE64' | 'BASE64URL'; + /** The name of the header to use for the HMAC signature */ + signatureHeaderName: string; + /** The secret key used to sign the HMAC signature */ + signingSecret: string; + }; + }; + export interface V1UpdateFilterRequest { /** The expression for the filter */ expression?: string; diff --git a/sql/schema/v0.sql b/sql/schema/v0.sql index 25e0e871d..95130dca1 100644 --- a/sql/schema/v0.sql +++ b/sql/schema/v0.sql @@ -44,7 +44,8 @@ CREATE TYPE "LimitResource" AS ENUM ( 'WORKER', 'WORKER_SLOT', 'CRON', - 'SCHEDULE' + 'SCHEDULE', + 'INCOMING_WEBHOOK' ); -- CreateEnum diff --git a/sql/schema/v1-core.sql b/sql/schema/v1-core.sql index 000912f7f..169c1e3d3 100644 --- a/sql/schema/v1-core.sql +++ b/sql/schema/v1-core.sql @@ -495,6 +495,74 @@ CREATE UNIQUE INDEX v1_filter_unique_tenant_workflow_id_scope_expression_payload payload_hash ); +CREATE TYPE v1_incoming_webhook_auth_type AS ENUM ('BASIC', 'API_KEY', 'HMAC'); +CREATE TYPE v1_incoming_webhook_hmac_algorithm AS ENUM ('SHA1', 'SHA256', 'SHA512', 'MD5'); +CREATE TYPE v1_incoming_webhook_hmac_encoding AS ENUM ('HEX', 'BASE64', 'BASE64URL'); + +-- Can add more sources in the future +CREATE TYPE v1_incoming_webhook_source_name AS ENUM ('GENERIC', 'GITHUB', 'STRIPE'); + +CREATE TABLE v1_incoming_webhook ( + tenant_id UUID NOT NULL, + + -- names are tenant-unique + name TEXT NOT NULL, + + source_name v1_incoming_webhook_source_name NOT NULL, + + -- CEL expression that creates an event key + -- from the payload of the webhook + event_key_expression TEXT NOT NULL, + + auth_method v1_incoming_webhook_auth_type NOT NULL, + + auth__basic__username TEXT, + auth__basic__password BYTEA, + + auth__api_key__header_name TEXT, + auth__api_key__key BYTEA, + + auth__hmac__algorithm v1_incoming_webhook_hmac_algorithm, + auth__hmac__encoding v1_incoming_webhook_hmac_encoding, + auth__hmac__signature_header_name TEXT, + auth__hmac__webhook_signing_secret BYTEA, + + inserted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (tenant_id, name), + CHECK ( + ( + auth_method = 'BASIC' + AND ( + auth__basic__username IS NOT NULL + AND auth__basic__password IS NOT NULL + ) + ) + OR + ( + auth_method = 'API_KEY' + AND ( + auth__api_key__header_name IS NOT NULL + AND auth__api_key__key IS NOT NULL + ) + ) + OR + ( + auth_method = 'HMAC' + AND ( + auth__hmac__algorithm IS NOT NULL + AND auth__hmac__encoding IS NOT NULL + AND auth__hmac__signature_header_name IS NOT NULL + AND auth__hmac__webhook_signing_secret IS NOT NULL + ) + ) + ), + CHECK (LENGTH(event_key_expression) > 0), + CHECK (LENGTH(name) > 0) +); + + CREATE INDEX v1_match_condition_filter_idx ON v1_match_condition ( tenant_id ASC, event_type ASC, diff --git a/sql/schema/v1-olap.sql b/sql/schema/v1-olap.sql index 06e33dde9..2b6fb7fae 100644 --- a/sql/schema/v1-olap.sql +++ b/sql/schema/v1-olap.sql @@ -336,6 +336,25 @@ CREATE TABLE v1_task_events_olap ( CREATE INDEX v1_task_events_olap_task_id_idx ON v1_task_events_olap (task_id); +CREATE TABLE v1_incoming_webhook_validation_failures_olap ( + id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY, + + tenant_id UUID NOT NULL, + + -- webhook names are tenant-unique + incoming_webhook_name TEXT NOT NULL, + + error TEXT NOT NULL, + + inserted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (inserted_at, id) +) PARTITION BY RANGE(inserted_at); + +CREATE INDEX v1_incoming_webhook_validation_failures_olap_tenant_id_incoming_webhook_name_idx ON v1_incoming_webhook_validation_failures_olap (tenant_id, incoming_webhook_name); + + -- this is a hash-partitioned table on the dag_id, so that we can process batches of events in parallel -- without needing to place conflicting locks on dags. CREATE TABLE v1_task_status_updates_tmp ( @@ -437,6 +456,7 @@ CREATE TABLE v1_events_olap ( payload JSONB NOT NULL, additional_metadata JSONB, scope TEXT, + triggering_webhook_name TEXT, PRIMARY KEY (tenant_id, seen_at, id) ) PARTITION BY RANGE(seen_at); @@ -463,6 +483,22 @@ CREATE TABLE v1_event_to_run_olap ( PRIMARY KEY (event_id, event_seen_at, run_id, run_inserted_at) ) PARTITION BY RANGE(event_seen_at); +CREATE TYPE v1_cel_evaluation_failure_source AS ENUM ('FILTER', 'WEBHOOK'); + +CREATE TABLE v1_cel_evaluation_failures_olap ( + id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY, + + tenant_id UUID NOT NULL, + + source v1_cel_evaluation_failure_source NOT NULL, + + error TEXT NOT NULL, + + inserted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (inserted_at, id) +) PARTITION BY RANGE(inserted_at); -- TRIGGERS TO LINK TASKS, DAGS AND EVENTS -- CREATE OR REPLACE FUNCTION v1_tasks_olap_insert_function()