Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03c902f58
|
||
|
|
673e665aba | ||
|
|
9dfe565df0
|
||
|
|
b67abf4ff4 | ||
|
|
0a8ccb3442
|
||
|
|
b31e65f40d | ||
|
|
e84ec8b9e5
|
||
|
|
35acb94756 | ||
|
|
1259fa25fe
|
||
|
|
3bd7cab084 | ||
|
|
cf940eb3bb
|
||
|
|
35af32dc1d
|
||
|
|
3636aacc26
|
||
|
|
a160ef216d
|
||
|
|
d650deb46f
|
||
|
|
d988e00be6
|
||
|
|
3cf073e2d5
|
||
|
|
945ebe66ff | ||
|
|
f7291b03b3 | ||
|
|
53a7844c5a | ||
|
|
d62556b764
|
||
|
|
f51b3ff75f | ||
|
|
f33e592aef
|
||
|
|
ad235f675f | ||
|
|
1c73868acb
|
||
|
|
2b2a593ded | ||
|
|
09345924e4
|
||
|
|
97a27195f8 | ||
|
|
f6ecc75d7f
|
||
|
|
0814ac59b7
|
||
|
|
6b56de589d
|
||
|
|
9584605426
|
||
|
|
e0c5d3d379 | ||
|
|
094a1fb28d | ||
|
|
fcfb6782b2 | ||
|
|
f6a1419c38 | ||
|
|
7be93496b8 | ||
|
|
1795858d0b | ||
|
|
37492d0352 | ||
|
|
9600349b7d | ||
|
|
19543936b2 | ||
|
|
58c5f91cdb | ||
|
|
0f308c2291 | ||
|
|
bfb0706ef4 | ||
|
|
b1df0d6d25 | ||
|
|
dfeb3fad99 | ||
|
|
c8b985369f | ||
|
|
d247432c38 | ||
|
|
9c063bfb27 | ||
|
|
68f1d0443b | ||
|
|
3e4c2f5bd5 | ||
|
|
1ee9e57bfd | ||
|
|
0e61d75f82 | ||
|
|
b40a1c2418 | ||
|
|
f346c245ef | ||
|
|
51c1bcf676 | ||
|
|
0cbbff6636 | ||
|
|
c73e278ee0 | ||
|
|
3f9d52d269 | ||
|
|
9e6ed3f985 | ||
|
|
f8a8e6a8e2 | ||
|
|
247f66f924 |
No files matched your search
+22
-14
@@ -1,23 +1,37 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.5.0
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
workflows:
|
||||
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release/.*|hotfix/.*|integration.*)/
|
||||
ignore: /(develop|release/.*|integration.*)/
|
||||
tags:
|
||||
only: []
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
@@ -28,6 +42,8 @@ workflows:
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
@@ -38,20 +54,12 @@ workflows:
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
only: [ main ]
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [ /v.*/ ]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [ qqq-maven-registry-credentials ]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /hotfix\/.*/
|
||||
only: [ /^v\d+\.\d+\.\d+$/ ]
|
||||
@@ -0,0 +1,20 @@
|
||||
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 3
|
||||
tab_width = 3
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.cmd text eol=crlf
|
||||
mvnw text eol=lf
|
||||
@@ -0,0 +1 @@
|
||||
* @KofTwentyTwo
|
||||
@@ -0,0 +1,25 @@
|
||||
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
|
||||
# major updates stay separate so each gets its own review. At most three version-update PRs per
|
||||
# ecosystem are open at once. Security updates are enabled in the repository settings and are
|
||||
# not limited by this schedule.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: maven
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
maven-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ['*']
|
||||
@@ -0,0 +1,25 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
@@ -18,3 +18,4 @@ hs_err_pid*
|
||||
dependency-reduced-pom.xml
|
||||
/.env.local
|
||||
/.cache/
|
||||
.claude/settings.local.json
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||
@@ -0,0 +1,60 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to coding agents working in this repository (same content as CLAUDE.md).
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit Webhooks**: signed, SSRF-safe outbound webhooks for QQQ applications. It covers:
|
||||
- table, process, ESB and custom events
|
||||
- subscriptions with filters, and an outbox with replica-safe claims
|
||||
- retries, dead letters, endpoint health, and an app home
|
||||
|
||||
Design: `docs/DESIGN.md` (from QRun-IO/qqq#1105). Inbound webhooks are phase 2 (DESIGN section 10).
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
|
||||
./mvnw test
|
||||
./mvnw verify # tests + Checkstyle + JaCoCo gate (70% instructions, 90% classes)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Entry point:** `WebhooksQBitProducer implements QBitMetaDataProducer<WebhooksQBitConfig>`. Optional: `WebhooksEsbSource.configure(qInstance)` for ESB topics.
|
||||
- **Packages:**
|
||||
- `metadata` (tables, enums, validating customizers), `registry` (event types)
|
||||
- `events`:
|
||||
- record and process listeners
|
||||
- `DeliveryOutbox`, `SubscriptionMatcher`, `PayloadBuilder`
|
||||
- `delivery`:
|
||||
- `DeliveryEngine` (claim, send, outcome), `EndpointHealth`
|
||||
- `WebhooksAlerts` (reflective Alerts SPI)
|
||||
- `secrets` (AES-GCM, env references, rotation), `processes`, `app` (home, secret widget), `permissions`, `esb`, `support/SystemWrites`.
|
||||
- Signing uses `core.utils.webhooks` directly at the QQQ 4.1.0-RC.5 baseline (QRun-IO/qqq#1160). `shared.http` remains an internal copy of the safe HTTP client pending a separate migration to `core.utils.http` (DESIGN section 11).
|
||||
|
||||
## Invariants
|
||||
|
||||
- A delivery is sent only by the runner whose insert of (`deliveryId`, `attemptNumber`) succeeded. Never send without a claim.
|
||||
- Payloads are built at queue time and stored; retries resend the same bytes and the same `webhook-id`.
|
||||
- Secrets are never stored, logged, audited, put in process results, or returned in plain text. Only fingerprints.
|
||||
- Outbound requests go only through `SafeHttpClient` (via `WebhooksRuntime.httpClient`).
|
||||
- The qbit's own writes go through `SystemWrites` (authorized scope; customizers can tell them from people's).
|
||||
- No static config caches: read config with `WebhooksRuntime.config()`.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
- Tables are lowercase snake_case, prefixed `webhooks_`; the backend name is the table name; column names are snake_case.
|
||||
- Permissions: `webhooks.view`, `webhooks.manage`, `webhooks.admin` (and `webhooks.hasAccess`).
|
||||
- Data lives in standard visible tables. Processes are table or record actions, never in the nav. The nav lists only the main tables.
|
||||
|
||||
## Code Style
|
||||
|
||||
3-space indent, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- `release/*` publishes RCs.
|
||||
- `vX.Y.Z` tags publish releases (GA tags only).
|
||||
+59
-43
@@ -1,58 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to QBit User Role Permissions will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
All notable changes to QBit Webhooks are documented in this file.
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Comprehensive README documentation following QQQ sub repo standards
|
||||
- Standard GitHub files (LICENSE, CHANGELOG, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT)
|
||||
- Proper project structure and documentation
|
||||
- Central `webhooks.endpoint.<id>` and `webhooks.dispatcher` dependency checks, every 300 seconds, using persisted endpoint health and outbox age.
|
||||
- A native home status line linking to the webhooks checks in System Health.
|
||||
- Complete host-neutral in-app help bundle for Webhooks, with live metadata references and a `HelpBundleAssertions` coverage test including the optional ESB process.
|
||||
- Foreign keys between all five tables in `docs/ddl/webhooks-postgres.sql`, named `fk_<table>_<column>`: an endpoint's secrets and subscriptions are deleted with it (CASCADE), deliveries and attempts keep their endpoint (RESTRICT: cancel and Delete Deliveries before deleting an endpoint with history), a delivery outlives its subscription (SET NULL) and attempts go with their delivery (CASCADE). New index `webhooks_delivery_subscription_ix`.
|
||||
- `docs/ddl/webhooks-foreign-keys-upgrade.sql` for existing databases: records orphan rows in `qqq_fk_quarantine` (nulls nullable references, deletes rows with a missing required parent and keeps them as JSON), replaces the NO ACTION endpoint keys, and is idempotent.
|
||||
- `ForeignKeyGuardrailTest` applies the DDL to PostgreSQL 17 (Testcontainers) and fails when a join or table possible-value field between webhooks tables has no foreign key.
|
||||
- Every table explains itself: a help bundle (`help/qbit-webhooks.yaml`, QQQ help-bundle format) says what each `webhooks_*` table is for and how its rows get created, including where event types come from. It shows on the table screen and when the table is empty. Registered through QQQ `HelpBundles`, with inline empty-state help retained for hosts without `helpContent`; a host's own help is kept.
|
||||
- A Setup card on the Webhooks home lists event types, encryption key, endpoints, signing secrets and subscriptions with their state and the next step.
|
||||
- `WebhooksTablesGuardrailTest` (each table is used at runtime, seeded or explained, and has help) and a test that a previous secret stops signing at its Signs Until date even before the maintenance job retires it.
|
||||
- Nothing is seeded, deliberately: the tables hold the owner's receivers and secrets, or logs.
|
||||
|
||||
### Removed
|
||||
- The home's separate endpoint-health attention tile, list and diagnostic queries; endpoint state, alerts and Send Test Event remain unchanged.
|
||||
|
||||
### Changed
|
||||
- Updated README to follow QQQ framework standards
|
||||
- Improved project documentation and structure
|
||||
- Enhanced GitHub repository setup
|
||||
- Pin QQQ to 4.1.0-RC.8 for the released central health API.
|
||||
- Pin QQQ to 4.1.0-RC.7 and use its help-bundle parser, registry and guarded startup synchronization. Hosts can override bundle text with a `HOST` bundle.
|
||||
- Require QQQ 4.1.0-RC.5 and use its webhook signer, verifier, secret and verification exception (QRun-IO/qqq#1160, F11). Removed the internal signing package; Java consumers must import `com.kingsrook.qqq.backend.core.utils.webhooks` instead.
|
||||
- Signing wire format, timestamp tolerance, rotation, endpoint management, encrypted secret storage and delivery behavior are unchanged.
|
||||
|
||||
## [0.30.0-SNAPSHOT] - 2024-01-XX
|
||||
## [1.0.0] - not yet released
|
||||
|
||||
A rebuild of the qbit on QQQ 4.1 (design: QRun-IO/qqq#1105, docs/DESIGN.md). Breaking: see docs/migration-0.x-to-1.0.md.
|
||||
|
||||
### Added
|
||||
- Initial QBit implementation for user role permissions
|
||||
- Database schema for users, roles, and permissions
|
||||
- Java classes for User, Role, Permission, UserRole, and RolePermission
|
||||
- Integration with QQQ Backend Core framework
|
||||
- H2 database support for testing
|
||||
- JUnit 5 test framework integration
|
||||
- AssertJ assertions for testing
|
||||
- Checkstyle configuration for code quality
|
||||
- **Signing:**
|
||||
- Standard Webhooks signing (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,...`, `webhook-attempt`).
|
||||
- Per-endpoint secrets, stored encrypted (AES-256-GCM) or as environment references; rotation with an overlap; an admin-only secret value widget.
|
||||
- **SSRF-safe sending:**
|
||||
- Private, loopback, link-local and metadata ranges are blocked.
|
||||
- DNS is resolved once and the connection pinned; redirects are not followed.
|
||||
- TLS 1.2+, connect, read and total timeouts, and a response size cap.
|
||||
- An admin-only internal allowlist per endpoint.
|
||||
- **Event sources:**
|
||||
- Delete events.
|
||||
- Process completed and failed events.
|
||||
- The ESB as an event source (`WebhooksEsbSource`), with own-event skipping and CloudEvent-id dedup.
|
||||
- Custom events (`Webhooks.fire`) and a channel API for other qbits (`Webhooks.deliver`).
|
||||
- **Delivery engine:**
|
||||
- An event id and idempotency key on every delivery.
|
||||
- Atomic claims safe across replicas (a unique attempt row) with leases.
|
||||
- Dead letters with Replay, and serial delivery per endpoint.
|
||||
- Rate limits per endpoint, 429 `Retry-After` handling without blocking the runner, and auto-disable after failed probations.
|
||||
- Retention purge.
|
||||
- **Subscriptions:**
|
||||
- Record filters (QQQ query filter JSON), record security matching and a read-permission check on subscribe.
|
||||
- Detail levels, include and exclude field lists.
|
||||
- **Alerts:** raised through the Alerts producer SPI when present.
|
||||
- **App:**
|
||||
- A home dashboard (delivery volume, failures, dead letters, attention list, latency).
|
||||
- Plain-language table and record actions with the standard review and result screens.
|
||||
- **Integrations:** ESB, Quick Search and API hooks (`WebhooksIntegrations`).
|
||||
- **Repository:** Postgres DDL in docs/ddl; JaCoCo gate; Checkstyle in `verify`; Maven wrapper; repo-standard files.
|
||||
|
||||
### Changed
|
||||
- Initial public release of QBit User Role Permissions
|
||||
- Established QBit development standards
|
||||
- Created modular architecture for permission management
|
||||
- Tables renamed to `webhooks_endpoint`, `webhooks_endpoint_secret`, `webhooks_subscription`, `webhooks_delivery`, `webhooks_delivery_attempt`; permissions to `webhooks.view / manage / admin`.
|
||||
- Payload envelope is now `{id, type, timestamp, source, sourceEventId, data}`.
|
||||
- One scheduled delivery process replaces the per-webhook jobs.
|
||||
- `httpTimeoutMillis` is now honoured.
|
||||
- Builds against QQQ 4.1.0-RC.4 by default.
|
||||
|
||||
---
|
||||
### Removed
|
||||
- `webhookEventContent` (the payload is a column of `webhooks_delivery`).
|
||||
|
||||
## 📚 For Detailed Information
|
||||
|
||||
**📖 [Complete Documentation Wiki](https://github.com/Kingsrook/qqq/wiki)** - Start here for comprehensive guides
|
||||
- **[🏠 Home](https://github.com/Kingsrook/qqq/wiki/Home)** - Project overview and quick start
|
||||
- **[🚀 Release Flow](https://github.com/Kingsrook/qqq/wiki/Release-Flow)** - Detailed release process
|
||||
- **[🏷️ Changelog & Tagging](https://github.com/Kingsrook/qqq/wiki/Changelog-and-Tagging)** - Commit conventions and release notes
|
||||
- **[🔧 Developer Onboarding](https://github.com/Kingsrook/qqq/wiki/Developer-Onboarding)** - Setup and contribution guide
|
||||
|
||||
## 🔄 Version Compatibility
|
||||
|
||||
QBit User Role Permissions follows semantic versioning:
|
||||
- **MAJOR** versions may contain breaking changes
|
||||
- **MINOR** versions add new functionality (backward compatible)
|
||||
- **PATCH** versions contain bug fixes (backward compatible)
|
||||
|
||||
For detailed compatibility information, see [QQQ Compatibility Matrix](https://github.com/Kingsrook/qqq/wiki/Compatibility-Matrix).
|
||||
|
||||
---
|
||||
|
||||
**Thank you for using QBit User Role Permissions!** 🚀
|
||||
## [0.3.0] - 2025-12-28
|
||||
- Last 0.x release (Java 21, qbit-build-parent).
|
||||
@@ -0,0 +1,60 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit Webhooks**: signed, SSRF-safe outbound webhooks for QQQ applications. It covers:
|
||||
- table, process, ESB and custom events
|
||||
- subscriptions with filters, and an outbox with replica-safe claims
|
||||
- retries, dead letters, endpoint health, and an app home
|
||||
|
||||
Design: `docs/DESIGN.md` (from QRun-IO/qqq#1105). Inbound webhooks are phase 2 (DESIGN section 10).
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM
|
||||
./mvnw test
|
||||
./mvnw verify # tests + Checkstyle + JaCoCo gate (70% instructions, 90% classes)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Entry point:** `WebhooksQBitProducer implements QBitMetaDataProducer<WebhooksQBitConfig>`. Optional: `WebhooksEsbSource.configure(qInstance)` for ESB topics.
|
||||
- **Packages:**
|
||||
- `metadata` (tables, enums, validating customizers), `registry` (event types)
|
||||
- `events`:
|
||||
- record and process listeners
|
||||
- `DeliveryOutbox`, `SubscriptionMatcher`, `PayloadBuilder`
|
||||
- `delivery`:
|
||||
- `DeliveryEngine` (claim, send, outcome), `EndpointHealth`
|
||||
- `WebhooksAlerts` (reflective Alerts SPI)
|
||||
- `secrets` (AES-GCM, env references, rotation), `processes`, `app` (home, secret widget), `permissions`, `esb`, `support/SystemWrites`.
|
||||
- Signing uses `core.utils.webhooks` directly at the QQQ 4.1.0-RC.5 baseline (QRun-IO/qqq#1160). `shared.http` remains an internal copy of the safe HTTP client pending a separate migration to `core.utils.http` (DESIGN section 11).
|
||||
|
||||
## Invariants
|
||||
|
||||
- A delivery is sent only by the runner whose insert of (`deliveryId`, `attemptNumber`) succeeded. Never send without a claim.
|
||||
- Payloads are built at queue time and stored; retries resend the same bytes and the same `webhook-id`.
|
||||
- Secrets are never stored, logged, audited, put in process results, or returned in plain text. Only fingerprints.
|
||||
- Outbound requests go only through `SafeHttpClient` (via `WebhooksRuntime.httpClient`).
|
||||
- The qbit's own writes go through `SystemWrites` (authorized scope; customizers can tell them from people's).
|
||||
- No static config caches: read config with `WebhooksRuntime.config()`.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
- Tables are lowercase snake_case, prefixed `webhooks_`; the backend name is the table name; column names are snake_case.
|
||||
- Permissions: `webhooks.view`, `webhooks.manage`, `webhooks.admin` (and `webhooks.hasAccess`).
|
||||
- Data lives in standard visible tables. Processes are table or record actions, never in the nav. The nav lists only the main tables.
|
||||
|
||||
## Code Style
|
||||
|
||||
3-space indent, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- `release/*` publishes RCs.
|
||||
- `vX.Y.Z` tags publish releases (GA tags only).
|
||||
+1
-2
@@ -1,4 +1,3 @@
|
||||
# Code of Conduct
|
||||
|
||||
|
||||
See Project level [Code of Conduct](https://github.com/Kingsrook/qqq/blob/develop/CODE_OF_CONDUCT.md)
|
||||
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
# Code Style Guide
|
||||
|
||||
* See Project [Code Style Guide](https://github.com/Kingsrook/qqq/blob/develop/CODE_STYLE.md)
|
||||
|
||||
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
|
||||
+5
-3
@@ -1,4 +1,6 @@
|
||||
# Contributing to QBit User Role Permissions
|
||||
|
||||
* See Project [Contributing](https://github.com/Kingsrook/qqq/blob/develop/CONTRIBUTING.md)
|
||||
# Contributing to QBit Webhooks
|
||||
|
||||
* See Project [Contributing](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md)
|
||||
* Branches follow GitFlow: `feature/*` from `develop`, pull requests to `develop`, `release/*` for release candidates, `vX.Y.Z` tags for releases.
|
||||
* Commits are signed and follow Conventional Commits.
|
||||
* Build and test locally with `./mvnw verify` (Java 21).
|
||||
@@ -0,0 +1,191 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright
|
||||
owner or by an individual or Legal Entity authorized to submit on
|
||||
behalf of the copyright owner. For the purposes of this definition,
|
||||
"submitted" means any form of electronic, verbal, or written
|
||||
communication sent to the Licensor or its representatives, including
|
||||
but not limited to communication on electronic mailing lists, source
|
||||
code control systems, and issue tracking systems that are managed by,
|
||||
or on behalf of, the Licensor for the purpose of discussing and
|
||||
improving the Work, but excluding communication that is conspicuously
|
||||
marked or otherwise designated in writing by the copyright owner as
|
||||
"Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024 Kingsrook, LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,6 @@
|
||||
QBit Webhooks
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
|
||||
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
@@ -1,154 +1,152 @@
|
||||
# qbit-webhooks
|
||||
# QBit Webhooks
|
||||
|
||||
Outbound webhook notifications for QQQ applications.
|
||||
Signed, SSRF-safe outbound webhooks for QQQ applications. Admins add **endpoints** and **subscribe** them to event types: table inserts, updates and deletes (in-process or from the ESB), process completions and failures, and custom domain events. The qbit queues one **delivery** per event per endpoint in the same transaction as the change. It then signs each delivery in the [Standard Webhooks](https://www.standardwebhooks.com/) format and sends it with retries, backoff, dead letters and endpoint health. Every row is a normal QQQ table, and the app has a home dashboard.
|
||||
|
||||
**For:** QQQ developers who need to notify external systems when records change
|
||||
**Status:** Stable
|
||||
Design: [QRun-IO/qqq#1105](https://github.com/QRun-IO/qqq/pull/1105) (`docs/specs/2026-10-12-qbit-webhooks-assessment.md`), and [docs/DESIGN.md](docs/DESIGN.md) for what 1.0 implements. Signing and secret generation use QQQ core directly; the local safe HTTP client remains pending a separate migration. Both primitives are available in QQQ core from 4.1.0-RC.5 ([QRun-IO/qqq#1109](https://github.com/QRun-IO/qqq/pull/1109)).
|
||||
|
||||
## Why This Exists
|
||||
## Requirements
|
||||
|
||||
Modern applications don't exist in isolation. When an order is created, you might need to notify a fulfillment system. When a user updates their profile, a CRM needs to know. Polling for changes is inefficient and adds latency.
|
||||
- Java 21
|
||||
- QQQ 4.1.0-RC.8 or later (`qqq-backend-core`). Two modules are optional:
|
||||
- `qqq-middleware-api`, for payloads rendered through a QQQ API version.
|
||||
- `qqq-esb`, for ESB topics as an event source.
|
||||
- A SQL backend for the five tables (DDL in [docs/ddl/webhooks-postgres.sql](docs/ddl/webhooks-postgres.sql)), and a QQQ scheduler for delivery.
|
||||
|
||||
This QBit sends HTTP callbacks when records are inserted, updated, or deleted. Configure endpoints, select which events to send, and the system handles delivery with retries and logging.
|
||||
|
||||
## Features
|
||||
|
||||
- **Event-Driven Delivery** - Fires on insert, update, delete operations
|
||||
- **Configurable Endpoints** - Register multiple webhook URLs per table
|
||||
- **Payload Customization** - Include full record, changed fields only, or custom format
|
||||
- **Retry Logic** - Automatic retries with exponential backoff
|
||||
- **Delivery Logging** - Track success, failures, and response codes
|
||||
- **Secret Signing** - HMAC signatures for payload verification
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- QQQ application (v0.20+)
|
||||
- Database backend configured
|
||||
|
||||
### Installation
|
||||
|
||||
Add to your `pom.xml`:
|
||||
## Install
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>io.qrun</groupId>
|
||||
<artifactId>qbit-webhooks</artifactId>
|
||||
<version>0.2.0</version>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-webhooks</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### Register the QBit
|
||||
|
||||
```java
|
||||
public class AppMetaProvider extends QMetaProvider {
|
||||
@Override
|
||||
public void configure(QInstance qInstance) {
|
||||
new WebhooksQBit().configure(qInstance);
|
||||
}
|
||||
}
|
||||
new WebhooksQBitProducer()
|
||||
.withQBitConfig(new WebhooksQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withSchedulerName("scheduler")
|
||||
.withSourceName("myapp") // events say qqq://myapp/...
|
||||
.withTableEventSources(List.of("order", "customer"))
|
||||
.withProcessEventSources(List.of("shipOrder")))
|
||||
.produce(qInstance);
|
||||
|
||||
// optional: also consume ESB topics (events from other services)
|
||||
WebhooksEsbSource.configure(qInstance);
|
||||
```
|
||||
|
||||
### Configure a Webhook
|
||||
Set `WEBHOOKS_SECRET_KEY` to the base64 of 32 random bytes, so the qbit can generate signing secrets and store them encrypted. Without it, use environment-reference secrets only.
|
||||
|
||||
```java
|
||||
new QWebhookMetaData()
|
||||
.withName("orderCreatedNotification")
|
||||
.withTable("order")
|
||||
.withEvents(WebhookEvent.INSERT)
|
||||
.withUrl("https://api.example.com/webhooks/orders")
|
||||
.withSecret("your-signing-secret");
|
||||
```
|
||||
## Tables
|
||||
|
||||
## Usage
|
||||
| Table | What it holds | In the nav |
|
||||
|---|---|---|
|
||||
| `webhooks_endpoint` | URL, status (Active, Paused, Disabled), health (Healthy, Probation, Unhealthy), in-order delivery, max per minute, timeout, internal-network allowlist (admin only) | yes |
|
||||
| `webhooks_subscription` | Endpoint + event type, record filter (QQQ query filter JSON), detail level, only-these / never-these fields, API name and version | yes |
|
||||
| `webhooks_delivery` | One row per event per endpoint: event id (the `webhook-id`), stored payload, status, attempts, next attempt, lease, last error, dead letter | yes |
|
||||
| `webhooks_endpoint_secret` | Signing secrets: generated (AES-GCM encrypted) or an environment variable name; status Active, Previous (still signing during a rotation), Retired; fingerprint | on the endpoint |
|
||||
| `webhooks_delivery_attempt` | One row per HTTP attempt: outcome, status, duration, response excerpt, error, remote address, which secrets signed it. Unique on delivery and attempt number (the claim) | on the delivery |
|
||||
|
||||
### Multiple Events
|
||||
Deliveries and attempts are written only by the qbit. People act on them through the actions below.
|
||||
|
||||
```java
|
||||
new QWebhookMetaData()
|
||||
.withName("customerSync")
|
||||
.withTable("customer")
|
||||
.withEvents(WebhookEvent.INSERT, WebhookEvent.UPDATE, WebhookEvent.DELETE)
|
||||
.withUrl("https://crm.example.com/sync");
|
||||
```
|
||||
## Setup and empty states
|
||||
|
||||
### Conditional Delivery
|
||||
Nothing in this qbit is seeded: endpoints, secrets and subscriptions are your choices, and deliveries and attempts are logs. Instead the app home has a **Setup** card (event types, encryption key, endpoints, signing secrets, subscriptions, each with its state and next step), and every table has help (`help/qbit-webhooks.yaml`) that explains how its rows get created and shows when the table is empty.
|
||||
|
||||
```java
|
||||
// Only fire when status changes to 'approved'
|
||||
new QWebhookMetaData()
|
||||
.withName("approvalNotification")
|
||||
.withTable("order")
|
||||
.withEvents(WebhookEvent.UPDATE)
|
||||
.withCondition((oldRecord, newRecord) ->
|
||||
"approved".equals(newRecord.getValue("status")) &&
|
||||
!"approved".equals(oldRecord.getValue("status")))
|
||||
.withUrl("https://notify.example.com/approved");
|
||||
```
|
||||
The event types you can subscribe to come from code, not from a table: the host's `tableEventSources`, `processEventSources` and ESB settings, custom events it fires with `Webhooks.fire`, and a built-in Test event, all held in the `WebhooksRegistry`.
|
||||
|
||||
### Custom Payload
|
||||
## In-app help
|
||||
|
||||
```java
|
||||
new QWebhookMetaData()
|
||||
.withName("slackNotification")
|
||||
.withTable("alert")
|
||||
.withEvents(WebhookEvent.INSERT)
|
||||
.withPayloadTransformer((record) -> Map.of(
|
||||
"text", "New alert: " + record.getValue("message"),
|
||||
"channel", "#alerts"
|
||||
))
|
||||
.withUrl("https://hooks.slack.com/services/xxx");
|
||||
```
|
||||
The producer registers [help/qbit-webhooks.yaml](src/main/resources/help/qbit-webhooks.yaml), a `QBIT` bundle covering the app, tables, fields, actions, widgets and possible-value sources. QQQ synchronizes it on startup when the host has the `helpContent` table; registration is safe without that table, and inline app/table empty-state help remains available.
|
||||
|
||||
### Verifying Signatures
|
||||
Hosts can register their own YAML bundle with `layer: HOST` through `HelpBundles.register(qInstance, "help/my-host.yaml")`. Use the same entry keys and roles to override selected text with deployment-specific guidance. Keep live `{{label:...}}`, `{{values:...}}` and `{{default:...}}` references where appropriate. If you customize the app name, supply its help under the customized app key in your host bundle.
|
||||
|
||||
Receiving systems can verify the payload using the HMAC-SHA256 signature in the `X-Webhook-Signature` header:
|
||||
`WebhooksHelpBundleTest` uses QQQ's `HelpBundleAssertions` against the produced instance, including the optional ESB process, to check coverage, summary length and live references. Run it with `./mvnw -q -Dtest=WebhooksHelpBundleTest test`; the full `./mvnw -q verify` also runs it. Keep the bundle's literal version aligned with the POM revision when releasing.
|
||||
|
||||
```java
|
||||
String payload = requestBody;
|
||||
String signature = request.getHeader("X-Webhook-Signature");
|
||||
String expected = HmacUtils.hmacSha256Hex(secret, payload);
|
||||
if (!expected.equals(signature)) {
|
||||
throw new SecurityException("Invalid signature");
|
||||
}
|
||||
```
|
||||
## Permissions
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `webhooks.view` | The app, the home, every table (read) |
|
||||
| `webhooks.manage` | Create and edit endpoints and subscriptions; Send Now, Replay, Cancel Delivery, Send Test Event |
|
||||
| `webhooks.admin` | Signing secrets (Rotate Signing Secret, environment references, the secret value), internal-network targets, deleting endpoints, Delete Deliveries |
|
||||
|
||||
Grant `WebhooksPermissions.ALL` (which includes `webhooks.hasAccess`) to administrators. Subscribing to a table's events also needs read permission on that table. Record security locks from `withSecurityFields` / `withRecordSecurityLocks` apply to endpoints, subscriptions and deliveries. A subscription only receives records its security values could read.
|
||||
|
||||
## Actions
|
||||
|
||||
All of these are table or record actions with the standard review and result screens. None is in the nav.
|
||||
|
||||
| Action | On | Does |
|
||||
|---|---|---|
|
||||
| Send Now | Deliveries | Sends the selected unfinished deliveries now |
|
||||
| Replay | Deliveries | Queues finished deliveries (dead letters included) again, with the same event id and a fresh attempt budget |
|
||||
| Cancel Delivery | Deliveries | Stops pending deliveries |
|
||||
| Delete Deliveries | Deliveries | Deletes finished deliveries and their attempts (admin) |
|
||||
| Send Test Event | Endpoint | Sends a signed `webhooks.test` event now and shows the result |
|
||||
| Rotate Signing Secret | Endpoint | Creates a new secret; the old one keeps signing for `rotationOverlapHours` (admin) |
|
||||
|
||||
The scheduler runs delivery every `deliveryIntervalSeconds` (15). It runs maintenance every 5 minutes (probation, retiring old secrets) and the retention purge daily.
|
||||
|
||||
## Configuration
|
||||
|
||||
The QBit creates these tables:
|
||||
| Setting | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `backendName` | required | Backend of the five tables |
|
||||
| `schedulerName` | none | Without it nothing is sent on a schedule |
|
||||
| `tableEventSources` | none | Local tables: `<table>.inserted / updated / deleted` |
|
||||
| `processEventSources` | none | `process.<name>.completed / failed` |
|
||||
| `esbDestinations`, `esbTableEventSources`, `esbSkipOwnEvents` | none, none, true | ESB topics to consume; remote tables; skip this instance's own events |
|
||||
| `maxAttempts`, `retryMinutes` | 6, [1, 5, 15, 60, 240, 720] | Then the delivery is a dead letter |
|
||||
| `leaseSeconds` | 120 | A claimed delivery whose runner died is retried after this |
|
||||
| `connectTimeoutMillis`, `httpTimeoutMillis`, `totalTimeoutMillis`, `maxResponseBytes` | 5000, 10000, 30000, 65536 | HTTP limits (an endpoint's `timeoutSeconds` overrides the read timeout) |
|
||||
| `allowPlainHttp` | false | `https` only by default |
|
||||
| `unhealthyAfterConsecutiveFailures`, `probationAfterMinutes`, `autoDisableAfterProbationFailures` | 10, 60, 3 | Endpoint health |
|
||||
| `encryptionKeyEnvName`, `rotationOverlapHours` | `WEBHOOKS_SECRET_KEY`, 24 | Secret storage and rotation |
|
||||
| `deliveredRetentionDays`, `attemptRetentionDays`, `deadLetterRetentionDays` | 400, 400, 0 | 0 keeps forever |
|
||||
| `snakeCaseColumnNames` | true | Column names as in `docs/ddl` |
|
||||
| `appName`, `appLabel`, `appPath`, `tablePathPrefix` | `webhooks`, `Webhooks` | The app and the links on its home |
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| `webhook` | Webhook endpoint configurations |
|
||||
| `webhook_log` | Delivery attempts and responses |
|
||||
## For receivers
|
||||
|
||||
### Retry Settings
|
||||
Every request carries `webhook-id`, `webhook-timestamp`, `webhook-signature` (`v1,<base64 HMAC-SHA256>`, more than one during a rotation) and `webhook-attempt`. Verify the signature, reject old timestamps, and ignore an id you have already processed. Delivery is at least once. See [docs/RECEIVERS.md](docs/RECEIVERS.md) for the format and code in Java, Node.js and Python.
|
||||
|
||||
```java
|
||||
new WebhooksQBit()
|
||||
.withMaxRetries(5)
|
||||
.withRetryDelayMs(1000)
|
||||
.withRetryBackoffMultiplier(2.0);
|
||||
## Adopting in another app
|
||||
|
||||
1. Add the dependency and run [docs/ddl/webhooks-postgres.sql](docs/ddl/webhooks-postgres.sql) (as a Liquibase changeset).
|
||||
2. Produce the qbit (above) after your tables exist. Add its app under your admin app if you have one.
|
||||
3. Grant the permissions to your admins, and set `WEBHOOKS_SECRET_KEY` in the environment.
|
||||
4. Optional hooks, through `WebhooksIntegrations`:
|
||||
- publish `esbTableNames()` to your ESB;
|
||||
- index `quickSearchTables()` in Quick Search;
|
||||
- expose `apiTableNames()` in your QQQ API.
|
||||
5. Other qbits deliver through `Webhooks.deliver(endpointName, type, data, idempotencyKey)` (the channel API), and code fires domain events with `Webhooks.fire(...)`.
|
||||
|
||||
Upgrading from 0.x: see [docs/migration-0.x-to-1.0.md](docs/migration-0.x-to-1.0.md).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./mvnw verify # tests, Checkstyle and the JaCoCo gate (always use the wrapper)
|
||||
```
|
||||
|
||||
## Project Status
|
||||
## Health checks
|
||||
|
||||
Stable and production-ready.
|
||||
The producer registers passive checks with QQQ central health monitoring (requires QQQ 4.1.0-RC.8 or later):
|
||||
|
||||
### Roadmap
|
||||
| Name | Kind | Category | Interval |
|
||||
|---|---|---|---|
|
||||
| `webhooks.endpoint.<id>` | DEPENDENCY | INTEGRATION | 300 seconds |
|
||||
| `webhooks.dispatcher` | DEPENDENCY | INTEGRATION | 300 seconds |
|
||||
|
||||
- Batch delivery (group multiple events)
|
||||
- Dead letter queue for failed deliveries
|
||||
- Dashboard UI for webhook management
|
||||
Both belong to component `webhooks`. Endpoint checks read the existing state machine: HEALTHY means UP; UNHEALTHY or PROBATION means DEGRADED; automatically disabled means DOWN. Active and automatically disabled endpoints are discovered dynamically; paused and manually disabled endpoints are excluded. Details contain only healthStatus, consecutiveFailures and unhealthySince when present.
|
||||
|
||||
## Contributing
|
||||
The dispatcher reports `oldestPendingSeconds`, measured from the oldest queued delivery's createDate across PENDING, RETRY_WAIT and SENDING. More than 15 minutes is DEGRADED; more than 60 minutes is DOWN. An empty queue reports UP with zero age. Checks do not send requests or change endpoint state.
|
||||
|
||||
The home shows one native central status line linking to the webhooks checks in System Health. Disable checks centrally with `health_check.enabled`. The endpoint state machine, alerts and Send Test Event action remain unchanged.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Run tests: `mvn clean verify`
|
||||
4. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
Proprietary - QRun.IO
|
||||
Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
# Security Policy
|
||||
|
||||
* See Project [Security Policy](https://github.com/Kingsrook/qqq/blob/develop/SECURITY.md)
|
||||
|
||||
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
|
||||
* Report suspected vulnerabilities privately through GitHub private vulnerability reporting on this repository, never in a public issue.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased
|
||||
|
||||
No action is needed. The qbit adds help to its tables and a Setup card to its app home when it is produced; it adds no tables, columns or data, and a host's own table or app help is kept. Nothing is seeded.
|
||||
+9
-10
@@ -6,18 +6,17 @@
|
||||
~ contact@kingsrook.com
|
||||
~ https://github.com/Kingsrook/
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU Affero General Public License as
|
||||
~ published by the Free Software Foundation, either version 3 of the
|
||||
~ License, or (at your option) any later version.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU Affero General Public License for more details.
|
||||
~ https://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ You should have received a copy of the GNU Affero General Public License
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<!DOCTYPE module PUBLIC
|
||||
|
||||
+9
-10
@@ -5,16 +5,15 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
# qbit-webhooks 1.0 design
|
||||
|
||||
**Status:** implemented for outbound (1.0). Inbound webhooks are phase 2 (section 10), after outbound GA.
|
||||
**Source:** the assessment in [QRun-IO/qqq#1105](https://github.com/QRun-IO/qqq/pull/1105) (`docs/specs/2026-10-12-qbit-webhooks-assessment.md`). Owner decisions D1 to D8 were adopted as recommended:
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| D1 | Enhance the existing qbit into 1.0; breaking changes allowed |
|
||||
| D2 | The signer and SSRF-safe client live in `qqq-backend-core` (QRun-IO/qqq#1109). Signing uses core directly at the QQQ 4.1.0-RC.5 baseline; the internal HTTP copy remains pending a separate migration (see section 11) |
|
||||
| D3 | Tables renamed to `webhooks_*` |
|
||||
| D4 | Internal and private targets blocked by default; admin-only allowlist per endpoint |
|
||||
| D5 | Inbound is phase 2, in this qbit |
|
||||
| D6 | The Alerts spec (#1090) no longer claims a signing scheme here (fixed on its branch, commit 797814c) |
|
||||
| D7 | PR #5 (licence and CI alignment) merged first |
|
||||
| D8 | At-least-once delivery; optional serial ordering per endpoint |
|
||||
|
||||
## 1. Flow
|
||||
|
||||
```
|
||||
table write ─► RecordChangeListener ─┐
|
||||
process end ─► ProcessLifecycleListener ─┤
|
||||
ESB topic ──► EsbTrigger process ────────┼─► DeliveryOutbox ─► webhooks_delivery (PENDING, payload stored)
|
||||
Webhooks.fire(...) (custom) ─────────────┤ one row per matching subscription,
|
||||
Webhooks.deliver(...) (channel) ─────────┘ unique dedup key, same transaction as the write
|
||||
│
|
||||
scheduler (every 15 s) ─► DeliveryEngine ┘
|
||||
claim attempt n (unique insert) ─► sign ─► SafeHttpClient POST
|
||||
─► DELIVERED │ RETRY_WAIT (backoff, Retry-After) │ DEAD_LETTER
|
||||
─► endpoint health ─► Alerts SPI (optional)
|
||||
```
|
||||
|
||||
## 2. Signing (Standard Webhooks)
|
||||
|
||||
Every request carries:
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `webhook-id` | The delivery's event id (`evt_` + 32 hex). The same on every retry and replay, so it is the receiver's **idempotency key** |
|
||||
| `webhook-timestamp` | Unix seconds when this attempt was sent |
|
||||
| `webhook-signature` | `v1,<base64(HMAC-SHA256(key, "<id>.<timestamp>.<body>"))>`; during a rotation, one entry per active secret, space separated |
|
||||
| `webhook-attempt` | 1, 2, ... (informational, not signed) |
|
||||
| `Content-Type` | `application/json` |
|
||||
|
||||
- **Key:** a secret written `whsec_<base64>` (the HMAC key is the decoded bytes; generated keys are 32 random bytes). A secret without the prefix is used as raw UTF-8 bytes.
|
||||
- **Verification** (core `WebhookVerifier`):
|
||||
- All three headers are required.
|
||||
- The timestamp must be within 5 minutes of now, in either direction.
|
||||
- The receiver recomputes `v1` for each secret it holds and compares in constant time.
|
||||
- Unknown versions are ignored.
|
||||
- **Rotation:**
|
||||
- "Rotate Signing Secret" makes the current ACTIVE secret PREVIOUS, with `expiresDate` = now + `rotationOverlapHours` (24), and creates a new ACTIVE secret.
|
||||
- Until it expires, every delivery is signed by both, so a receiver can switch secrets whenever it likes.
|
||||
- Maintenance retires expired PREVIOUS secrets.
|
||||
- **Storage:** a secret is never stored, logged, audited or returned in plain text. Each row keeps a 12-hex fingerprint (part of the SHA-256), which is safe to show. There are two kinds:
|
||||
- **GENERATED:** AES-256-GCM. The key is base64 of 32 bytes in `WEBHOOKS_SECRET_KEY` (the env name is configurable). The endpoint id is bound in as associated data. The stored form is `v1:<base64(nonce || ciphertext)>`.
|
||||
- **REFERENCE:** the name of an environment variable (for example, injected from Infisical). The value is read at send time.
|
||||
- **Showing a generated secret:**
|
||||
- The result screen of Rotate never shows it, because process tracing can store result screens.
|
||||
- Instead, the secret record has a "Secret Value" widget for `webhooks.admin` only. The value is decrypted on request and shown behind a "Show the secret" toggle.
|
||||
- **No secret:** deliveries to an endpoint with no usable secret wait (they are not attempted, and the home flags it). The exception is an endpoint with "Allow Unsigned Delivery", which gets `webhook-id` and `webhook-timestamp` without a signature.
|
||||
|
||||
Receiver code: [RECEIVERS.md](RECEIVERS.md).
|
||||
|
||||
## 3. SSRF policy (core `SafeHttpClient`, `AddressRules`)
|
||||
|
||||
- **URL:**
|
||||
- `https` only (`allowPlainHttp` is false by default).
|
||||
- No user name or password in the URL; a host is required.
|
||||
- **Blocked by default:**
|
||||
- IPv4:
|
||||
- unspecified 0/8
|
||||
- private 10/8, 172.16/12, 192.168/16
|
||||
- CGNAT 100.64/10
|
||||
- loopback 127/8
|
||||
- link-local 169.254/16, which includes cloud metadata
|
||||
- IETF and documentation 192.0.0/24, 192.0.2/24, 198.51.100/24, 203.0.113/24
|
||||
- 6to4 relay 192.88.99/24
|
||||
- benchmarking 198.18/15
|
||||
- multicast and reserved 224/4, 240/4
|
||||
- IPv6: `::`, `::1`, discard 100::/64, documentation 2001:db8::/32, ULA fc00::/7, link-local fe80::/10, site-local fec0::/10, multicast ff00::/8.
|
||||
- IPv4 embedded in IPv6 is checked as IPv4: mapped, compatible, NAT64 64:ff9b::/96 and 6to4 2002::/16.
|
||||
- **Always blocked, even when allowlisted:** 169.254.169.254, 100.100.100.200, fd00:ec2::254, and the unspecified addresses.
|
||||
- **Allowlist (D4):**
|
||||
- The endpoint's "Allow Internal Targets" plus "Internal Allowlist". Entries are CIDR blocks, addresses, exact host names, or `.suffix` host names.
|
||||
- Only `webhooks.admin` can set or change them. A table customizer enforces this, so it applies to the UI, the API and code alike.
|
||||
- **DNS:** resolved once per request, and every address is checked; one blocked address refuses the whole request. The connection is pinned to the checked addresses through the HTTP client's DNS resolver, so DNS rebinding between the check and the connect cannot redirect it.
|
||||
- **Redirects:** never followed. A 3xx is a failed attempt ("redirects are not followed").
|
||||
- **TLS:** 1.2 or 1.3, the JVM trust store, standard host name verification.
|
||||
- **Limits:**
|
||||
- Timeouts: connect 5 s; read `httpTimeoutMillis` 10 s (the endpoint's `timeoutSeconds` overrides it); total 30 s. Before 1.0 the timeout setting was ignored.
|
||||
- Response: at most 64 KiB is read. The attempt row stores at most 2,000 characters, marked `[truncated]`.
|
||||
- **When it is checked:** on save, the endpoint URL is checked (an unresolvable host is allowed, because DNS may not exist yet), and again on every send.
|
||||
- **Policy blocks** (BLOCKED attempts) do not count against endpoint health.
|
||||
|
||||
## 4. Event sources
|
||||
|
||||
| Source | Event types | How | Dedup key |
|
||||
|---|---|---|---|
|
||||
| Table (in-process) | `<table>.inserted / updated / deleted` for `tableEventSources`; custom types with `fieldName` (and `value`) for "field changed (to)" | QQQ 4.1 record change listener; deletes included. Delivery rows are inserted in the write's transaction when it is on the qbit's backend; otherwise right after the write (or after its commit) | new UUID per change |
|
||||
| Process (in-process) | `process.<name>.completed / failed` for `processEventSources` | QQQ process lifecycle listener. Data: name, label, run id, table, error. Never the process's inputs | `<processUUID>-<completed|failed>` |
|
||||
| ESB | Table and process CloudEvents (`qqq.table.<t>.<inserted|updated|deleted>`, `qqq.process.<p>.<completed|failed>`) and registered custom types | `WebhooksEsbSource.configure(qInstance)`: a hidden process with an `EsbTrigger` (shared durable subscription "webhooks") on each `esbDestinations` topic | the CloudEvent `id` |
|
||||
| Custom (domain) | any registered `CUSTOM` type | `Webhooks.fire(type, data[, transaction])`, `Webhooks.fireForRecord(...)` | new UUID |
|
||||
| Channel (other qbits, e.g. Alerts) | any type | `Webhooks.deliver(endpointName, type, data, idempotencyKey)`: one endpoint, no subscription | the idempotency key |
|
||||
|
||||
**No double sends between the in-process and ESB paths:**
|
||||
- An ESB event whose source is `qqq://<this instance's ESB name>/...` is skipped (`esbSkipOwnEvents`, on by default), because the in-process listener already queued it.
|
||||
- Events from other services about the same tables are queued.
|
||||
- Every delivery's `dedup_key` (`<source event id>:<subscription>`) is unique, so a redelivered message queues nothing new.
|
||||
|
||||
## 5. Subscriptions
|
||||
|
||||
A subscription matches an event when all of these hold:
|
||||
1. The subscription is Active, and its endpoint is not Disabled.
|
||||
2. The event type is the same.
|
||||
3. The type's field condition holds (an update changed the field, to the value if one is set).
|
||||
4. The **record filter** matches. It is a QQQ `QQueryFilter` as JSON, evaluated in memory against the merged record (old values overlaid with new).
|
||||
5. **Record security** allows it: a session made from the subscription's security-field values must be able to read the record.
|
||||
|
||||
Creating a subscription to a table's events needs read permission on that table.
|
||||
|
||||
**Payload shaping:**
|
||||
- **API version:** `apiName` + `apiVersion` render the record through the QQQ API middleware (stable, versioned field names) when it is on the classpath.
|
||||
- **Detail level:**
|
||||
- `FULL`: the record, plus `oldRecord` for updates.
|
||||
- `CHANGED_FIELDS`: only the changed fields and the key.
|
||||
- `KEYS_ONLY`: the key.
|
||||
- **Field lists:** "Only These Fields" and "Never These Fields".
|
||||
- **Code:** an event type's `WebhookPayloadCustomizerInterface` can change `data` last.
|
||||
|
||||
The payload is built and stored when the event is queued, so retries send identical bytes.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "evt_6f1c...",
|
||||
"type": "cms_blog_post.updated",
|
||||
"timestamp": "2026-10-12T20:15:00Z",
|
||||
"source": "qqq://kof22/table/cms_blog_post",
|
||||
"sourceEventId": "4b9e...",
|
||||
"data": { "table": "cms_blog_post", "primaryKey": "42", "changedFields": ["title"], "record": { }, "oldRecord": { } }
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Delivery semantics
|
||||
|
||||
- **Claim:**
|
||||
- To send attempt *n*, a runner inserts `webhooks_delivery_attempt` (`delivery_id`, *n*); that pair is unique.
|
||||
- One runner wins. The losers get a duplicate (a pre-check error or a constraint violation) and skip the delivery.
|
||||
- This is a compare-and-set that works on any SQL backend, with no `SELECT ... FOR UPDATE SKIP LOCKED` needed.
|
||||
- Tested with two replicas and two runners each on one H2 database: 150 deliveries, each sent exactly once.
|
||||
- **Lease:**
|
||||
- The winner marks the delivery SENDING with `lease_expires_date` = now + `leaseSeconds` (120, which must be more than the total HTTP timeout).
|
||||
- If the runner dies, the next run claims attempt *n+1* after the lease, and marks *n* LEASE_EXPIRED.
|
||||
- **At least once (D8):**
|
||||
- A runner that dies after the receiver got the request, but before recording the outcome, causes one repeat with the same `webhook-id`.
|
||||
- Receivers deduplicate on that id.
|
||||
- **Outcomes:**
|
||||
|
||||
| Response | Delivery becomes | Notes |
|
||||
|---|---|---|
|
||||
| 2xx | DELIVERED | |
|
||||
| 429 | RETRY_WAIT | After `Retry-After` (seconds or an HTTP date, capped at 1 h). Uses no attempt budget and no health |
|
||||
| Anything else (3xx included), a network failure or a policy block | RETRY_WAIT | Backoff of `retryMinutes` (1, 5, 15, 60, 240, 720) |
|
||||
| `maxAttempts` (6) failures since the last replay | DEAD_LETTER | |
|
||||
|
||||
Dead letters stay until replayed or deleted, and are kept forever by default.
|
||||
- **Ordering:**
|
||||
- No global order. Within an endpoint, oldest first, but a failing delivery backs off while newer ones go.
|
||||
- With **Deliver In Order** (`serial_delivery`), one delivery at a time: the oldest unfinished one blocks the rest until it is delivered or dead-lettered.
|
||||
- **Rate limit:** "Max Deliveries Per Minute" counts that endpoint's attempts in the last minute, from the database, so it holds across replicas (approximately: concurrent runners can overshoot by a few).
|
||||
- **Health:**
|
||||
|
||||
| From | To | When |
|
||||
|---|---|---|
|
||||
| HEALTHY | UNHEALTHY | `unhealthyAfterConsecutiveFailures` (10) failures in a row. No attempts while unhealthy |
|
||||
| UNHEALTHY | PROBATION | after `probationAfterMinutes` (60). One attempt per run |
|
||||
| PROBATION | HEALTHY | a success |
|
||||
| PROBATION | UNHEALTHY | a failure |
|
||||
| PROBATION | DISABLED | after `autoDisableAfterProbationFailures` (3) failed probations |
|
||||
|
||||
Send Now and Send Test Event ignore health. A disabled endpoint sends nothing.
|
||||
- **Alerts (optional):**
|
||||
- When the Alerts producer SPI (`com.kingsrook.qqq.backend.core.alerts.QAlerts`, #1090) is on the classpath, called by reflection so there is no compile dependency:
|
||||
- `webhooks.endpoint.unhealthy` (ERROR), resolved on recovery
|
||||
- `webhooks.endpoint.disabled` (ERROR)
|
||||
- `webhooks.delivery.dead_letter` (WARNING)
|
||||
- The dedup key is `endpoint:<id>`.
|
||||
- Titles are external-safe (endpoint name and counts only).
|
||||
- **No static caches:**
|
||||
- Config is read from the current QInstance on every use.
|
||||
- Subscriptions are read per change, so every replica sees edits at once.
|
||||
- The only JVM-wide state is the HTTP connection pool per policy.
|
||||
- **Retention:**
|
||||
- A daily purge deletes delivered and cancelled deliveries older than `deliveredRetentionDays` (400), and attempts older than `attemptRetentionDays` (400). Dead letters are deleted only after `deadLetterRetentionDays` (0, never).
|
||||
- 0 keeps forever. kof22 keeps data, so it sets 0 for all three.
|
||||
|
||||
## 7. Data model
|
||||
|
||||
Lowercase snake_case tables with the `webhooks_` prefix; backend names equal table names; columns are snake_case of the field names. DDL: [ddl/webhooks-postgres.sql](ddl/webhooks-postgres.sql).
|
||||
|
||||
| Table | Key fields | Unique |
|
||||
|---|---|---|
|
||||
| `webhooks_endpoint` | name, url, active_status, health_status, serial_delivery, max_per_minute, timeout_seconds, allow_unsigned, allow_internal_targets, internal_allowlist, consecutive_failures, probation_failures, last_success_date, last_failure_date, last_failure_message, unhealthy_since_date, auto_disabled_date | name |
|
||||
| `webhooks_endpoint_secret` | endpoint_id, status (ACTIVE / PREVIOUS / RETIRED), source (GENERATED / REFERENCE), secret_ref, encrypted_secret, fingerprint, expires_date, retired_date, note | |
|
||||
| `webhooks_subscription` | endpoint_id, event_type_name, active_status, description, filter_json, detail_level, include_fields, exclude_fields, api_name, api_version | |
|
||||
| `webhooks_delivery` | event_id, event_type_name, endpoint_id, subscription_id, source, source_table, source_record_id, source_event_id, dedup_key, status, attempt_count, cycle_attempt_count, replay_count, occurred_date, next_attempt_date, lease_expires_date, last_http_status, last_error, delivered_date, dead_letter_date, payload_bytes, payload | dedup_key, event_id |
|
||||
| `webhooks_delivery_attempt` | delivery_id, endpoint_id, attempt_number, outcome, claimed_by, lease_expires_date, start_date, end_date, duration_millis, http_status, response_excerpt, error_message, remote_address, request_url, signed_with | (delivery_id, attempt_number) |
|
||||
|
||||
Host security fields (config) are added to endpoint, subscription and delivery.
|
||||
|
||||
## 8. Standards (owner rules)
|
||||
|
||||
- **Data:** every table is a standard, visible QQQ table (query, view, export, audit). People write endpoints and subscriptions. Secrets are written through the Rotate action or as references. Deliveries and attempts are written only by the qbit.
|
||||
- **Permissions:** `webhooks.view`, `webhooks.manage` and `webhooks.admin` (full qbit name), through a custom checker. The qbit's own writes run in core's authorized scope, so core permission enforcement can stay on.
|
||||
- **Processes:** six table or record actions with the standard streamed-ETL review and result screens, plus three hidden scheduled processes. None is in the nav. The labels are plain.
|
||||
- **App:**
|
||||
- The home dashboard shows real data only:
|
||||
- delivered (24 h), failed attempts (24 h), dead letters, waiting to send, endpoints needing attention, and latency median / p95
|
||||
- attempts per hour for 24 h (succeeded and failed)
|
||||
- endpoints needing attention, with the reason
|
||||
- the newest dead letters, with the Replay hint
|
||||
- The nav lists Endpoints, Subscriptions and Deliveries. Secrets, attempts, a subscription's deliveries and an endpoint's deliveries are relations on their parent records.
|
||||
- **UI:** the home is one HTML widget with no inner scroll and no horizontal scroll. Tables are full width and become stacked cards below 640 px. Dates are compact and the labels plain.
|
||||
- **Integration:**
|
||||
- `WebhooksIntegrations.esbTableNames()` (publish the main tables), `quickSearchTables()` (field weights) and `apiTableNames()`.
|
||||
- The API goes through the same tables, permissions and customizers as the admin, so it behaves identically.
|
||||
|
||||
## 9. Cross-qbit use
|
||||
|
||||
- **Alerts (#1090):** its generic webhook channel can call `Webhooks.deliver(endpoint, type, data, key)` when this qbit is installed, or sign with the core signer itself (same format).
|
||||
- **CDN (#1084):** uses the core signer and safe client (trusted-internal allowlist) for revalidation hooks; it does not need the outbox.
|
||||
|
||||
## 10. Phase 2: inbound webhooks (design only)
|
||||
|
||||
Goal: verify and record signed callbacks from third parties (Authentik, GitHub, Stripe, other QQQ apps), then hand them to the ESB or a process. This is after outbound GA, in this qbit.
|
||||
|
||||
- **Route:** `POST /qqq/v1/webhooks/inbound/{sourceName}`, a route provider, on the same pattern as the Authentik design. The raw body is kept byte for byte for verification.
|
||||
- **Tables:**
|
||||
- `webhooks_inbound_source`:
|
||||
- name, active_status, secret (a reference or generated, same storage as outbound)
|
||||
- scheme: STANDARD_WEBHOOKS, GITHUB (`X-Hub-Signature-256: sha256=<hex>`), STRIPE (`Stripe-Signature: t=..,v1=..`), or SHARED_TOKEN (bearer, constant-time compare)
|
||||
- tolerance_seconds
|
||||
- target: an ESB destination name, a process name, or both
|
||||
- max_body_bytes
|
||||
- `webhooks_inbound_event`:
|
||||
- source_id, external_id (the provider's message id), received_date, status (ACCEPTED / REJECTED / DUPLICATE / HANDED_OFF / FAILED)
|
||||
- reject_reason, headers (allowlisted names only), body (capped), handed_off_date
|
||||
- unique (source_id, external_id)
|
||||
- **Checks, in order** (each rejection is recorded with a reason; secrets are never logged):
|
||||
1. The source exists and is active.
|
||||
2. The body is within the size limit.
|
||||
3. The signature verifies under the scheme (core `WebhookVerifier` for Standard Webhooks).
|
||||
4. The timestamp is within tolerance (replay window).
|
||||
5. The `external_id` was not seen before; otherwise DUPLICATE, answered 200 so the sender stops retrying.
|
||||
- **Response:**
|
||||
- It answers 2xx as soon as the event is stored (the outbox pattern again).
|
||||
- The hand-off runs after commit: it publishes a CloudEvent (`webhooks.inbound.<source>.<type>`, id = `external_id`) to the target ESB destination, or runs the target process with the event id.
|
||||
- A failed hand-off retries, and stays visible as FAILED with Retry and Replay actions.
|
||||
- **Security:**
|
||||
- Rate limiting per source.
|
||||
- No authentication other than the signature (the route is public).
|
||||
- `webhooks.admin` manages sources.
|
||||
- Only allowlisted headers are stored, and payloads can be purged by retention.
|
||||
- **UI:** an "Inbound" area on the home (received / rejected / duplicates per source). Sources join the nav; inbound events are a relation.
|
||||
- **Effort:** about 8 working days (assessment #14).
|
||||
|
||||
## 11. Moving to the core primitives (QQQ 4.1.0-RC.5)
|
||||
|
||||
QRun-IO/qqq#1160 (F11) migrates signing to the released `qqq-bom-pom` 4.1.0-RC.5 baseline. Delivery signing, secret generation/rotation and receiver verification use `com.kingsrook.qqq.backend.core.utils.webhooks` directly. The local `shared.signing` package is removed; endpoints, encrypted secret storage and delivery remain in this qbit. Inbound routes are still phase 2 (design only).
|
||||
|
||||
Comparison against core sources at tag `v4.1.0-RC.5` found no executable differences in `WebhookSigner`, `WebhookVerifier`, `WebhookSecret` or `WebhookVerificationException`; only package names, copyright years and migration comments differed. No compatibility adapter is needed:
|
||||
|
||||
- Headers remain `webhook-id`, `webhook-timestamp`, `webhook-signature` (case-insensitive when verifying).
|
||||
- HMAC-SHA256 signs UTF-8 `id.timestamp.` followed by unchanged body bytes; signatures are padded Base64 with `v1,` and a single space between rotation signatures, in secret-list order.
|
||||
- Secrets remain `whsec_` plus padded Base64 key bytes; unprefixed secrets use trimmed UTF-8 bytes. Generation uses 32 bytes, with a 16-byte minimum for imported keys.
|
||||
- Default timestamp tolerance remains five minutes in either direction, including the boundary.
|
||||
- Verification accepts any matching `v1` entry with any known secret and ignores unknown signature versions.
|
||||
|
||||
Signing contract tests retain the reference vector and test independent local HMAC signing against core verification and core signing against local verification, including two-secret rotation and exact tolerance boundaries. Delivery integration tests verify actual requests with core.
|
||||
|
||||
The `shared.http` copy is outside F11 and remains for a separate migration to `com.kingsrook.qqq.backend.core.utils.http`.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Receiving webhooks from QQQ
|
||||
|
||||
QQQ webhooks follow [Standard Webhooks](https://www.standardwebhooks.com/), so the official Standard Webhooks libraries verify them too.
|
||||
|
||||
## What arrives
|
||||
|
||||
```
|
||||
POST /your/endpoint
|
||||
Content-Type: application/json
|
||||
webhook-id: evt_6f1c2d...
|
||||
webhook-timestamp: 1760300100
|
||||
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
|
||||
webhook-attempt: 1
|
||||
|
||||
{"id":"evt_6f1c2d...","type":"cms_blog_post.updated","timestamp":"2026-10-12T20:15:00Z","source":"qqq://kof22/table/cms_blog_post","sourceEventId":"...","data":{...}}
|
||||
```
|
||||
|
||||
## How to verify
|
||||
|
||||
1. Take the raw request body bytes before any JSON parsing.
|
||||
2. Build `signed = webhook-id + "." + webhook-timestamp + "." + body`.
|
||||
3. Compute `base64(HMAC-SHA256(key, signed))`. The key is the base64-decoded part after `whsec_` in the secret you were given.
|
||||
4. `webhook-signature` holds one or more space-separated `v1,<signature>` entries (two during a secret rotation). Accept the request if any `v1` entry equals yours, comparing in constant time.
|
||||
5. Reject a `webhook-timestamp` more than 5 minutes from your clock (replay protection).
|
||||
6. Remember `webhook-id`s you have processed and ignore repeats. Delivery is at least once, and retries and replays reuse the id.
|
||||
7. Answer 2xx quickly. Any other status, or a redirect, is retried with backoff. A 429 with `Retry-After` is honoured.
|
||||
|
||||
## Java (QQQ core 4.1.0-RC.5 or later)
|
||||
|
||||
```java
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookVerifier;
|
||||
|
||||
WebhookVerifier verifier = new WebhookVerifier(List.of(WebhookSecret.parse(System.getenv("WEBHOOK_SECRET"))));
|
||||
verifier.verify(headersAsMap, rawBodyBytes); // throws WebhookVerificationException with a safe reason
|
||||
```
|
||||
|
||||
## Node.js
|
||||
|
||||
```js
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function verify(headers, rawBody, secret) {
|
||||
const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"];
|
||||
if (!id || !ts || !sigs) throw new Error("missing headers");
|
||||
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("timestamp outside tolerance");
|
||||
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
|
||||
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.`).update(rawBody).digest();
|
||||
const ok = sigs.split(" ").some((part) => {
|
||||
const [version, value] = part.split(",");
|
||||
if (version !== "v1" || !value) return false;
|
||||
const given = Buffer.from(value, "base64");
|
||||
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
|
||||
});
|
||||
if (!ok) throw new Error("bad signature");
|
||||
}
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
```python
|
||||
import base64, hashlib, hmac, time
|
||||
|
||||
def verify(headers: dict, raw_body: bytes, secret: str) -> None:
|
||||
msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
|
||||
if abs(time.time() - int(ts)) > 300:
|
||||
raise ValueError("timestamp outside tolerance")
|
||||
key = base64.b64decode(secret.removeprefix("whsec_"))
|
||||
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
|
||||
for part in sigs.split(" "):
|
||||
version, _, value = part.partition(",")
|
||||
if version == "v1" and hmac.compare_digest(value, expected):
|
||||
return
|
||||
raise ValueError("bad signature")
|
||||
```
|
||||
|
||||
## Secret rotation
|
||||
|
||||
When the sender rotates, it signs with the old and the new secret for 24 hours (configurable). Switch your receiver to the new secret any time in that window. Get the new value from the endpoint's Signing Secrets, or from the environment variable your admin set as a reference.
|
||||
@@ -0,0 +1,68 @@
|
||||
-- qbit-webhooks: add the foreign keys to an existing PostgreSQL schema
|
||||
-- (one created from webhooks-postgres.sql before they were declared there).
|
||||
--
|
||||
-- Safe to run more than once: a foreign key that already exists with the
|
||||
-- right ON DELETE (under any name) is left alone. Before each key is added,
|
||||
-- rows that point at a missing parent are recorded in qqq_fk_quarantine and
|
||||
-- then set to NULL (nullable column) or deleted (NOT NULL column; the whole
|
||||
-- row is kept in row_data). An existing key on the column with a different
|
||||
-- ON DELETE (endpoint_secret and subscription -> endpoint were NO ACTION) is
|
||||
-- replaced by the named one.
|
||||
--
|
||||
-- Order matters: deliveries of a missing endpoint are removed before their
|
||||
-- attempts are checked.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
|
||||
CREATE OR REPLACE FUNCTION pg_temp.qqq_add_fk(p_table TEXT, p_column TEXT, p_parent TEXT, p_on_delete TEXT) RETURNS VOID LANGUAGE plpgsql AS
|
||||
$fn$
|
||||
DECLARE
|
||||
v_name TEXT := 'fk_' || p_table || '_' || p_column;
|
||||
v_rule "char" := CASE p_on_delete WHEN 'CASCADE' THEN 'c' WHEN 'SET NULL' THEN 'n' WHEN 'RESTRICT' THEN 'r' ELSE 'a' END;
|
||||
v_nullable BOOLEAN;
|
||||
v_changeset TEXT := 'qbit-webhooks:webhooks-foreign-keys-upgrade.sql';
|
||||
v_old RECORD;
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f' AND c.conrelid = p_table::regclass AND cardinality(c.conkey) = 1 AND a.attname = p_column
|
||||
AND c.confrelid = p_parent::regclass AND c.confdeltype = v_rule) THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
FOR v_old IN SELECT c.conname FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f' AND c.conrelid = p_table::regclass AND cardinality(c.conkey) = 1 AND a.attname = p_column LOOP
|
||||
EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', p_table, v_old.conname);
|
||||
END LOOP;
|
||||
|
||||
SELECT is_nullable = 'YES' INTO v_nullable FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = p_table AND column_name = p_column;
|
||||
|
||||
IF v_nullable THEN
|
||||
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) '
|
||||
|| 'SELECT %L, %L, t.id::TEXT, t.%I::TEXT, ''NULLED'', %L FROM %I t WHERE t.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
|
||||
p_table, p_column, p_column, v_changeset, p_table, p_column, p_parent, p_column);
|
||||
EXECUTE format('UPDATE %I t SET %I = NULL WHERE t.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
|
||||
p_table, p_column, p_column, p_parent, p_column);
|
||||
ELSE
|
||||
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) '
|
||||
|| 'SELECT %L, %L, t.id::TEXT, t.%I::TEXT, ''DELETED'', row_to_json(t)::TEXT, %L FROM %I t WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)',
|
||||
p_table, p_column, p_column, v_changeset, p_table, p_parent, p_column);
|
||||
EXECUTE format('DELETE FROM %I t WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = t.%I)', p_table, p_parent, p_column);
|
||||
END IF;
|
||||
|
||||
EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I (id) ON DELETE %s', p_table, v_name, p_column, p_parent, p_on_delete);
|
||||
END
|
||||
$fn$;
|
||||
|
||||
SELECT pg_temp.qqq_add_fk('webhooks_endpoint_secret', 'endpoint_id', 'webhooks_endpoint', 'CASCADE');
|
||||
SELECT pg_temp.qqq_add_fk('webhooks_subscription', 'endpoint_id', 'webhooks_endpoint', 'CASCADE');
|
||||
SELECT pg_temp.qqq_add_fk('webhooks_delivery', 'endpoint_id', 'webhooks_endpoint', 'RESTRICT');
|
||||
SELECT pg_temp.qqq_add_fk('webhooks_delivery', 'subscription_id', 'webhooks_subscription', 'SET NULL');
|
||||
SELECT pg_temp.qqq_add_fk('webhooks_delivery_attempt', 'delivery_id', 'webhooks_delivery', 'CASCADE');
|
||||
SELECT pg_temp.qqq_add_fk('webhooks_delivery_attempt', 'endpoint_id', 'webhooks_endpoint', 'RESTRICT');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS webhooks_delivery_subscription_ix ON webhooks_delivery (subscription_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,157 @@
|
||||
-- qbit-webhooks 1.0: the five webhooks_ tables (PostgreSQL).
|
||||
--
|
||||
-- Column names are the snake_case of the field names (WebhooksQBitConfig
|
||||
-- snakeCaseColumnNames, the default). Hosts that configure securityFields add
|
||||
-- those columns to webhooks_endpoint, webhooks_subscription and
|
||||
-- webhooks_delivery. The unique keys matter: webhooks_delivery.dedup_key stops
|
||||
-- an event being queued twice, and (delivery_id, attempt_number) on
|
||||
-- webhooks_delivery_attempt is the claim that stops two replicas sending the
|
||||
-- same attempt.
|
||||
--
|
||||
-- Foreign keys (named fk_<table>_<column>):
|
||||
-- endpoint_secret.endpoint_id, subscription.endpoint_id -> endpoint CASCADE:
|
||||
-- an endpoint's secrets and subscriptions are its own configuration, so
|
||||
-- deleting the endpoint (webhooks.admin) removes them.
|
||||
-- delivery.endpoint_id, delivery_attempt.endpoint_id -> endpoint RESTRICT:
|
||||
-- delivery history keeps its endpoint; cancel and Delete Deliveries first.
|
||||
-- delivery.subscription_id -> subscription SET NULL:
|
||||
-- a delivery outlives the subscription that queued it.
|
||||
-- delivery_attempt.delivery_id -> delivery CASCADE:
|
||||
-- attempts belong to their delivery (Retention deletes both).
|
||||
-- Existing databases: run webhooks-foreign-keys-upgrade.sql.
|
||||
|
||||
CREATE TABLE webhooks_endpoint
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
active_status VARCHAR(20) DEFAULT 'ACTIVE',
|
||||
health_status VARCHAR(20) DEFAULT 'HEALTHY',
|
||||
serial_delivery BOOLEAN DEFAULT FALSE,
|
||||
max_per_minute INTEGER,
|
||||
timeout_seconds INTEGER,
|
||||
allow_unsigned BOOLEAN DEFAULT FALSE,
|
||||
allow_internal_targets BOOLEAN DEFAULT FALSE,
|
||||
internal_allowlist TEXT,
|
||||
consecutive_failures INTEGER DEFAULT 0,
|
||||
probation_failures INTEGER DEFAULT 0,
|
||||
last_success_date TIMESTAMP WITH TIME ZONE,
|
||||
last_failure_date TIMESTAMP WITH TIME ZONE,
|
||||
last_failure_message VARCHAR(500),
|
||||
unhealthy_since_date TIMESTAMP WITH TIME ZONE,
|
||||
auto_disabled_date TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT webhooks_endpoint_name_uk UNIQUE (name)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE webhooks_endpoint_secret
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
endpoint_id INTEGER NOT NULL CONSTRAINT fk_webhooks_endpoint_secret_endpoint_id REFERENCES webhooks_endpoint (id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) DEFAULT 'ACTIVE',
|
||||
source VARCHAR(20) NOT NULL,
|
||||
secret_ref VARCHAR(100),
|
||||
encrypted_secret TEXT,
|
||||
fingerprint VARCHAR(20),
|
||||
expires_date TIMESTAMP WITH TIME ZONE,
|
||||
retired_date TIMESTAMP WITH TIME ZONE,
|
||||
note VARCHAR(250)
|
||||
);
|
||||
|
||||
CREATE INDEX webhooks_endpoint_secret_endpoint_ix ON webhooks_endpoint_secret (endpoint_id, status);
|
||||
|
||||
|
||||
CREATE TABLE webhooks_subscription
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
endpoint_id INTEGER NOT NULL CONSTRAINT fk_webhooks_subscription_endpoint_id REFERENCES webhooks_endpoint (id) ON DELETE CASCADE,
|
||||
event_type_name VARCHAR(200) NOT NULL,
|
||||
active_status VARCHAR(20) DEFAULT 'ACTIVE',
|
||||
description VARCHAR(250),
|
||||
filter_json TEXT,
|
||||
detail_level VARCHAR(20) DEFAULT 'FULL',
|
||||
include_fields VARCHAR(1000),
|
||||
exclude_fields VARCHAR(1000),
|
||||
api_name VARCHAR(100),
|
||||
api_version VARCHAR(50)
|
||||
);
|
||||
|
||||
CREATE INDEX webhooks_subscription_type_ix ON webhooks_subscription (event_type_name, active_status);
|
||||
|
||||
CREATE INDEX webhooks_subscription_endpoint_ix ON webhooks_subscription (endpoint_id);
|
||||
|
||||
|
||||
CREATE TABLE webhooks_delivery
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
event_id VARCHAR(40) NOT NULL,
|
||||
event_type_name VARCHAR(200) NOT NULL,
|
||||
endpoint_id INTEGER NOT NULL CONSTRAINT fk_webhooks_delivery_endpoint_id REFERENCES webhooks_endpoint (id) ON DELETE RESTRICT,
|
||||
subscription_id INTEGER CONSTRAINT fk_webhooks_delivery_subscription_id REFERENCES webhooks_subscription (id) ON DELETE SET NULL,
|
||||
source VARCHAR(20),
|
||||
source_table VARCHAR(100),
|
||||
source_record_id VARCHAR(100),
|
||||
source_event_id VARCHAR(100),
|
||||
dedup_key VARCHAR(250) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
attempt_count INTEGER DEFAULT 0,
|
||||
cycle_attempt_count INTEGER DEFAULT 0,
|
||||
replay_count INTEGER DEFAULT 0,
|
||||
occurred_date TIMESTAMP WITH TIME ZONE,
|
||||
next_attempt_date TIMESTAMP WITH TIME ZONE,
|
||||
lease_expires_date TIMESTAMP WITH TIME ZONE,
|
||||
last_http_status INTEGER,
|
||||
last_error VARCHAR(500),
|
||||
delivered_date TIMESTAMP WITH TIME ZONE,
|
||||
dead_letter_date TIMESTAMP WITH TIME ZONE,
|
||||
payload_bytes INTEGER,
|
||||
payload TEXT,
|
||||
CONSTRAINT webhooks_delivery_dedup_key_uk UNIQUE (dedup_key),
|
||||
CONSTRAINT webhooks_delivery_event_id_uk UNIQUE (event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX webhooks_delivery_due_ix ON webhooks_delivery (endpoint_id, status, next_attempt_date);
|
||||
|
||||
CREATE INDEX webhooks_delivery_status_ix ON webhooks_delivery (status, modify_date);
|
||||
|
||||
CREATE INDEX webhooks_delivery_delivered_ix ON webhooks_delivery (delivered_date);
|
||||
|
||||
CREATE INDEX webhooks_delivery_subscription_ix ON webhooks_delivery (subscription_id);
|
||||
|
||||
|
||||
CREATE TABLE webhooks_delivery_attempt
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
modify_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
delivery_id INTEGER NOT NULL CONSTRAINT fk_webhooks_delivery_attempt_delivery_id REFERENCES webhooks_delivery (id) ON DELETE CASCADE,
|
||||
endpoint_id INTEGER NOT NULL CONSTRAINT fk_webhooks_delivery_attempt_endpoint_id REFERENCES webhooks_endpoint (id) ON DELETE RESTRICT,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
outcome VARCHAR(20) NOT NULL,
|
||||
claimed_by VARCHAR(100),
|
||||
lease_expires_date TIMESTAMP WITH TIME ZONE,
|
||||
start_date TIMESTAMP WITH TIME ZONE,
|
||||
end_date TIMESTAMP WITH TIME ZONE,
|
||||
duration_millis INTEGER,
|
||||
http_status INTEGER,
|
||||
response_excerpt TEXT,
|
||||
error_message VARCHAR(1000),
|
||||
remote_address VARCHAR(64),
|
||||
request_url VARCHAR(500),
|
||||
signed_with VARCHAR(100),
|
||||
CONSTRAINT webhooks_delivery_attempt_uk UNIQUE (delivery_id, attempt_number)
|
||||
);
|
||||
|
||||
CREATE INDEX webhooks_delivery_attempt_endpoint_ix ON webhooks_delivery_attempt (endpoint_id, start_date);
|
||||
|
||||
CREATE INDEX webhooks_delivery_attempt_start_ix ON webhooks_delivery_attempt (start_date);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Migrating from qbit-webhooks 0.x to 1.0
|
||||
|
||||
1.0 is a breaking release (owner decision D1/D3). The only known 0.x consumer is the `qqq-all` demo app.
|
||||
|
||||
| 0.x | 1.0 |
|
||||
|---|---|
|
||||
| Tables `webhook`, `webhookSubscription`, `webhookEvent`, `webhookEventContent`, `webhookEventSendLog` | `webhooks_endpoint`, `webhooks_subscription`, `webhooks_delivery` (payload inline, no content table), `webhooks_delivery_attempt`, plus `webhooks_endpoint_secret` |
|
||||
| Permission names `webhook.*` (generic table permissions) | `webhooks.view`, `webhooks.manage`, `webhooks.admin` |
|
||||
| Integer status ids | Enum names stored as text (`ACTIVE`, `DELIVERED`, ...) |
|
||||
| `WebhookEventCategory` (insert, update, store, with-field, with-value, ad hoc) | `WebhookEventKind` (TABLE_INSERT / UPDATE / DELETE / STORE, PROCESS_COMPLETED / FAILED, CUSTOM) with optional `fieldName` / `value` |
|
||||
| `FireAdHocWebhookEvent.fire(...)` | `Webhooks.fire(...)` / `Webhooks.fireForRecord(...)` |
|
||||
| `WebhookEventTypeCustomizerInterface` | `WebhookPayloadCustomizerInterface` (changes `data` only) |
|
||||
| Payload `{webhookEventDetails, record}` | Envelope `{id, type, timestamp, source, sourceEventId, data}` |
|
||||
| Unsigned POSTs to any URL | Signed (Standard Webhooks); private targets blocked unless an admin allowlists them |
|
||||
| One scheduled job per webhook | One scheduled delivery process for all endpoints |
|
||||
| `httpTimeoutMillis` ignored | Honoured (read timeout) |
|
||||
|
||||
## Data
|
||||
|
||||
Existing 0.x rows are configuration and history. The simplest path, which needs no downtime handling, is:
|
||||
|
||||
1. Create the 1.0 tables (`docs/ddl/webhooks-postgres.sql`).
|
||||
2. Re-create endpoints and subscriptions in the new tables, from the old `webhook` and `webhookSubscription` rows: name, url, event type name, API name and version.
|
||||
3. Rotate a signing secret for each endpoint, and give it to the receiver.
|
||||
4. Keep or archive the old tables. 1.0 does not read them.
|
||||
|
||||
Event type names change if they were custom. Re-register them as `WebhookEventType` with a `WebhookEventKind`.
|
||||
@@ -0,0 +1,295 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
@@ -0,0 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
@@ -1,47 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
~ Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
~ contact@kingsrook.com
|
||||
~ https://github.com/Kingsrook/
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU Affero General Public License as
|
||||
~ published by the Free Software Foundation, either version 3 of the
|
||||
~ License, or (at your option) any later version.
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU Affero General Public License for more details.
|
||||
~ https://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ You should have received a copy of the GNU Affero General Public License
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<properties>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>1.0.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
<h2.version>2.2.224</h2.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>1.5.1</version>
|
||||
<version>2.1.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-webhooks</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Webhooks</name>
|
||||
<description>Webhook implementation for QQQ applications, enabling real-time event notifications to external systems and partners</description>
|
||||
<url>https://github.com/Kingsrook/qbit-webhooks</url>
|
||||
<description>QBit for signed, SSRF-safe outbound webhooks from QQQ applications: table, process, ESB and custom events, subscriptions, retries, dead letters and endpoint health</description>
|
||||
<url>https://github.com/QRun-IO/qbit-webhooks</url>
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>GNU Affero General Public License v3.0</name>
|
||||
<url>https://www.gnu.org/licenses/agpl-3.0.html</url>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
@@ -57,46 +67,67 @@
|
||||
</developers>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/Kingsrook/qbit-webhooks.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:Kingsrook/qbit-webhooks.git</developerConnection>
|
||||
<url>https://github.com/Kingsrook/qbit-webhooks</url>
|
||||
<connection>scm:git:git://github.com/QRun-IO/qbit-webhooks.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-webhooks.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qbit-webhooks</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<properties>
|
||||
<revision>0.3.0-SNAPSHOT</revision>
|
||||
</properties>
|
||||
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>0.27.9</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: render payloads through a QQQ API version (subscription apiName / apiVersion). -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-api</artifactId>
|
||||
<artifactId>qqq-middleware-api</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional: consume ESB topics (WebhooksEsbSource). -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-esb</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>${h2.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- the foreign key guardrail test: docs/ddl on a real PostgreSQL (src/test only) -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.9</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-postgresql</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -105,9 +136,140 @@
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.27.3</version>
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<!-- the artifact version, for the help bundle version test -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.12.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle/config.xml</configLocation>
|
||||
<headerLocation>checkstyle/license.txt</headerLocation>
|
||||
<encoding>UTF-8</encoding>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>false</failsOnError>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<includeTestSourceDirectory>false</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- QQQ 4.1 APIs until qbit-build-parent imports the 4.1 BOM (as in qbit-quick-search and qbit-cdn, ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.snapshot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.events.DeliveryOutbox;
|
||||
import com.kingsrook.qbits.webhooks.events.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.EventSource;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's public API for code: fire custom (domain) events, and deliver
|
||||
** to a named endpoint as a channel (the Alerts qbit's webhook channel uses
|
||||
** this, through reflection, when this qbit is installed).
|
||||
**
|
||||
** <pre>
|
||||
** // a domain event: every subscription to "post.published" gets it
|
||||
** Webhooks.fire("post.published", Map.of("slug", slug, "title", title));
|
||||
**
|
||||
** // in the same transaction as the write that caused it
|
||||
** Webhooks.fireForRecord("post.published", "cms_blog_post", record, transaction);
|
||||
**
|
||||
** // a channel delivery to one endpoint, queued once per idempotency key
|
||||
** Integer deliveryId = Webhooks.deliver("ops-pager", "alerts.alert.raised", body, "alert-123-raised");
|
||||
** </pre>
|
||||
*******************************************************************************/
|
||||
public final class Webhooks
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private Webhooks()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register a custom event type (call while building the instance).
|
||||
*******************************************************************************/
|
||||
public static void registerEventType(WebhookEventType eventType)
|
||||
{
|
||||
WebhooksRegistry.of(QContext.getQInstance()).register(eventType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fire a custom event. Returns the number of deliveries queued.
|
||||
*******************************************************************************/
|
||||
public static int fire(String eventTypeName, Map<String, ?> data) throws QException
|
||||
{
|
||||
return (fire(eventTypeName, data, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fire a custom event inside a transaction (on the qbit's backend).
|
||||
*******************************************************************************/
|
||||
public static int fire(String eventTypeName, Map<String, ?> data, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
requireType(eventTypeName);
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
WebhookEvent event = new WebhookEvent()
|
||||
.withEventTypeName(eventTypeName)
|
||||
.withSource(EventSource.CUSTOM)
|
||||
.withSourceUri("qqq://" + config.getSourceName() + "/event/" + eventTypeName)
|
||||
.withOccurredAt(WebhooksRuntime.clock().instant())
|
||||
.withData(data);
|
||||
return (DeliveryOutbox.enqueue(List.of(event), transaction).size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fire a custom event about a record (its subscriptions' filters, record
|
||||
** security and payload shaping apply to the record).
|
||||
*******************************************************************************/
|
||||
public static int fireForRecord(String eventTypeName, String tableName, QRecord record, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
requireType(eventTypeName);
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
Object key = table == null ? null : record.getValue(table.getPrimaryKeyField());
|
||||
WebhookEvent event = new WebhookEvent()
|
||||
.withEventTypeName(eventTypeName)
|
||||
.withSource(EventSource.CUSTOM)
|
||||
.withSourceTable(tableName)
|
||||
.withSourceRecordId(key == null ? null : ValueUtils.getValueAsString(key))
|
||||
.withSourceUri("qqq://" + config.getSourceName() + "/table/" + tableName)
|
||||
.withOccurredAt(WebhooksRuntime.clock().instant())
|
||||
.withRecord(record);
|
||||
return (DeliveryOutbox.enqueue(List.of(event), transaction).size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliver a payload to one endpoint (by name), with no subscription: the
|
||||
** channel API for other qbits. Signing, retries, dead letters, health and
|
||||
** the delivery log all apply. Calling again with the same idempotency key
|
||||
** returns the existing delivery instead of queuing another.
|
||||
**
|
||||
** @return the webhooks_delivery id
|
||||
*******************************************************************************/
|
||||
public static Integer deliver(String endpointName, String eventTypeName, Map<String, ?> data, String idempotencyKey) throws QException
|
||||
{
|
||||
if(!WebhooksRuntime.isInstalled())
|
||||
{
|
||||
throw (new QException("The webhooks qbit is not installed"));
|
||||
}
|
||||
List<QRecord> endpoints = SystemWrites.query(WebhooksTables.ENDPOINT, new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, endpointName)));
|
||||
if(endpoints.isEmpty())
|
||||
{
|
||||
throw (new QException("No webhook endpoint is named " + endpointName));
|
||||
}
|
||||
QRecord endpoint = endpoints.get(0);
|
||||
if(ActiveStatus.DISABLED.name().equals(endpoint.getValueString("activeStatus")))
|
||||
{
|
||||
throw (new QException("The webhook endpoint " + endpointName + " is disabled"));
|
||||
}
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
WebhookEvent event = new WebhookEvent()
|
||||
.withEventTypeName(eventTypeName)
|
||||
.withSource(EventSource.CHANNEL)
|
||||
.withSourceUri("qqq://" + config.getSourceName() + "/channel/" + eventTypeName)
|
||||
.withOccurredAt(WebhooksRuntime.clock().instant())
|
||||
.withData(data);
|
||||
return (DeliveryOutbox.enqueueDirect(event, endpoint.getValueInteger("id"), idempotencyKey).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail on an unregistered type (a typo would otherwise fire into nothing).
|
||||
*******************************************************************************/
|
||||
private static void requireType(String eventTypeName) throws QException
|
||||
{
|
||||
if(!WebhooksRuntime.isInstalled())
|
||||
{
|
||||
throw (new QException("The webhooks qbit is not installed"));
|
||||
}
|
||||
if(WebhooksRegistry.of(QContext.getQInstance()).get(eventTypeName) == null)
|
||||
{
|
||||
throw (new QException("Unknown webhook event type: " + eventTypeName + " (register it with Webhooks.registerEventType)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a host wires into its ESB, Quick Search and API, without this qbit
|
||||
** depending on those modules (the same opt-in hooks as qbit-sql-workbench).
|
||||
**
|
||||
** <ul>
|
||||
** <li>{@link #esbTableNames()}: publish these tables' inserts, updates and
|
||||
** deletes to an ESB topic (the main tables).</li>
|
||||
** <li>{@link #quickSearchTables()}: index these in Quick Search, with field
|
||||
** weights.</li>
|
||||
** <li>{@link #apiTableNames()}: expose these in the host's QQQ API. The API
|
||||
** gets exactly the admin's rules: the same permissions, customizers and
|
||||
** read-only tables.</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksIntegrations
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A table for Quick Search: field weights, the timestamp for incremental
|
||||
** indexing, and the record label.
|
||||
*******************************************************************************/
|
||||
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksIntegrations()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables to publish on the ESB.
|
||||
*******************************************************************************/
|
||||
public static List<String> esbTableNames()
|
||||
{
|
||||
return (WebhooksTables.MAIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables for the host's QQQ API.
|
||||
*******************************************************************************/
|
||||
public static List<String> apiTableNames()
|
||||
{
|
||||
return (WebhooksTables.MAIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables for Quick Search.
|
||||
*******************************************************************************/
|
||||
public static List<SearchableTable> quickSearchTables()
|
||||
{
|
||||
Map<String, Integer> endpoint = new LinkedHashMap<>();
|
||||
endpoint.put("name", 10);
|
||||
endpoint.put("url", 6);
|
||||
endpoint.put("description", 3);
|
||||
|
||||
Map<String, Integer> subscription = new LinkedHashMap<>();
|
||||
subscription.put("eventTypeName", 8);
|
||||
subscription.put("description", 4);
|
||||
|
||||
Map<String, Integer> delivery = new LinkedHashMap<>();
|
||||
delivery.put("eventId", 10);
|
||||
delivery.put("sourceEventId", 6);
|
||||
delivery.put("eventTypeName", 5);
|
||||
delivery.put("sourceRecordId", 4);
|
||||
delivery.put("lastError", 2);
|
||||
|
||||
return (List.of(
|
||||
new SearchableTable(WebhooksTables.ENDPOINT, endpoint, "modifyDate", "%s", List.of("name")),
|
||||
new SearchableTable(WebhooksTables.SUBSCRIPTION, subscription, "modifyDate", "Subscription to %s", List.of("eventTypeName")),
|
||||
new SearchableTable(WebhooksTables.DELIVERY, delivery, "modifyDate", "%s delivery %s", List.of("eventTypeName", "eventId"))));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,134 +5,255 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.actions.FirePostInsertWebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.actions.FirePostUpdateWebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventContent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventSendLog;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookSubscription;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.app.WebhooksApp;
|
||||
import com.kingsrook.qbits.webhooks.events.WebhooksProcessListener;
|
||||
import com.kingsrook.qbits.webhooks.events.WebhooksRecordChangeListener;
|
||||
import com.kingsrook.qbits.webhooks.health.WebhooksHealthChecks;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksHelp;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTableCustomizers;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.processes.WebhooksProcesses;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventKind;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** meta-data producer for the webhooks qbit
|
||||
** Entry point of the webhooks qbit.
|
||||
**
|
||||
** <pre>
|
||||
** new WebhooksQBitProducer()
|
||||
** .withQBitConfig(new WebhooksQBitConfig()
|
||||
** .withBackendName("rdbms")
|
||||
** .withSchedulerName("scheduler")
|
||||
** .withTableEventSources(List.of("cms_blog_post")))
|
||||
** .produce(qInstance);
|
||||
** </pre>
|
||||
**
|
||||
** <p>Produces the five {@code webhooks_*} tables, their joins, widgets and
|
||||
** possible values, the processes, the Webhooks app, the event types implied
|
||||
** by the config, and the record-change and process listeners. Call
|
||||
** {@code WebhooksEsbSource.configure(qInstance)} afterwards to consume ESB
|
||||
** topics.</p>
|
||||
*******************************************************************************/
|
||||
public class WebhooksQBitProducer implements QBitMetaDataProducer<WebhooksQBitConfig>
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-webhooks";
|
||||
public static final String VERSION = "1.0.0";
|
||||
|
||||
private WebhooksQBitConfig qBitConfig;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
QBitMetaData qBitMetaData = new QBitMetaData()
|
||||
.withGroupId("com.kingsrook.qbits")
|
||||
.withArtifactId("webhooks")
|
||||
.withVersion("0.1.4")
|
||||
.withNamespace(getNamespace())
|
||||
.withConfig(getQBitConfig());
|
||||
return qBitMetaData;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static QAppSection produceAppSection()
|
||||
{
|
||||
return new QAppSection()
|
||||
.withName("webhooks")
|
||||
.withTables(List.of(
|
||||
Webhook.TABLE_NAME,
|
||||
WebhookSubscription.TABLE_NAME,
|
||||
WebhookEvent.TABLE_NAME,
|
||||
WebhookEventContent.TABLE_NAME,
|
||||
WebhookEventSendLog.TABLE_NAME
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void postProduceActions(MetaDataProducerMultiOutput metaDataProducerMultiOutput, QInstance qinstance)
|
||||
{
|
||||
registerTableActionsInInstance(qinstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public void registerTableActionsInInstance(QInstance qInstance)
|
||||
{
|
||||
qInstance.withTableCustomizer(TableCustomizers.POST_INSERT_RECORD, new QCodeReference(FirePostInsertWebhookEvent.class));
|
||||
qInstance.withTableCustomizer(TableCustomizers.POST_UPDATE_RECORD, new QCodeReference(FirePostUpdateWebhookEvent.class));
|
||||
}
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for qBitConfig
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
public void setQBitConfig(WebhooksQBitConfig qBitConfig)
|
||||
public WebhooksQBitProducer withQBitConfig(WebhooksQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = qBitConfig;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for qBitConfig
|
||||
*******************************************************************************/
|
||||
public WebhooksQBitProducer withQBitConfig(WebhooksQBitConfig qBitConfig)
|
||||
{
|
||||
this.qBitConfig = qBitConfig;
|
||||
this.qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
** Getter for the config.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public WebhooksQBitConfig getQBitConfig()
|
||||
{
|
||||
return (qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the namespace.
|
||||
*******************************************************************************/
|
||||
public WebhooksQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the namespace.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identity.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-scanned producers have no config: skip them.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce everything into the instance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
qBitConfig.validate(qInstance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QException("Webhooks qbit configuration is invalid: " + String.join("; ", errors)));
|
||||
}
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
qInstance.addQBit(qBit);
|
||||
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
for(QPossibleValueSource possibleValueSource : WebhooksTables.possibleValueSources())
|
||||
{
|
||||
output.add(possibleValueSource);
|
||||
}
|
||||
|
||||
Map<String, QTableMetaData> tables = new LinkedHashMap<>();
|
||||
for(QTableMetaData table : WebhooksTables.tables(qBitConfig))
|
||||
{
|
||||
addCustomizers(table);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getSnakeCaseColumnNames()))
|
||||
{
|
||||
snakeCaseColumns(table);
|
||||
}
|
||||
QTableMetaData customized = qBitConfig.getTableMetaDataCustomizer() == null ? table : qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
|
||||
customized.setSourceQBitName(qBit.getName());
|
||||
tables.put(customized.getName(), customized);
|
||||
output.add(customized);
|
||||
}
|
||||
for(QJoinMetaData join : WebhooksTables.joins())
|
||||
{
|
||||
output.add(join);
|
||||
}
|
||||
for(QWidgetMetaData widget : WebhooksTables.childWidgets())
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
for(QWidgetMetaData widget : WebhooksApp.widgets())
|
||||
{
|
||||
output.add(widget);
|
||||
}
|
||||
for(QProcessMetaData process : WebhooksProcesses.processes(qBitConfig))
|
||||
{
|
||||
process.setSourceQBitName(qBit.getName());
|
||||
output.add(process);
|
||||
}
|
||||
output.add(WebhooksApp.app(qBitConfig, tables));
|
||||
output.addSelfToInstance(qInstance);
|
||||
|
||||
WebhooksHealthChecks.register(qInstance);
|
||||
|
||||
WebhooksRegistry registry = WebhooksRegistry.of(qInstance);
|
||||
registry.registerFromConfig(qInstance, qBitConfig);
|
||||
registry.register(new WebhookEventType().withName(WebhooksProcesses.TEST_EVENT_TYPE).withKind(WebhookEventKind.CUSTOM)
|
||||
.withLabel("Test event").withDescription("Sent by the Send Test Event action."));
|
||||
|
||||
qInstance.withRecordChangeListener(new QCodeReference(WebhooksRecordChangeListener.class));
|
||||
if(!qBitConfig.getProcessEventSources().isEmpty())
|
||||
{
|
||||
qInstance.withProcessLifecycleListener(new QCodeReference(WebhooksProcessListener.class));
|
||||
}
|
||||
// Registration is metadata-only; core guards startup sync when helpContent is absent.
|
||||
HelpBundles.register(qInstance, "help/qbit-webhooks.yaml");
|
||||
WebhooksHelp.apply(qInstance, qBitConfig);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The validating customizers.
|
||||
*******************************************************************************/
|
||||
private static void addCustomizers(QTableMetaData table)
|
||||
{
|
||||
Class<?> customizer = switch(table.getName())
|
||||
{
|
||||
case WebhooksTables.ENDPOINT -> WebhooksTableCustomizers.EndpointCustomizer.class;
|
||||
case WebhooksTables.SUBSCRIPTION -> WebhooksTableCustomizers.SubscriptionCustomizer.class;
|
||||
case WebhooksTables.SECRET -> WebhooksTableCustomizers.SecretCustomizer.class;
|
||||
default -> null;
|
||||
};
|
||||
if(customizer != null)
|
||||
{
|
||||
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(customizer));
|
||||
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(customizer));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Column names in snake_case (matching docs/ddl).
|
||||
*******************************************************************************/
|
||||
private static void snakeCaseColumns(QTableMetaData table)
|
||||
{
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(field.getBackendName() == null)
|
||||
{
|
||||
field.setBackendName(QInstanceEnricher.inferBackendName(field.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks;
|
||||
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.webhooks.shared.http.SafeHttpClient;
|
||||
import com.kingsrook.qbits.webhooks.shared.http.SafeHttpPolicy;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runtime lookups for the qbit.
|
||||
**
|
||||
** <p>The config is read from the current QInstance on every call (never held
|
||||
** in a static), so reconfiguring, or two instances in one JVM, never see
|
||||
** stale settings. The only JVM-wide state is the HTTP connection pools (one
|
||||
** per distinct policy) and the clock, which tests replace.</p>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksRuntime
|
||||
{
|
||||
private static final Map<String, SafeHttpClient> HTTP_CLIENTS = new ConcurrentHashMap<>();
|
||||
|
||||
private static final String NODE_ID = ManagementFactory.getRuntimeMXBean().getName() + "/" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
private static volatile Clock clock = Clock.systemUTC();
|
||||
private static volatile SafeHttpClient.HostResolver resolver = null;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksRuntime()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The config in the current instance, or null when the qbit is not installed.
|
||||
*******************************************************************************/
|
||||
public static WebhooksQBitConfig config()
|
||||
{
|
||||
return (config(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The config in an instance, or null when the qbit is not installed.
|
||||
*******************************************************************************/
|
||||
public static WebhooksQBitConfig config(QInstance qInstance)
|
||||
{
|
||||
if(qInstance == null || qInstance.getQBits() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(QBitMetaData qBit : qInstance.getQBits().values())
|
||||
{
|
||||
if(WebhooksQBitProducer.ARTIFACT_ID.equals(qBit.getArtifactId()) && qBit.getConfig() instanceof WebhooksQBitConfig config)
|
||||
{
|
||||
return (config);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the qbit is installed in the current instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isInstalled()
|
||||
{
|
||||
return (config() != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The clock (tests replace it).
|
||||
*******************************************************************************/
|
||||
public static Clock clock()
|
||||
{
|
||||
return (clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the clock (tests).
|
||||
*******************************************************************************/
|
||||
public static void setClock(Clock newClock)
|
||||
{
|
||||
clock = newClock == null ? Clock.systemUTC() : newClock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace DNS resolution for outbound requests (tests; null restores the
|
||||
** system resolver). Clears the connection pools.
|
||||
*******************************************************************************/
|
||||
public static void setHostResolver(SafeHttpClient.HostResolver newResolver)
|
||||
{
|
||||
resolver = newResolver;
|
||||
closeHttpClients();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An id for this JVM, written on the attempts it claims.
|
||||
*******************************************************************************/
|
||||
public static String nodeId()
|
||||
{
|
||||
return (NODE_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The HTTP policy for an endpoint: the config's limits plus the endpoint's
|
||||
** own timeout and internal allowlist (when it may use one).
|
||||
*******************************************************************************/
|
||||
public static SafeHttpPolicy policyFor(WebhooksQBitConfig config, Integer endpointTimeoutSeconds, java.util.List<String> internalAllowlist)
|
||||
{
|
||||
Duration read = endpointTimeoutSeconds != null && endpointTimeoutSeconds > 0
|
||||
? Duration.ofSeconds(endpointTimeoutSeconds) : Duration.ofMillis(config.getHttpTimeoutMillis());
|
||||
Duration total = Duration.ofMillis(config.getTotalTimeoutMillis());
|
||||
if(read.compareTo(total) > 0)
|
||||
{
|
||||
read = total;
|
||||
}
|
||||
return (new SafeHttpPolicy()
|
||||
.withAllowPlainHttp(Boolean.TRUE.equals(config.getAllowPlainHttp()))
|
||||
.withInternalAllowlist(internalAllowlist)
|
||||
.withConnectTimeout(Duration.ofMillis(config.getConnectTimeoutMillis()))
|
||||
.withReadTimeout(read)
|
||||
.withTotalTimeout(total)
|
||||
.withMaxResponseBytes(config.getMaxResponseBytes())
|
||||
.withUserAgent(config.getUserAgent()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A shared client for a policy.
|
||||
*******************************************************************************/
|
||||
public static SafeHttpClient httpClient(SafeHttpPolicy policy)
|
||||
{
|
||||
SafeHttpClient.HostResolver current = resolver;
|
||||
return (HTTP_CLIENTS.computeIfAbsent(policy.cacheKey(), key -> current == null ? new SafeHttpClient(policy) : new SafeHttpClient(policy, current)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close every pooled client (tests, shutdown).
|
||||
*******************************************************************************/
|
||||
public static void closeHttpClients()
|
||||
{
|
||||
for(SafeHttpClient client : HTTP_CLIENTS.values())
|
||||
{
|
||||
client.close();
|
||||
}
|
||||
HTTP_CLIENTS.clear();
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
- a
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** table customizer that clears the memoizations in the WebhookSubscriptionsHelper
|
||||
*******************************************************************************/
|
||||
public class ClearWebhookSubscriptionHelperMemoizationsTableCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsertOrUpdate(AbstractActionInput input, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
WebhookSubscriptionsHelper.clearMemoizations();;
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
WebhookSubscriptionsHelper.clearMemoizations();;
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default implementation of WebhookEventTypeCustomizerInterface - just lets
|
||||
** all the default methods from the interface run.
|
||||
*******************************************************************************/
|
||||
public class DefaultWebhookEventTypeCustomizer implements WebhookEventTypeCustomizerInterface
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventCategory;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
* fire an ad-hoc webhook event - e.g., one not tied to a basic table insert/
|
||||
* update/delete
|
||||
*******************************************************************************/
|
||||
public class FireAdHocWebhookEvent
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(FireAdHocWebhookEvent.class);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* fire the event - creating webhookEvent records for any non-disabled
|
||||
* subscriptions that match it.
|
||||
*
|
||||
* @param eventTypeName must be a registered event type name in the instance.
|
||||
* @param tableName table that the key-record is from, which will be used as
|
||||
* part of the subscription-matching
|
||||
* @param keyRecord record that the event is related to - again may be used
|
||||
* in subscription-matching (e.g., based on security fields
|
||||
* @param transaction in case an update to the key record was made on a
|
||||
* transaction, the events will be created in the same one.
|
||||
***************************************************************************/
|
||||
public void fire(String eventTypeName, String tableName, QRecord keyRecord, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
List<WebhookEventType> webhookEventTypes = WebhookSubscriptionsHelper.getWebhookEventTypesToConsiderFiringEventsFor(WebhookEventCategory.Kind.AD_HOC, tableName);
|
||||
if(CollectionUtils.nullSafeIsEmpty(webhookEventTypes))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WebhookEventBuilder webhookEventBuilder = null;
|
||||
for(WebhookEventType webhookEventType : webhookEventTypes)
|
||||
{
|
||||
//////////////////////////////
|
||||
// filter by the event type //
|
||||
//////////////////////////////
|
||||
if(!webhookEventType.getName().equals(eventTypeName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(keyRecord.getTableName() == null)
|
||||
{
|
||||
keyRecord.setTableName(tableName);
|
||||
}
|
||||
|
||||
webhookEventBuilder = FirePostInsertOrUpdateWebhookEventUtil.processSubscriptions(tableName, webhookEventType, keyRecord, webhookEventBuilder, transaction);
|
||||
}
|
||||
|
||||
if(webhookEventBuilder != null)
|
||||
{
|
||||
webhookEventBuilder.storeWebhookEvents(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookSubscription;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** utility for the fire post insert/update events
|
||||
*******************************************************************************/
|
||||
public abstract class FirePostInsertOrUpdateWebhookEventUtil
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(FirePostInsertOrUpdateWebhookEventUtil.class);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static WebhookEventBuilder processSubscriptions(String tableName, WebhookEventType webhookEventType, QRecord record, WebhookEventBuilder webhookEventBuilder, QBackendTransaction transaction)
|
||||
{
|
||||
List<WebhookSubscription> webhookSubscriptions = CollectionUtils.nonNullList(WebhookSubscriptionsHelper.getSubscriptionsForEventType(webhookEventType));
|
||||
for(WebhookSubscription webhookSubscription : webhookSubscriptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(WebhookSubscriptionsHelper.doesRecordMatchSubscription(webhookEventType, webhookSubscription, record, transaction))
|
||||
{
|
||||
LOG.info("Building webhook event", logPair("tableName", tableName), logPair("recordId", record.getValue("id")), logPair("webhookEventType", () -> webhookEventType.getName()), logPair("webhookSubscriptionId", webhookSubscription.getId()));
|
||||
webhookEventBuilder = Objects.requireNonNullElseGet(webhookEventBuilder, WebhookEventBuilder::new);
|
||||
webhookEventBuilder.addWebhookEvent(webhookSubscription, record);
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
LOG.warn("Error processing webhookSubscription", e, logPair("tableName", tableName), logPair("recordId", record.getValue("id")), logPair("webhookEventType", () -> webhookEventType.getName()), logPair("webhookSubscriptionId", webhookSubscription.getId()));
|
||||
}
|
||||
}
|
||||
return webhookEventBuilder;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static QFieldMetaData getQFieldMetaData(WebhookEventType webhookEventType)
|
||||
{
|
||||
QFieldMetaData field = null;
|
||||
if(StringUtils.hasContent(webhookEventType.getFieldName()))
|
||||
{
|
||||
field = QContext.getQInstance().getTable(webhookEventType.getTableName()).getFields().get(webhookEventType.getFieldName());
|
||||
if(field == null)
|
||||
{
|
||||
LOG.warn("No field found for webhook event type", logPair("webhookEventType", webhookEventType), logPair("tableName", webhookEventType.getTableName()), logPair("fieldName", webhookEventType.getFieldName()));
|
||||
}
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
public static Serializable getValue(QRecord record, QFieldMetaData field)
|
||||
{
|
||||
if(field == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
return ValueUtils.getValueAsFieldType(field.getType(), record.getValue(field.getName()));
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static boolean doesNewValueMatchEventValue(WebhookEventType webhookEventType, QFieldMetaData field, Serializable newValue)
|
||||
{
|
||||
Serializable eventValue = webhookEventType.getValue();
|
||||
if(eventValue instanceof Collection<?> collection)
|
||||
{
|
||||
for(Object o : collection)
|
||||
{
|
||||
Serializable eventSubValueAsFieldType = ValueUtils.getValueAsFieldType(field.getType(), o);
|
||||
if(Objects.equals(eventSubValueAsFieldType, newValue))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Serializable eventValueAsFieldType = ValueUtils.getValueAsFieldType(field.getType(), eventValue);
|
||||
if(Objects.equals(eventValueAsFieldType, newValue))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventCategory;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhooksActionFlags;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** post-insert table customizer to fire webhook events
|
||||
*******************************************************************************/
|
||||
public class FirePostInsertWebhookEvent implements TableCustomizerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(FirePostInsertWebhookEvent.class);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsert(InsertInput insertInput, List<QRecord> records) throws QException
|
||||
{
|
||||
if(insertInput.hasFlag(WebhooksActionFlags.OMIT_WEBHOOKS))
|
||||
{
|
||||
LOG.debug("Requested to omit webhooks after inert; returning early.", logPair("tableName", insertInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
List<WebhookEventType> insertWebhookEventTypes = WebhookSubscriptionsHelper.getWebhookEventTypesToConsiderFiringEventsFor(WebhookEventCategory.Kind.INSERT, insertInput.getTableName());
|
||||
List<WebhookEventType> storeWebhookEventTypes = WebhookSubscriptionsHelper.getWebhookEventTypesToConsiderFiringEventsFor(WebhookEventCategory.Kind.STORE, insertInput.getTableName());
|
||||
if(CollectionUtils.nullSafeIsEmpty(insertWebhookEventTypes) && CollectionUtils.nullSafeIsEmpty(storeWebhookEventTypes))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
List<WebhookEventType> webhookEventTypes = new ArrayList<>();
|
||||
CollectionUtils.addAllIfNotNull(webhookEventTypes, insertWebhookEventTypes);
|
||||
CollectionUtils.addAllIfNotNull(webhookEventTypes, storeWebhookEventTypes);
|
||||
|
||||
WebhookEventBuilder webhookEventBuilder = null;
|
||||
for(WebhookEventType webhookEventType : webhookEventTypes)
|
||||
{
|
||||
QFieldMetaData field = FirePostInsertOrUpdateWebhookEventUtil.getQFieldMetaData(webhookEventType);
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(record.getTableName() == null)
|
||||
{
|
||||
record.setTableName(insertInput.getTableName());
|
||||
}
|
||||
|
||||
if(doesRecordMatchWebhookEventType(record, webhookEventType, field))
|
||||
{
|
||||
webhookEventBuilder = FirePostInsertOrUpdateWebhookEventUtil.processSubscriptions(insertInput.getTableName(), webhookEventType, record, webhookEventBuilder, insertInput.getTransaction());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(webhookEventBuilder != null)
|
||||
{
|
||||
webhookEventBuilder.storeWebhookEvents(insertInput.getTransaction());
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
boolean doesRecordMatchWebhookEventType(QRecord record, WebhookEventType webhookEventType, QFieldMetaData field)
|
||||
{
|
||||
switch(webhookEventType.getCategory())
|
||||
{
|
||||
case INSERT, STORE ->
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
case INSERT_WITH_FIELD, STORE_WITH_FIELD ->
|
||||
{
|
||||
Serializable value = FirePostInsertOrUpdateWebhookEventUtil.getValue(record, field);
|
||||
if(value != null && !"".equals(ValueUtils.getValueAsString(value)))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
case INSERT_WITH_VALUE, STORE_WITH_VALUE ->
|
||||
{
|
||||
Serializable value = FirePostInsertOrUpdateWebhookEventUtil.getValue(record, field);
|
||||
|
||||
if(FirePostInsertOrUpdateWebhookEventUtil.doesNewValueMatchEventValue(webhookEventType, field, value))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
default ->
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventCategory;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhooksActionFlags;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.OldRecordHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** post-update table customizer to fire webhook events
|
||||
*******************************************************************************/
|
||||
public class FirePostUpdateWebhookEvent implements TableCustomizerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(FirePostUpdateWebhookEvent.class);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postUpdate(UpdateInput updateInput, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(updateInput.hasFlag(WebhooksActionFlags.OMIT_WEBHOOKS))
|
||||
{
|
||||
LOG.debug("Requested to omit webhooks after update; returning early.", logPair("tableName", updateInput.getTableName()));
|
||||
return (records);
|
||||
}
|
||||
|
||||
List<WebhookEventType> updateWebhookEventTypes = WebhookSubscriptionsHelper.getWebhookEventTypesToConsiderFiringEventsFor(WebhookEventCategory.Kind.UPDATE, updateInput.getTableName());
|
||||
List<WebhookEventType> storeWebhookEventTypes = WebhookSubscriptionsHelper.getWebhookEventTypesToConsiderFiringEventsFor(WebhookEventCategory.Kind.STORE, updateInput.getTableName());
|
||||
if(CollectionUtils.nullSafeIsEmpty(updateWebhookEventTypes) && CollectionUtils.nullSafeIsEmpty(storeWebhookEventTypes))
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
|
||||
List<WebhookEventType> webhookEventTypes = new ArrayList<>();
|
||||
CollectionUtils.addAllIfNotNull(webhookEventTypes, updateWebhookEventTypes);
|
||||
CollectionUtils.addAllIfNotNull(webhookEventTypes, storeWebhookEventTypes);
|
||||
|
||||
OldRecordHelper oldRecordHelper = new OldRecordHelper(updateInput.getTableName(), oldRecordList);
|
||||
|
||||
WebhookEventBuilder webhookEventBuilder = null;
|
||||
for(WebhookEventType webhookEventType : webhookEventTypes)
|
||||
{
|
||||
QFieldMetaData field = FirePostInsertOrUpdateWebhookEventUtil.getQFieldMetaData(webhookEventType);
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(record.getTableName() == null)
|
||||
{
|
||||
record.setTableName(updateInput.getTableName());
|
||||
}
|
||||
|
||||
Optional<QRecord> oldRecord = oldRecordHelper.getOldRecord(record);
|
||||
if(doesRecordMatchWebhookEventType(record, oldRecord, webhookEventType, field))
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// in case the input record is "sparse" (e.g., missing some field values), and we have //
|
||||
// the old record, then make a hybrid of the new + old for processing. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
QRecord recordToProcess = record;
|
||||
if(oldRecord.isPresent())
|
||||
{
|
||||
recordToProcess = new QRecord(record);
|
||||
for(Map.Entry<String, Serializable> entry : oldRecord.get().getValues().entrySet())
|
||||
{
|
||||
if(!recordToProcess.getValues().containsKey(entry.getKey()))
|
||||
{
|
||||
recordToProcess.getValues().put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webhookEventBuilder = FirePostInsertOrUpdateWebhookEventUtil.processSubscriptions(updateInput.getTableName(), webhookEventType, recordToProcess, webhookEventBuilder, updateInput.getTransaction());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(webhookEventBuilder != null)
|
||||
{
|
||||
webhookEventBuilder.storeWebhookEvents(updateInput.getTransaction());
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
boolean doesRecordMatchWebhookEventType(QRecord record, Optional<QRecord> optionalOldRecord, WebhookEventType webhookEventType, QFieldMetaData field)
|
||||
{
|
||||
switch(webhookEventType.getCategory())
|
||||
{
|
||||
case UPDATE, STORE ->
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
case UPDATE_WITH_FIELD, STORE_WITH_FIELD ->
|
||||
{
|
||||
if(optionalOldRecord.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
Serializable newValue = FirePostInsertOrUpdateWebhookEventUtil.getValue(record, field);
|
||||
Serializable oldValue = FirePostInsertOrUpdateWebhookEventUtil.getValue(optionalOldRecord.get(), field);
|
||||
if(!Objects.equals(newValue, oldValue))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
case UPDATE_WITH_VALUE, STORE_WITH_VALUE ->
|
||||
{
|
||||
if(optionalOldRecord.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
Serializable newValue = FirePostInsertOrUpdateWebhookEventUtil.getValue(record, field);
|
||||
Serializable oldValue = FirePostInsertOrUpdateWebhookEventUtil.getValue(optionalOldRecord.get(), field);
|
||||
if(!Objects.equals(newValue, oldValue) && FirePostInsertOrUpdateWebhookEventUtil.doesNewValueMatchEventValue(webhookEventType, field, newValue))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
default ->
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventContent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookSubscription;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.tables.QQQTableTableManager;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** class that build webhook events
|
||||
*******************************************************************************/
|
||||
public class WebhookEventBuilder
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(WebhookEventBuilder.class);
|
||||
|
||||
private List<QRecord> webhookEvents = null;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public void addWebhookEvent(WebhookSubscription subscription, QRecord sourceRecord)
|
||||
{
|
||||
try
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(sourceRecord.getTableName());
|
||||
Integer sourceRecordId = ValueUtils.getValueAsInteger(sourceRecord.getValue(table.getPrimaryKeyField()));
|
||||
|
||||
Integer sourceTableId = QQQTableTableManager.getQQQTableId(QContext.getQInstance(), table.getName());
|
||||
|
||||
WebhookEvent webhookEvent = new WebhookEvent();
|
||||
webhookEvent.setWebhookId(subscription.getWebhookId());
|
||||
webhookEvent.setWebhookSubscriptionId(subscription.getId());
|
||||
webhookEvent.setWebhookEventTypeName(subscription.getWebhookEventTypeName());
|
||||
webhookEvent.setEventStatusId(WebhookEventStatus.NEW.getId());
|
||||
webhookEvent.setEventSourceRecordId(sourceRecordId);
|
||||
webhookEvent.setEventSourceRecordQqqTableId(sourceTableId);
|
||||
|
||||
webhookEvent.setContent(List.of(buildEventContent(sourceRecord, subscription.getWebhookEventTypeName(), subscription.getApiName(), subscription.getApiVersion())));
|
||||
|
||||
QRecord eventRecord = webhookEvent.toQRecord();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// copy security field values from the sourceRecord into the event record //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
QTableMetaData webhookTable = QContext.getQInstance().getTable(Webhook.TABLE_NAME);
|
||||
QBitConfig qbitConfig = webhookTable.getSourceQBitConfig();
|
||||
if(qbitConfig instanceof WebhooksQBitConfig webhooksQBitConfig)
|
||||
{
|
||||
for(QFieldMetaData field : CollectionUtils.nonNullList(webhooksQBitConfig.getSecurityFields()))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// todo - do we need to be more robust re: the field name within the source record? //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
eventRecord.setValue(field.getName(), sourceRecord.getValue(field.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
if(webhookEvents == null)
|
||||
{
|
||||
webhookEvents = new ArrayList<>();
|
||||
}
|
||||
|
||||
webhookEvents.add(eventRecord);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error while creating webhook event", e, logPair("webhookSubscriptionId", () -> subscription.getId()), logPair("sourceRecord", sourceRecord));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
private static WebhookEventContent buildEventContent(QRecord sourceRecord, String webhookEventTypeName, String apiName, String apiVersion) throws QException
|
||||
{
|
||||
WebhookEventType webhookEventType = WebhooksRegistry.ofOrWithNew(QContext.getQInstance()).getWebhookEventType(webhookEventTypeName);
|
||||
|
||||
WebhookEventTypeCustomizerInterface webhookEventTypeCustomizerInterface;
|
||||
if(webhookEventType != null && webhookEventType.getCustomizer() != null)
|
||||
{
|
||||
webhookEventTypeCustomizerInterface = QCodeLoader.getAdHoc(WebhookEventTypeCustomizerInterface.class, webhookEventType.getCustomizer());
|
||||
}
|
||||
else
|
||||
{
|
||||
webhookEventTypeCustomizerInterface = new DefaultWebhookEventTypeCustomizer();
|
||||
}
|
||||
|
||||
return webhookEventTypeCustomizerInterface.buildEventContent(sourceRecord, webhookEventTypeName, apiName, apiVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public void storeWebhookEvents(QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(webhookEvents != null)
|
||||
{
|
||||
new InsertAction().execute(new InsertInput(WebhookEvent.TABLE_NAME)
|
||||
.withRecords(webhookEvents)
|
||||
.withTransaction(transaction));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventContent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventSendLog;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.audits.AuditAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.SleepUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** class that send webhook events to a webhook
|
||||
*******************************************************************************/
|
||||
public class WebhookEventSender
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(WebhookEventSender.class);
|
||||
|
||||
private static Integer maxAllowedAttempts = null;
|
||||
private static Integer minutesToConsiderLeakedSendAttempt = null;
|
||||
private static Integer[] backoffMinutes = null;
|
||||
private static Integer maxAllowedRateLimitErrors = null;
|
||||
private static Integer initialRateLimitBackoffMillis = null;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public boolean handleEvent(WebhookEvent webhookEvent, Webhook webhook, List<WebhookEventSendLog> sendLogs) throws QException
|
||||
{
|
||||
/////////////////////
|
||||
// mark as sending //
|
||||
/////////////////////
|
||||
Instant nextAttemptTimestamp = Instant.now().plus(getMinutesToConsiderLeakedSendAttempt() * 60, ChronoUnit.SECONDS);
|
||||
updateWebhookEvent(webhookEvent.getId(), WebhookEventStatus.SENDING, nextAttemptTimestamp, null);
|
||||
|
||||
/////////////////
|
||||
// try to send //
|
||||
/////////////////
|
||||
WebhookEventSendLog sendLog = post(webhookEvent, webhook);
|
||||
sendLog.setAttemptNo(sendLogs.size() + 1);
|
||||
|
||||
InsertInput insertSendLogInput = new InsertInput(WebhookEventSendLog.TABLE_NAME).withRecordEntity(sendLog);
|
||||
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(insertSendLogInput))
|
||||
{
|
||||
insertSendLogInput.setTransaction(transaction);
|
||||
if(sendLog.getSuccessful())
|
||||
{
|
||||
///////////////////////
|
||||
// mark as delivered //
|
||||
///////////////////////
|
||||
new InsertAction().execute(insertSendLogInput);
|
||||
updateWebhookEvent(webhookEvent.getId(), WebhookEventStatus.DELIVERED, null, transaction);
|
||||
transaction.commit();
|
||||
return (true);
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// consider if this event is now failed, or if it is retryable (based on # of failures) //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
WebhookEventStatus eventStatus = sendLog.getAttemptNo() >= getMaxAllowedAttempts() ? WebhookEventStatus.FAILED : WebhookEventStatus.AWAITING_RETRY;
|
||||
if(eventStatus.equals(WebhookEventStatus.FAILED) && WebhookHealthStatus.PROBATION.getId().equals(webhook.getHealthStatusId()))
|
||||
{
|
||||
String message = "This Webhook Event has had too many failures, but since its Webhook's health status is Probation, its status will be Awaiting Retry instead of Failure.";
|
||||
AuditAction.execute(WebhookEvent.TABLE_NAME, webhookEvent.getId(), null, message);
|
||||
eventStatus = WebhookEventStatus.AWAITING_RETRY;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// set the next-send time based on how many attempts there have been //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
nextAttemptTimestamp = calculateNextAttemptBackoff(sendLog.getAttemptNo());
|
||||
if(eventStatus.equals(WebhookEventStatus.FAILED))
|
||||
{
|
||||
nextAttemptTimestamp = null;
|
||||
}
|
||||
|
||||
new InsertAction().execute(insertSendLogInput);
|
||||
updateWebhookEvent(webhookEvent.getId(), eventStatus, nextAttemptTimestamp, transaction);
|
||||
transaction.commit();
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static Instant calculateNextAttemptBackoff(int attemptNumber)
|
||||
{
|
||||
Integer[] backoffMinutes = getBackoffMinutes();
|
||||
int index = Math.min(attemptNumber - 1, backoffMinutes.length - 1);
|
||||
return Instant.now().plusSeconds(backoffMinutes[index] * 60);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public WebhookEventSendLog post(WebhookEvent webhookEvent, Webhook webhook)
|
||||
{
|
||||
WebhookEventSendLog sendLog = new WebhookEventSendLog();
|
||||
sendLog.setWebhookEventId(webhookEvent.getId());
|
||||
sendLog.setWebhookId(webhook.getId());
|
||||
sendLog.setStartTimestamp(Instant.now());
|
||||
|
||||
try
|
||||
{
|
||||
doPost(webhookEvent, webhook, sendLog);
|
||||
|
||||
//////////////////////////////////////////////////
|
||||
// interpret no exception as meaning successful //
|
||||
//////////////////////////////////////////////////
|
||||
sendLog.setSuccessful(true);
|
||||
}
|
||||
catch(WebhookPostException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// any exception thrown means not successful - capture its error message //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
sendLog.setSuccessful(false);
|
||||
sendLog.setErrorMessage(e.getMessage());
|
||||
}
|
||||
|
||||
sendLog.setEndTimestamp(Instant.now());
|
||||
return (sendLog);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
protected void doPost(WebhookEvent webhookEvent, Webhook webhook, WebhookEventSendLog sendLog) throws WebhookPostException
|
||||
{
|
||||
int rateLimitsCaught = 0;
|
||||
int rateLimitSleepMillis = getInitialRateLimitBackoffMillis();
|
||||
|
||||
///////////////////////////////////
|
||||
// loop in case of rate limiting //
|
||||
///////////////////////////////////
|
||||
while(true)
|
||||
{
|
||||
try(CloseableHttpClient httpClient = buildHttpClient())
|
||||
{
|
||||
HttpPost request = new HttpPost(webhook.getUrl());
|
||||
request.setHeader("Content-Type", "application/json");
|
||||
|
||||
String postBody = getPostBody(webhookEvent);
|
||||
request.setEntity(new StringEntity(postBody, StandardCharsets.UTF_8));
|
||||
|
||||
try(CloseableHttpResponse response = executeHttpRequest(httpClient, request))
|
||||
{
|
||||
int statusCode = response.getStatusLine().getStatusCode();
|
||||
String responseString = EntityUtils.toString(response.getEntity());
|
||||
|
||||
if(statusCode == HttpStatus.SC_TOO_MANY_REQUESTS) // 429
|
||||
{
|
||||
throw (new WebhookRateLimitException(responseString));
|
||||
}
|
||||
|
||||
sendLog.setHttpStatusCode(statusCode);
|
||||
|
||||
if(statusCode < 200 || statusCode >= 300)
|
||||
{
|
||||
if(!StringUtils.hasContent(responseString))
|
||||
{
|
||||
LOG.warn("Unsuccessful http status code, but no response body returned", logPair("statusCode", statusCode), logPair("webhookId", webhook.getId()));
|
||||
responseString = "No response body returned";
|
||||
}
|
||||
throw new WebhookPostException(responseString);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////
|
||||
// successful exit of the rate-limit loop //
|
||||
////////////////////////////////////////////
|
||||
break;
|
||||
}
|
||||
catch(WebhookRateLimitException rle)
|
||||
{
|
||||
rateLimitsCaught++;
|
||||
if(rateLimitsCaught > getMaxAllowedRateLimitErrors())
|
||||
{
|
||||
sendLog.setHttpStatusCode(HttpStatus.SC_TOO_MANY_REQUESTS); // 429
|
||||
LOG.warn("Giving up webhook post after too many rate limit errors", logPair("webhookId", webhook.getId()), logPair("rateLimitsCaught", rateLimitsCaught), logPair("response", rle.getMessage()));
|
||||
throw (new WebhookPostException("Giving up after too many rate-limit errors (" + getMaxAllowedRateLimitErrors() + "). Latest response: " + rle.getMessage()));
|
||||
}
|
||||
|
||||
LOG.info("Caught rate limit. Will sleep and re-try", logPair("rateLimitsCaught", rateLimitsCaught), logPair("webhookId", webhook.getId()), logPair("sleeping", rateLimitSleepMillis));
|
||||
SleepUtils.sleep(rateLimitSleepMillis, TimeUnit.MILLISECONDS);
|
||||
rateLimitSleepMillis *= 2;
|
||||
}
|
||||
catch(WebhookPostException wpe)
|
||||
{
|
||||
throw (wpe);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Exception executing http request", e, logPair("webhookId", webhook.getId()));
|
||||
throw (new WebhookPostException(e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
catch(WebhookPostException wpe)
|
||||
{
|
||||
throw (wpe);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Exception building http client", e, logPair("webhookId", webhook.getId()));
|
||||
throw (new WebhookPostException(e.getMessage(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
protected String getPostBody(WebhookEvent webhookEvent) throws QException
|
||||
{
|
||||
String postBody = null;
|
||||
if(CollectionUtils.nullSafeIsEmpty(webhookEvent.getContent()))
|
||||
{
|
||||
List<QRecord> contentRecords = QueryAction.execute(WebhookEventContent.TABLE_NAME, new QQueryFilter(new QFilterCriteria("webhookEventId", QCriteriaOperator.EQUALS, webhookEvent.getId())));
|
||||
if(CollectionUtils.nullSafeHasContents(contentRecords))
|
||||
{
|
||||
postBody = contentRecords.get(0).getValueString("postBody");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
postBody = webhookEvent.getContent().get(0).getPostBody();
|
||||
}
|
||||
|
||||
if(!StringUtils.hasContent(postBody))
|
||||
{
|
||||
throw (new QException("Missing content/postBody for webhook event: " + webhookEvent.getId()));
|
||||
}
|
||||
|
||||
return postBody;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
protected CloseableHttpResponse executeHttpRequest(CloseableHttpClient httpClient, HttpPost request) throws IOException
|
||||
{
|
||||
return httpClient.execute(request);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the default HttpClient used by the makeRequest method
|
||||
*******************************************************************************/
|
||||
protected CloseableHttpClient buildHttpClient()
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// do we want this?? .setConnectionManager(new PoolingHttpClientConnectionManager()) //
|
||||
// needs some good scrutiny. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
return HttpClientBuilder.create()
|
||||
.setDefaultRequestConfig(RequestConfig.custom()
|
||||
.setConnectTimeout(5000)
|
||||
.setConnectionRequestTimeout(5000)
|
||||
.setSocketTimeout(5000).build())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
private void updateWebhookEvent(Integer id, WebhookEventStatus status, Instant nextAttemptTimestamp, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput(WebhookEvent.TABLE_NAME)
|
||||
.withTransaction(transaction)
|
||||
.withRecord(new QRecord()
|
||||
.withValue("id", id)
|
||||
.withValue("nextAttemptTimestamp", nextAttemptTimestamp)
|
||||
.withValue("eventStatusId", status.getId())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
protected int getMaxAllowedRateLimitErrors()
|
||||
{
|
||||
if(maxAllowedRateLimitErrors == null)
|
||||
{
|
||||
maxAllowedRateLimitErrors = WebhooksQBitConfig.getConfigValue(config -> config.getMaxRateLimitRetries());
|
||||
}
|
||||
return (maxAllowedRateLimitErrors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
protected int getInitialRateLimitBackoffMillis()
|
||||
{
|
||||
if(initialRateLimitBackoffMillis == null)
|
||||
{
|
||||
initialRateLimitBackoffMillis = WebhooksQBitConfig.getConfigValue(config -> config.getInitialRateLimitBackoffMillis());
|
||||
}
|
||||
return (initialRateLimitBackoffMillis);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
private static Integer getMaxAllowedAttempts()
|
||||
{
|
||||
if(maxAllowedAttempts == null)
|
||||
{
|
||||
maxAllowedAttempts = WebhooksQBitConfig.getConfigValue(config -> config.getMaxSendAttemptsBeforeFailure());
|
||||
}
|
||||
return (maxAllowedAttempts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
private static Integer getMinutesToConsiderLeakedSendAttempt()
|
||||
{
|
||||
if(minutesToConsiderLeakedSendAttempt == null)
|
||||
{
|
||||
minutesToConsiderLeakedSendAttempt = WebhooksQBitConfig.getConfigValue(config -> config.getMinutesToConsiderLeakedSendAttempt());
|
||||
}
|
||||
return (minutesToConsiderLeakedSendAttempt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
private static Integer[] getBackoffMinutes()
|
||||
{
|
||||
if(backoffMinutes == null)
|
||||
{
|
||||
List<Integer> configValue = WebhooksQBitConfig.getConfigValue(config -> config.getMinutesBetweenRetryAttempts());
|
||||
backoffMinutes = configValue.toArray(new Integer[0]);
|
||||
}
|
||||
return (backoffMinutes);
|
||||
}
|
||||
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventContent;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qqq.api.actions.QRecordApiAdapter;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** interface for doing work for a webhookEventType - where specific types can
|
||||
** customize behavior.
|
||||
*******************************************************************************/
|
||||
public interface WebhookEventTypeCustomizerInterface
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
default WebhookEventContent buildEventContent(QRecord sourceRecord, String webhookEventTypeName, String apiName, String apiVersion) throws QException
|
||||
{
|
||||
WebhookEventType webhookEventType = WebhooksRegistry.of(QContext.getQInstance()).getWebhookEventType(webhookEventTypeName);
|
||||
|
||||
Map<String, Object> postBody = new LinkedHashMap<>();
|
||||
|
||||
Map<String, Object> webhookEventDetails = new LinkedHashMap<>();
|
||||
postBody.put("webhookEventDetails", webhookEventDetails);
|
||||
|
||||
webhookEventDetails.put("webhookEventTypeName", webhookEventTypeName);
|
||||
webhookEventDetails.put("eventTimestamp", Instant.now().toString());
|
||||
|
||||
if(webhookEventType != null && StringUtils.hasContent(webhookEventType.getTableName()))
|
||||
{
|
||||
webhookEventDetails.put("tableName", webhookEventType.getTableName());
|
||||
}
|
||||
|
||||
webhookEventDetails.put("apiName", apiName);
|
||||
webhookEventDetails.put("apiVersion", apiVersion);
|
||||
|
||||
Map<String, Serializable> apiRecord = QRecordApiAdapter.qRecordToApiMap(sourceRecord, sourceRecord.getTableName(), apiName, apiVersion);
|
||||
postBody.put("record", apiRecord);
|
||||
|
||||
customizeEventContent(sourceRecord, webhookEventTypeName, apiName, apiVersion, postBody);
|
||||
|
||||
String json = JsonUtils.toJson(postBody, mapper ->
|
||||
mapper.setSerializationInclusion(JsonInclude.Include.ALWAYS));
|
||||
|
||||
return new WebhookEventContent().withPostBody(json);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
default void customizeEventContent(QRecord sourceRecord, String webhookEventTypeName, String apiName, String apiVersion, Map<String, Object> postBody) throws QException
|
||||
{
|
||||
/////////////////////
|
||||
// noop by default //
|
||||
/////////////////////
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.util.Stack;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventSendLog;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.scheduler.processes.AbstractRecordSyncToScheduledJobProcess;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** class that manages webhook health status
|
||||
*******************************************************************************/
|
||||
public class WebhookHealthManager
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(WebhookHealthManager.class);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public Webhook updateWebhookHealth(Stack<WebhookEventSendLog> recentSendLogs, Webhook webhook) throws QException
|
||||
{
|
||||
Integer repeatedFailsToGoUnhealthy = WebhooksQBitConfig.getConfigValue(config -> config.getRepeatedFailsToGoUnhealthy());
|
||||
boolean lastSuccessful = !recentSendLogs.isEmpty() && BooleanUtils.isTrue(recentSendLogs.peek().getSuccessful());
|
||||
if(lastSuccessful)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// if the last send was successful, and the webhook had been on //
|
||||
// probation, then take it off probation and mark it as healthy //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
if(WebhookHealthStatus.PROBATION.getId().equals(webhook.getHealthStatusId()))
|
||||
{
|
||||
LOG.info("Marking previously-probation webhook as healthy due to a success", logPair("id", webhook.getId()));
|
||||
webhook = updateWebhookHealthStatus(webhook, WebhookHealthStatus.HEALTHY);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
/////////////////////////////////////////
|
||||
// else the last send was a failure... //
|
||||
/////////////////////////////////////////
|
||||
if(WebhookHealthStatus.PROBATION.getId().equals(webhook.getHealthStatusId()))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// if the webhook was on probation, put it back on probation. //
|
||||
////////////////////////////////////////////////////////////////
|
||||
LOG.info("Marking previously-probation webhook as unhealthy due to a failure", logPair("id", webhook.getId()));
|
||||
webhook = updateWebhookHealthStatus(webhook, WebhookHealthStatus.UNHEALTHY);
|
||||
}
|
||||
else if(WebhookHealthStatus.HEALTHY.getId().equals(webhook.getHealthStatusId()))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the webhook was healthy - mark it as unhealthy if there have been enough failures in-a-row //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(repeatedFailsToGoUnhealthy != null && recentSendLogs.size() >= repeatedFailsToGoUnhealthy
|
||||
&& recentSendLogs.stream().noneMatch(sendLog -> BooleanUtils.isTrue(sendLog.getSuccessful())))
|
||||
{
|
||||
LOG.info("Marking previously-healthy webhook as unhealthy due to repeated failures", logPair("id", webhook.getId()), logPair("repeatedFailLimit", repeatedFailsToGoUnhealthy));
|
||||
webhook = updateWebhookHealthStatus(webhook, WebhookHealthStatus.UNHEALTHY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (webhook);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
private Webhook updateWebhookHealthStatus(Webhook webhook, WebhookHealthStatus status) throws QException
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput(Webhook.TABLE_NAME)
|
||||
.withFlag(AbstractRecordSyncToScheduledJobProcess.ActionFlags.DO_NOT_SYNC)
|
||||
.withRecord(new QRecord()
|
||||
.withValue("id", webhook.getId())
|
||||
.withValue("healthStatusId", status.getId())));
|
||||
|
||||
webhook.setHealthStatusId(status.getId());
|
||||
return (webhook);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** exception thrown while posting to a webhook
|
||||
*******************************************************************************/
|
||||
public class WebhookPostException extends QException
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public WebhookPostException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public WebhookPostException(String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class WebhookRateLimitException extends Exception
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
**
|
||||
*******************************************************************************/
|
||||
public WebhookRateLimitException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.actions;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventCategory;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookSubscription;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.memoization.Memoization;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** helper class for working with webhook subscriptions
|
||||
*******************************************************************************/
|
||||
public class WebhookSubscriptionsHelper
|
||||
{
|
||||
private static final Memoization<String, List<WebhookSubscription>> subscriptionsByEventTypeMemoization = new Memoization<String, List<WebhookSubscription>>()
|
||||
.withTimeout(Duration.of(WebhooksQBitConfig.getConfigValue(config -> config.getMemoizationTimeoutMinutes()).longValue(), ChronoUnit.MINUTES));
|
||||
|
||||
private static final Memoization<Integer, QRecord> webhookBySubscriptionIdMemoization = new Memoization<Integer, QRecord>()
|
||||
.withTimeout(Duration.of(WebhooksQBitConfig.getConfigValue(config -> config.getMemoizationTimeoutMinutes()).longValue(), ChronoUnit.MINUTES));
|
||||
|
||||
private static List<String> allAccessSecurityKeysToUse = null;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static void clearMemoizations()
|
||||
{
|
||||
subscriptionsByEventTypeMemoization.clear();
|
||||
webhookBySubscriptionIdMemoization.clear();
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static List<WebhookEventType> getWebhookEventTypesToConsiderFiringEventsFor(WebhookEventCategory.Kind kind, String tableName)
|
||||
{
|
||||
List<WebhookEventType> eventTypes = null;
|
||||
for(WebhookEventType webhookEventType : WebhooksRegistry.ofOrWithNew(QContext.getQInstance()).getAllWebhookEventTypes())
|
||||
{
|
||||
if(webhookEventType.getCategory().getKind().equals(kind))
|
||||
{
|
||||
if(!StringUtils.hasContent(webhookEventType.getTableName()) || Objects.equals(webhookEventType.getTableName(), tableName))
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(getSubscriptionsForEventType(webhookEventType)))
|
||||
{
|
||||
eventTypes = Objects.requireNonNullElseGet(eventTypes, ArrayList::new);
|
||||
eventTypes.add(webhookEventType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (eventTypes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static List<WebhookSubscription> getSubscriptionsForEventType(WebhookEventType webhookEventType)
|
||||
{
|
||||
return subscriptionsByEventTypeMemoization.getResultThrowing(webhookEventType.getName(), name ->
|
||||
{
|
||||
try
|
||||
{
|
||||
return QueryAction.execute(WebhookSubscription.TABLE_NAME, WebhookSubscription.class, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("webhookEventTypeName", QCriteriaOperator.EQUALS, name))
|
||||
.withCriteria(new QFilterCriteria("activeStatusId", QCriteriaOperator.NOT_EQUALS, WebhookActiveStatus.DISABLED.getId()))
|
||||
);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}).orElse(Collections.emptyList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static QRecord getWebhookForSubscription(WebhookSubscription webhookSubscription)
|
||||
{
|
||||
return webhookBySubscriptionIdMemoization.getResultThrowing(webhookSubscription.getId(), subscriptionId ->
|
||||
{
|
||||
try
|
||||
{
|
||||
List<QRecord> webhooks = new QueryAction().execute(new QueryInput(Webhook.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria(WebhookSubscription.TABLE_NAME + ".id", QCriteriaOperator.EQUALS, subscriptionId)))
|
||||
.withQueryJoin(new QueryJoin(WebhookSubscription.TABLE_NAME)))
|
||||
.getRecords();
|
||||
if(webhooks.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (webhooks.get(0));
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}).orElse(null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static List<String> getAllAccessSecurityKeysToUse()
|
||||
{
|
||||
if(allAccessSecurityKeysToUse == null)
|
||||
{
|
||||
List<String> list = new ArrayList<>();
|
||||
|
||||
Map<String, String> securityKeyTypeToAllAccessKeyNameMap = new HashMap<>();
|
||||
for(QSecurityKeyType securityKeyType : QContext.getQInstance().getSecurityKeyTypes().values())
|
||||
{
|
||||
if(StringUtils.hasContent(securityKeyType.getAllAccessKeyName()))
|
||||
{
|
||||
securityKeyTypeToAllAccessKeyNameMap.put(securityKeyType.getName(), securityKeyType.getAllAccessKeyName());
|
||||
}
|
||||
}
|
||||
|
||||
QTableMetaData webhookTable = QContext.getQInstance().getTable(Webhook.TABLE_NAME);
|
||||
QBitConfig qbitConfig = webhookTable.getSourceQBitConfig();
|
||||
if(qbitConfig instanceof WebhooksQBitConfig webhooksQBitConfig)
|
||||
{
|
||||
for(RecordSecurityLock securityLock : CollectionUtils.nonNullList(webhooksQBitConfig.getRecordSecurityLocks()))
|
||||
{
|
||||
securityKeyTypeToAllAccessKeyNameMap.remove(securityLock.getSecurityKeyType());
|
||||
}
|
||||
|
||||
list.addAll(securityKeyTypeToAllAccessKeyNameMap.values());
|
||||
}
|
||||
|
||||
allAccessSecurityKeysToUse = list;
|
||||
}
|
||||
|
||||
return (allAccessSecurityKeysToUse);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static boolean doesRecordMatchSubscription(WebhookEventType webhookEventType, WebhookSubscription webhookSubscription, QRecord record, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
String tableName = record.getTableName();
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
|
||||
List<RecordSecurityLock> recordSecurityLocks = table.getRecordSecurityLocks();
|
||||
if(CollectionUtils.nullSafeIsEmpty(recordSecurityLocks))
|
||||
{
|
||||
////////////////////////////////////////////
|
||||
// if no locks on the table, always allow //
|
||||
////////////////////////////////////////////
|
||||
return (true);
|
||||
}
|
||||
|
||||
QRecord webhookRecord = getWebhookForSubscription(webhookSubscription);
|
||||
QTableMetaData webhookTable = QContext.getQInstance().getTable(Webhook.TABLE_NAME);
|
||||
QBitConfig qbitConfig = webhookTable.getSourceQBitConfig();
|
||||
QSession pseudoSession = new QSession();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ask the qbit config what security locks are in place - e.g., values to copy from the webhook //
|
||||
// record into a pseudo-session, to use for checking if the webhook is allowed to see the record. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(qbitConfig instanceof WebhooksQBitConfig webhooksQBitConfig)
|
||||
{
|
||||
for(RecordSecurityLock securityLock : CollectionUtils.nonNullList(webhooksQBitConfig.getRecordSecurityLocks()))
|
||||
{
|
||||
Serializable securityFieldValue = webhookRecord.getValue(securityLock.getFieldName());
|
||||
pseudoSession.withSecurityKeyValue(securityLock.getSecurityKeyType(), securityFieldValue);
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// if there are no security locks on the qbit, always allow //
|
||||
//////////////////////////////////////////////////////////////
|
||||
if(pseudoSession.getSecurityKeyValues().isEmpty())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// any keys in the instance that aren't used by the webhook qbit, if there's an all-access key, //
|
||||
// then use it - to avoid records being blocked by a non-applicable lock //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
for(String allAccessKey : getAllAccessSecurityKeysToUse())
|
||||
{
|
||||
pseudoSession.withSecurityKeyValue(allAccessKey, true);
|
||||
}
|
||||
|
||||
return (ValidateRecordSecurityLockHelper.allowedToReadRecord(table, record, pseudoSession, transaction));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.app;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.health.WebhooksHealthWidget;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.SecretSource;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.SecretStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.permissions.WebhooksPermissionChecker;
|
||||
import com.kingsrook.qbits.webhooks.permissions.WebhooksPermissions;
|
||||
import com.kingsrook.qbits.webhooks.secrets.SecretCipher;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.UseTablePermissionCustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Webhooks app: its home dashboard, and the main tables in the nav
|
||||
** (Endpoints, Subscriptions, Deliveries). Signing secrets and delivery
|
||||
** attempts are relations on their parent records. No process is in the nav.
|
||||
*******************************************************************************/
|
||||
public final class WebhooksApp
|
||||
{
|
||||
public static final String HEALTH_WIDGET = "webhooksHealth";
|
||||
public static final String HOME_WIDGET = "webhooksHome";
|
||||
public static final String SECRET_VALUE_WIDGET = "webhooksSecretValue";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksApp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The widgets (home and secret value).
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(
|
||||
new QWidgetMetaData().withName(HEALTH_WIDGET).withLabel("System Health").withType(WidgetType.STATISTICS.getType())
|
||||
.withCodeReference(new QCodeReference(WebhooksHealthWidget.class)).withGridColumns(12)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withCustomPermissionChecker(UseTablePermissionCustomPermissionChecker.build(HealthMetaDataProvider.CHECK_TABLE, TablePermissionSubType.READ))),
|
||||
new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
|
||||
.withPermissionRules(WebhooksTables.permissionRules()),
|
||||
new QWidgetMetaData().withName(SECRET_VALUE_WIDGET).withLabel("Secret Value").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(SecretValueRenderer.class)).withGridColumns(12).withIsCard(true)
|
||||
.withPermissionRules(WebhooksTables.permissionRules())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The app, with the main tables as children.
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData app(WebhooksQBitConfig config, Map<String, QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(config.getAppName())
|
||||
.withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("webhook"))
|
||||
.withPermissionRules(WebhooksTables.permissionRules())
|
||||
.withWidgets(List.of(HEALTH_WIDGET, HOME_WIDGET));
|
||||
for(String name : WebhooksTables.MAIN)
|
||||
{
|
||||
if(tables.containsKey(name))
|
||||
{
|
||||
app.withChild(tables.get(name));
|
||||
}
|
||||
}
|
||||
return (app);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Home renderer.
|
||||
*******************************************************************************/
|
||||
public static class HomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
return (new RenderWidgetOutput(new RawHTML(null, WebhooksHomeView.render(WebhooksHomeData.load(), config.getTablePathPrefix()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The value of a generated signing secret, for webhook admins only, behind
|
||||
** a "Show" toggle. It is decrypted on request and never stored elsewhere.
|
||||
*******************************************************************************/
|
||||
public static class SecretValueRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, html(input.getQueryParams()))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The HTML for the secret in the "id" parameter.
|
||||
*******************************************************************************/
|
||||
static String html(Map<String, String> params) throws QException
|
||||
{
|
||||
if(!WebhooksPermissionChecker.has(WebhooksPermissions.ADMIN))
|
||||
{
|
||||
return ("<p>Only webhook administrators can see secret values.</p>");
|
||||
}
|
||||
Serializable id = params == null ? null : params.get("id");
|
||||
if(id == null)
|
||||
{
|
||||
return ("<p>Open a signing secret to see its value.</p>");
|
||||
}
|
||||
List<QRecord> rows = SystemWrites.query(WebhooksTables.SECRET, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id)));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
return ("<p>Not found.</p>");
|
||||
}
|
||||
QRecord row = rows.get(0);
|
||||
if(SecretStatus.RETIRED.name().equals(row.getValueString("status")))
|
||||
{
|
||||
return ("<p>This secret is retired and no longer signs anything.</p>");
|
||||
}
|
||||
if(SecretSource.REFERENCE.name().equals(row.getValueString("source")))
|
||||
{
|
||||
return ("<p>The value lives in the environment variable <code>" + WebhooksHomeView.esc(row.getValueString("secretRef"))
|
||||
+ "</code> and is not shown here. Give the receiver the same value.</p>");
|
||||
}
|
||||
SecretCipher cipher = SecretCipher.fromEnvironment(WebhooksRuntime.config().getEncryptionKeyEnvName());
|
||||
if(cipher == null)
|
||||
{
|
||||
return ("<p>The encryption key is not set in this environment, so the value cannot be shown.</p>");
|
||||
}
|
||||
String value = cipher.decrypt(row.getValueString("encryptedSecret"), row.getValueInteger("endpointId"));
|
||||
return ("<details><summary>Show the secret</summary><p style=\"overflow-wrap:anywhere\"><code>" + WebhooksHomeView.esc(value) + "</code></p></details>"
|
||||
+ "<p style=\"opacity:.75\">Give this to the receiver to verify the webhook-signature header. Treat it like a password.</p>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.app;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.AttemptOutcome;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.DeliveryStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The numbers on the Webhooks home, read as the viewer (so record security
|
||||
** applies), from the qbit's own tables only. Nothing is estimated or made up:
|
||||
** with no data, the home says so.
|
||||
*******************************************************************************/
|
||||
public final class WebhooksHomeData
|
||||
{
|
||||
private static final int ATTEMPT_SAMPLE = 20000;
|
||||
|
||||
private Instant now;
|
||||
private int delivered24h;
|
||||
private int failedAttempts24h;
|
||||
private int rateLimited24h;
|
||||
private int deadLetters;
|
||||
private int backlog;
|
||||
private Long p50Millis;
|
||||
private Long p95Millis;
|
||||
private final int[] hourlyOk = new int[24];
|
||||
private final int[] hourlyFailed = new int[24];
|
||||
private final List<QRecord> recentDeadLetters = new ArrayList<>();
|
||||
private List<WebhooksSetupStatus.Step> setup = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load.
|
||||
*******************************************************************************/
|
||||
public static WebhooksHomeData load() throws QException
|
||||
{
|
||||
WebhooksHomeData data = new WebhooksHomeData();
|
||||
data.now = WebhooksRuntime.clock().instant();
|
||||
Instant since = data.now.minus(Duration.ofHours(24));
|
||||
|
||||
data.delivered24h = count(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("deliveredDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since)));
|
||||
data.deadLetters = count(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, DeliveryStatus.DEAD_LETTER.name())));
|
||||
data.backlog = count(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN,
|
||||
DeliveryStatus.PENDING.name(), DeliveryStatus.RETRY_WAIT.name(), DeliveryStatus.SENDING.name())));
|
||||
|
||||
List<Long> latencies = new ArrayList<>();
|
||||
Instant hourZero = data.now.truncatedTo(ChronoUnit.HOURS).minus(Duration.ofHours(23));
|
||||
for(QRecord attempt : query(WebhooksTables.ATTEMPT, new QQueryFilter(new QFilterCriteria("startDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since))
|
||||
.withLimit(ATTEMPT_SAMPLE)))
|
||||
{
|
||||
String outcome = attempt.getValueString("outcome");
|
||||
Instant start = ValueUtils.getValueAsInstant(attempt.getValue("startDate"));
|
||||
int bucket = start == null ? -1 : (int) Duration.between(hourZero, start.truncatedTo(ChronoUnit.HOURS)).toHours();
|
||||
boolean inRange = bucket >= 0 && bucket < 24;
|
||||
if(AttemptOutcome.SUCCEEDED.name().equals(outcome))
|
||||
{
|
||||
Integer millis = attempt.getValueInteger("durationMillis");
|
||||
if(millis != null)
|
||||
{
|
||||
latencies.add(millis.longValue());
|
||||
}
|
||||
if(inRange)
|
||||
{
|
||||
data.hourlyOk[bucket]++;
|
||||
}
|
||||
}
|
||||
else if(AttemptOutcome.RATE_LIMITED.name().equals(outcome))
|
||||
{
|
||||
data.rateLimited24h++;
|
||||
}
|
||||
else if(!AttemptOutcome.IN_FLIGHT.name().equals(outcome))
|
||||
{
|
||||
data.failedAttempts24h++;
|
||||
if(inRange)
|
||||
{
|
||||
data.hourlyFailed[bucket]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!latencies.isEmpty())
|
||||
{
|
||||
Collections.sort(latencies);
|
||||
data.p50Millis = latencies.get((int) Math.floor((latencies.size() - 1) * 0.50));
|
||||
data.p95Millis = latencies.get((int) Math.floor((latencies.size() - 1) * 0.95));
|
||||
}
|
||||
|
||||
data.recentDeadLetters.addAll(query(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.EQUALS, DeliveryStatus.DEAD_LETTER.name()))
|
||||
.withOrderBy(new QFilterOrderBy("id", false)).withLimit(5)));
|
||||
data.setup = WebhooksSetupStatus.load();
|
||||
return (data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query as the viewer.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count as the viewer.
|
||||
*******************************************************************************/
|
||||
private static int count(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
Integer count = new CountAction().execute(new CountInput(table).withFilter(filter)).getCount();
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for now.
|
||||
*******************************************************************************/
|
||||
public Instant getNow()
|
||||
{
|
||||
return (now);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for delivered24h.
|
||||
*******************************************************************************/
|
||||
public int getDelivered24h()
|
||||
{
|
||||
return (delivered24h);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for failedAttempts24h.
|
||||
*******************************************************************************/
|
||||
public int getFailedAttempts24h()
|
||||
{
|
||||
return (failedAttempts24h);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rateLimited24h.
|
||||
*******************************************************************************/
|
||||
public int getRateLimited24h()
|
||||
{
|
||||
return (rateLimited24h);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for deadLetters.
|
||||
*******************************************************************************/
|
||||
public int getDeadLetters()
|
||||
{
|
||||
return (deadLetters);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backlog.
|
||||
*******************************************************************************/
|
||||
public int getBacklog()
|
||||
{
|
||||
return (backlog);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for p50Millis (null with no successful attempts).
|
||||
*******************************************************************************/
|
||||
public Long getP50Millis()
|
||||
{
|
||||
return (p50Millis);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for p95Millis.
|
||||
*******************************************************************************/
|
||||
public Long getP95Millis()
|
||||
{
|
||||
return (p95Millis);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Successful attempts per hour, oldest first (24 hours ending now).
|
||||
*******************************************************************************/
|
||||
public int[] getHourlyOk()
|
||||
{
|
||||
return (hourlyOk.clone());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed attempts per hour.
|
||||
*******************************************************************************/
|
||||
public int[] getHourlyFailed()
|
||||
{
|
||||
return (hourlyFailed.clone());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The five newest dead letters.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> getRecentDeadLetters()
|
||||
{
|
||||
return (recentDeadLetters);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The setup steps (state and next step of each thing an admin configures).
|
||||
*******************************************************************************/
|
||||
public List<WebhooksSetupStatus.Step> getSetup()
|
||||
{
|
||||
return (setup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.app;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renders the Webhooks home as HTML for an HTML widget.
|
||||
**
|
||||
** <p>Next UI's sanitizer removes {@code <style>} elements, so everything is
|
||||
** inline styles. Layout rules (owner, 2026-10-11):</p>
|
||||
** <ul>
|
||||
** <li>one page scroll: nothing scrolls inside the widget;</li>
|
||||
** <li>no horizontal scroll at any width;</li>
|
||||
** <li>list rows are cards whose labelled cells wrap by themselves
|
||||
** ({@code auto-fit} grids with {@code min(100%, ...)}), so they read
|
||||
** at 360 px and at 3200 px without media queries;</li>
|
||||
** <li>dates compact and on one line, plain labels;</li>
|
||||
** <li>colours from the current text colour with opacity, plus a green and
|
||||
** a red that read on light and dark backgrounds.</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksHomeView
|
||||
{
|
||||
private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("MMM d, HH:mm 'UTC'", Locale.ENGLISH).withZone(ZoneOffset.UTC);
|
||||
|
||||
private static final String OK_COLOR = "#2e9e5b";
|
||||
private static final String FAIL_COLOR = "#d64545";
|
||||
|
||||
private static final String HOME = "display:flex;flex-direction:column;gap:1rem;min-width:0;font-size:.95rem;line-height:1.4;";
|
||||
private static final String KPIS = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,9.5rem),1fr));gap:.75rem;";
|
||||
private static final String KPI = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:.75rem 1rem;min-width:0;";
|
||||
private static final String VALUE = "font-size:1.6rem;font-weight:600;line-height:1.2;overflow-wrap:anywhere;";
|
||||
private static final String LABEL = "opacity:.75;font-size:.85rem;";
|
||||
private static final String CARD = "border:1px solid rgba(127,127,127,.3);border-radius:8px;padding:1rem;min-width:0;";
|
||||
private static final String TITLE = "margin:0 0 .75rem;font-size:1rem;font-weight:600;";
|
||||
private static final String MUTED = "opacity:.75;margin:0;";
|
||||
private static final String ROW = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr));gap:.25rem 1rem;padding:.6rem 0;"
|
||||
+ "border-top:1px solid rgba(127,127,127,.2);align-items:start;";
|
||||
private static final String CELL = "min-width:0;overflow-wrap:anywhere;";
|
||||
private static final String CAP = "display:block;font-size:.75rem;font-weight:600;opacity:.65;";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksHomeView()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home.
|
||||
*******************************************************************************/
|
||||
public static String render(WebhooksHomeData data, String tablePrefix)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div style=\"" + HOME + "\">");
|
||||
setup(html, data);
|
||||
kpis(html, data, tablePrefix);
|
||||
volume(html, data);
|
||||
deadLetters(html, data, tablePrefix);
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each thing to configure, its state and the next step.
|
||||
*******************************************************************************/
|
||||
private static void setup(StringBuilder html, WebhooksHomeData data)
|
||||
{
|
||||
html.append("<div style=\"").append(CARD).append("\"><h3 style=\"").append(TITLE).append("\">Setup</h3>");
|
||||
for(WebhooksSetupStatus.Step step : data.getSetup())
|
||||
{
|
||||
String label = step.href() == null ? esc(step.label()) : "<a href=\"" + esc(step.href()) + "\">" + esc(step.label()) + "</a>";
|
||||
String state = WebhooksSetupStatus.NEEDS_SETUP.equals(step.state()) ? "<span style=\"color:" + FAIL_COLOR + "\">" + esc(step.state()) + "</span>"
|
||||
: WebhooksSetupStatus.SET_UP.equals(step.state()) ? "<span style=\"color:" + OK_COLOR + "\">" + esc(step.state()) + "</span>" : esc(step.state());
|
||||
html.append("<div style=\"").append(ROW).append("\">").append(cell("Step", label)).append(cell("State", state)).append(cell("Next step", esc(step.next()))).append("</div>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The KPI tiles.
|
||||
*******************************************************************************/
|
||||
private static void kpis(StringBuilder html, WebhooksHomeData data, String prefix)
|
||||
{
|
||||
html.append("<div style=\"").append(KPIS).append("\">");
|
||||
kpi(html, String.valueOf(data.getDelivered24h()), "Delivered (24 h)", false, prefix + "/" + WebhooksTables.DELIVERY);
|
||||
kpi(html, String.valueOf(data.getFailedAttempts24h()), "Failed attempts (24 h)", data.getFailedAttempts24h() > 0, prefix + "/" + WebhooksTables.DELIVERY);
|
||||
kpi(html, String.valueOf(data.getDeadLetters()), "Dead letters", data.getDeadLetters() > 0, prefix + "/" + WebhooksTables.DELIVERY);
|
||||
kpi(html, String.valueOf(data.getBacklog()), "Waiting to send", false, prefix + "/" + WebhooksTables.DELIVERY);
|
||||
kpi(html, data.getP50Millis() == null ? "None yet" : data.getP50Millis() + " / " + data.getP95Millis() + " ms", "Latency, median / 95th (24 h)", false, null);
|
||||
html.append("</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One KPI tile.
|
||||
*******************************************************************************/
|
||||
private static void kpi(StringBuilder html, String value, String label, boolean bad, String href)
|
||||
{
|
||||
String valueStyle = VALUE + (bad ? "color:" + FAIL_COLOR + ";" : "");
|
||||
html.append("<div style=\"").append(KPI).append("\"><div style=\"").append(valueStyle).append("\">");
|
||||
if(href != null)
|
||||
{
|
||||
html.append("<a href=\"").append(esc(href)).append("\" style=\"color:inherit;text-decoration:none\">").append(esc(value)).append("</a>");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append(esc(value));
|
||||
}
|
||||
html.append("</div><div style=\"").append(LABEL).append("\">").append(esc(label)).append("</div></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Attempts per hour, last 24 hours.
|
||||
*******************************************************************************/
|
||||
private static void volume(StringBuilder html, WebhooksHomeData data)
|
||||
{
|
||||
int[] ok = data.getHourlyOk();
|
||||
int[] failed = data.getHourlyFailed();
|
||||
int max = 0;
|
||||
int total = 0;
|
||||
for(int i = 0; i < 24; i++)
|
||||
{
|
||||
max = Math.max(max, ok[i] + failed[i]);
|
||||
total += ok[i] + failed[i];
|
||||
}
|
||||
html.append("<div style=\"").append(CARD).append("\"><h3 style=\"").append(TITLE).append("\">Delivery attempts, last 24 hours</h3>");
|
||||
if(total == 0)
|
||||
{
|
||||
html.append("<p style=\"").append(MUTED).append("\">No delivery attempts in the last 24 hours.</p></div>");
|
||||
return;
|
||||
}
|
||||
html.append("<div role=\"img\" aria-label=\"").append(total).append(" attempts in the last 24 hours, by hour\" style=\"display:flex;align-items:flex-end;gap:2px;height:7rem;width:100%\">");
|
||||
for(int i = 0; i < 24; i++)
|
||||
{
|
||||
html.append("<div title=\"").append(ok[i]).append(" succeeded, ").append(failed[i]).append(" failed\" style=\"flex:1 1 0;min-width:0;height:100%;display:flex;flex-direction:column-reverse\">")
|
||||
.append("<div style=\"background:").append(OK_COLOR).append(";height:").append(pct(ok[i], max)).append("%\"></div>")
|
||||
.append("<div style=\"background:").append(FAIL_COLOR).append(";height:").append(pct(failed[i], max)).append("%\"></div></div>");
|
||||
}
|
||||
html.append("</div><div style=\"display:flex;justify-content:space-between;font-size:.75rem;opacity:.7;margin-top:.25rem\"><span>24 h ago</span><span>Now</span></div>")
|
||||
.append("<div style=\"display:flex;gap:1rem;flex-wrap:wrap;font-size:.8rem;margin-top:.5rem\">")
|
||||
.append(legend(OK_COLOR, "Succeeded")).append(legend(FAIL_COLOR, "Failed"))
|
||||
.append("<span style=\"opacity:.75\">Peak ").append(max).append(" per hour; ").append(data.getRateLimited24h()).append(" rate-limited</span></div></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A legend entry.
|
||||
*******************************************************************************/
|
||||
private static String legend(String color, String label)
|
||||
{
|
||||
return ("<span><span style=\"display:inline-block;width:.7rem;height:.7rem;border-radius:2px;margin-right:.3rem;vertical-align:-1px;background:" + color + "\"></span>"
|
||||
+ esc(label) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Newest dead letters.
|
||||
*******************************************************************************/
|
||||
private static void deadLetters(StringBuilder html, WebhooksHomeData data, String prefix)
|
||||
{
|
||||
html.append("<div style=\"").append(CARD).append("\"><h3 style=\"").append(TITLE).append("\">Newest dead letters</h3>");
|
||||
if(data.getRecentDeadLetters().isEmpty())
|
||||
{
|
||||
html.append("<p style=\"").append(MUTED).append("\">No dead letters.</p></div>");
|
||||
return;
|
||||
}
|
||||
for(QRecord delivery : data.getRecentDeadLetters())
|
||||
{
|
||||
html.append("<div style=\"").append(ROW).append("\">")
|
||||
.append(cell("Delivery", "<a href=\"" + esc(prefix + "/" + WebhooksTables.DELIVERY + "/" + delivery.getValue("id")) + "\">#"
|
||||
+ esc(String.valueOf(delivery.getValue("id"))) + "</a>"))
|
||||
.append(cell("Event type", esc(delivery.getValueString("eventTypeName"))))
|
||||
.append(cell("Last error", esc(clamp(delivery.getValueString("lastError"), 140))))
|
||||
.append(cell("Dead-lettered", when(delivery.getValue("deadLetterDate"))))
|
||||
.append("</div>");
|
||||
}
|
||||
html.append("<p style=\"").append(MUTED).append("margin-top:.5rem\">Select dead letters in Deliveries and run Replay to send them again.</p></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A labelled cell.
|
||||
*******************************************************************************/
|
||||
private static String cell(String label, String valueHtml)
|
||||
{
|
||||
return ("<div style=\"" + CELL + "\"><span style=\"" + CAP + "\">" + esc(label) + "</span>" + valueHtml + "</div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Percent of max, one decimal.
|
||||
*******************************************************************************/
|
||||
private static String pct(int value, int max)
|
||||
{
|
||||
return (String.format(Locale.ROOT, "%.1f", max == 0 ? 0.0 : 100.0 * value / max));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A compact date (never wrapped), or a dash.
|
||||
*******************************************************************************/
|
||||
private static String when(Object value)
|
||||
{
|
||||
Instant instant = value == null ? null : ValueUtils.getValueAsInstant((Serializable) value);
|
||||
return (instant == null ? "-" : "<span style=\"white-space:nowrap\">" + esc(WHEN.format(instant)) + "</span>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cut long text.
|
||||
*******************************************************************************/
|
||||
private static String clamp(String text, int max)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
return (text.length() <= max ? text : text.substring(0, max - 1) + "…");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTML-escape.
|
||||
*******************************************************************************/
|
||||
static String esc(String text)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
return (text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.app;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.processes.WebhooksProcesses;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qbits.webhooks.secrets.EndpointSecrets;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Webhooks home: each thing an administrator configures
|
||||
** (event types, encryption key, endpoints, secrets, subscriptions), its state
|
||||
** and the next step. Nothing here is seeded: every step is the owner's choice.
|
||||
*******************************************************************************/
|
||||
public final class WebhooksSetupStatus
|
||||
{
|
||||
public static final String SET_UP = "Set up";
|
||||
public static final String NEEDS_SETUP = "Needs setup";
|
||||
public static final String OPTIONAL = "Optional";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Step(String label, String state, String next, String href)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the current state, as the viewer.
|
||||
*******************************************************************************/
|
||||
public static List<Step> load() throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
int eventTypes = 0;
|
||||
for(WebhookEventType type : WebhooksRegistry.of(QContext.getQInstance()).getAll())
|
||||
{
|
||||
if(!WebhooksProcesses.TEST_EVENT_TYPE.equals(type.getName()))
|
||||
{
|
||||
eventTypes++;
|
||||
}
|
||||
}
|
||||
|
||||
int endpoints = count(WebhooksTables.ENDPOINT);
|
||||
int subscriptions = count(WebhooksTables.SUBSCRIPTION);
|
||||
int blocked = 0;
|
||||
for(QRecord endpoint : new QueryAction().execute(new QueryInput(WebhooksTables.ENDPOINT)).getRecords())
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// exactly DeliveryEngine's test: active, signing required, and no usable secret //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
if(ActiveStatus.ACTIVE.name().equals(endpoint.getValueString("activeStatus")) && !Boolean.TRUE.equals(endpoint.getValueBoolean("allowUnsigned"))
|
||||
&& EndpointSecrets.signingKeys(endpoint.getValueInteger("id")).isEmpty())
|
||||
{
|
||||
blocked++;
|
||||
}
|
||||
}
|
||||
return (steps(config.getTablePathPrefix(), config.getEncryptionKeyEnvName(), eventTypes, EndpointSecrets.canGenerate(), endpoints, blocked, subscriptions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The steps, from the numbers (pure, for tests).
|
||||
*******************************************************************************/
|
||||
static List<Step> steps(String prefix, String keyEnvName, int eventTypes, boolean canGenerate, int endpoints, int blockedEndpoints, int subscriptions)
|
||||
{
|
||||
String endpointHref = prefix + "/" + WebhooksTables.ENDPOINT;
|
||||
String subscriptionHref = prefix + "/" + WebhooksTables.SUBSCRIPTION;
|
||||
List<Step> steps = new ArrayList<>();
|
||||
steps.add(eventTypes > 0
|
||||
? new Step("Event types", SET_UP, eventTypes + " available to subscribe to (from the code-side registry)", subscriptionHref)
|
||||
: new Step("Event types", NEEDS_SETUP, "The host lists tables or processes that raise events in the qbit config (tableEventSources, processEventSources), or fires custom events", null));
|
||||
steps.add(canGenerate
|
||||
? new Step("Encryption key", SET_UP, "Generated signing secrets can be stored encrypted", null)
|
||||
: new Step("Encryption key", OPTIONAL, "Set " + keyEnvName + " to generate secrets here, or use environment-variable secrets", null));
|
||||
steps.add(endpoints > 0
|
||||
? new Step("Endpoints", SET_UP, endpoints + (endpoints == 1 ? " endpoint" : " endpoints"), endpointHref)
|
||||
: new Step("Endpoints", NEEDS_SETUP, "Add an endpoint: the URL that receives events", endpointHref));
|
||||
steps.add(endpoints > 0 && blockedEndpoints == 0
|
||||
? new Step("Signing secrets", SET_UP, "Every endpoint signs its deliveries", endpointHref)
|
||||
: new Step("Signing secrets", NEEDS_SETUP, endpoints == 0 ? "Add an endpoint first, then give it a secret"
|
||||
: blockedEndpoints + " active endpoint(s) have no usable signing secret, so their deliveries wait: open the endpoint and add one", endpointHref));
|
||||
steps.add(subscriptions > 0
|
||||
? new Step("Subscriptions", SET_UP, subscriptions + (subscriptions == 1 ? " subscription" : " subscriptions"), subscriptionHref)
|
||||
: new Step("Subscriptions", NEEDS_SETUP, "Subscribe an endpoint to an event type, or nothing is ever sent", subscriptionHref));
|
||||
return (steps);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count as the viewer.
|
||||
*******************************************************************************/
|
||||
private static int count(String table) throws QException
|
||||
{
|
||||
Integer count = new CountAction().execute(new CountInput(table)).getCount();
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.delivery;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.AttemptOutcome;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.DeliveryStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.HealthStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.secrets.EndpointSecrets;
|
||||
import com.kingsrook.qbits.webhooks.shared.http.SafeHttpClient;
|
||||
import com.kingsrook.qbits.webhooks.shared.http.SafeHttpException;
|
||||
import com.kingsrook.qbits.webhooks.shared.http.SafeHttpResponse;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSigner;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sends due deliveries. Safe to run on any number of replicas at once.
|
||||
**
|
||||
** <h2>Claiming (no double send)</h2>
|
||||
** <p>To send attempt <i>n</i> of a delivery, a runner first inserts the
|
||||
** {@code webhooks_delivery_attempt} row ({@code deliveryId}, <i>n</i>), which
|
||||
** is unique. Exactly one runner's insert succeeds; the others get a duplicate
|
||||
** and skip. Only the winner sends, then records the outcome. This is a
|
||||
** compare-and-set on the attempt number that works on every SQL backend, with
|
||||
** no {@code SELECT ... FOR UPDATE} needed.</p>
|
||||
**
|
||||
** <h2>Leases</h2>
|
||||
** <p>A claimed delivery is SENDING with a lease (config {@code leaseSeconds},
|
||||
** longer than the total HTTP timeout). If its runner dies, the lease expires,
|
||||
** the stale attempt is marked LEASE_EXPIRED, and the next run claims attempt
|
||||
** <i>n+1</i>. So delivery is at least once: a runner that died after the
|
||||
** receiver got the request but before recording it causes one repeat, with
|
||||
** the same {@code webhook-id}.</p>
|
||||
**
|
||||
** <h2>Outcomes</h2>
|
||||
** <ul>
|
||||
** <li>2xx: DELIVERED.</li>
|
||||
** <li>429: waits for Retry-After (capped), does not count against the
|
||||
** endpoint's health or the delivery's attempt budget.</li>
|
||||
** <li>Anything else (3xx included, redirects are not followed), a network
|
||||
** failure or a policy block: RETRY_WAIT with backoff; after
|
||||
** {@code maxAttempts} since the last replay, DEAD_LETTER.</li>
|
||||
** </ul>
|
||||
**
|
||||
** <h2>Ordering</h2>
|
||||
** <p>By default there is no ordering guarantee (a failing delivery backs off
|
||||
** while later ones go). An endpoint with {@code serialDelivery} gets one
|
||||
** delivery at a time, oldest first; a failing one blocks the rest until it
|
||||
** is delivered or dead-lettered.</p>
|
||||
*******************************************************************************/
|
||||
public class DeliveryEngine
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(DeliveryEngine.class);
|
||||
|
||||
private static final int MAX_EXCERPT = 2000;
|
||||
private static final int MAX_ERROR = 1000;
|
||||
|
||||
private static final List<String> DUE_STATUSES = List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.RETRY_WAIT.name());
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a run did.
|
||||
*******************************************************************************/
|
||||
public static class RunSummary
|
||||
{
|
||||
private int delivered;
|
||||
private int failed;
|
||||
private int deadLettered;
|
||||
private int rateLimited;
|
||||
private int claimedElsewhere;
|
||||
private int skippedForRateLimit;
|
||||
private int waitingForSecret;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for delivered.
|
||||
*******************************************************************************/
|
||||
public int getDelivered()
|
||||
{
|
||||
return (delivered);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for failed (including dead-lettered).
|
||||
*******************************************************************************/
|
||||
public int getFailed()
|
||||
{
|
||||
return (failed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for deadLettered.
|
||||
*******************************************************************************/
|
||||
public int getDeadLettered()
|
||||
{
|
||||
return (deadLettered);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for rateLimited (429 from the receiver).
|
||||
*******************************************************************************/
|
||||
public int getRateLimited()
|
||||
{
|
||||
return (rateLimited);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for claimedElsewhere (another runner had it).
|
||||
*******************************************************************************/
|
||||
public int getClaimedElsewhere()
|
||||
{
|
||||
return (claimedElsewhere);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for skippedForRateLimit (the endpoint's own per-minute cap).
|
||||
*******************************************************************************/
|
||||
public int getSkippedForRateLimit()
|
||||
{
|
||||
return (skippedForRateLimit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for waitingForSecret (due, but the endpoint has no usable
|
||||
** signing secret and does not allow unsigned delivery: not attempted).
|
||||
*******************************************************************************/
|
||||
public int getWaitingForSecret()
|
||||
{
|
||||
return (waitingForSecret);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Total attempts made.
|
||||
*******************************************************************************/
|
||||
public int getAttempts()
|
||||
{
|
||||
return (delivered + failed + rateLimited);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result of sending one delivery now.
|
||||
*******************************************************************************/
|
||||
public enum SendResult
|
||||
{
|
||||
DELIVERED,
|
||||
FAILED,
|
||||
CLAIMED_ELSEWHERE,
|
||||
NOT_SENT
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send one delivery now (a person asked): ignores its next-attempt time and
|
||||
** the endpoint's health, but not a disabled endpoint or a finished delivery.
|
||||
*******************************************************************************/
|
||||
public SendResult sendNow(Integer deliveryId) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
List<QRecord> deliveries = SystemWrites.query(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, deliveryId)));
|
||||
if(config == null || deliveries.isEmpty())
|
||||
{
|
||||
return (SendResult.NOT_SENT);
|
||||
}
|
||||
QRecord delivery = deliveries.get(0);
|
||||
String status = delivery.getValueString("status");
|
||||
if(!DUE_STATUSES.contains(status) && !DeliveryStatus.SENDING.name().equals(status))
|
||||
{
|
||||
return (SendResult.NOT_SENT);
|
||||
}
|
||||
if(DeliveryStatus.SENDING.name().equals(status))
|
||||
{
|
||||
Instant lease = ValueUtils.getValueAsInstant(delivery.getValue("leaseExpiresDate"));
|
||||
if(lease != null && lease.isAfter(WebhooksRuntime.clock().instant()))
|
||||
{
|
||||
return (SendResult.CLAIMED_ELSEWHERE);
|
||||
}
|
||||
}
|
||||
List<QRecord> endpoints = SystemWrites.query(WebhooksTables.ENDPOINT, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, delivery.getValue("endpointId"))));
|
||||
if(endpoints.isEmpty() || ActiveStatus.DISABLED.name().equals(endpoints.get(0).getValueString("activeStatus")))
|
||||
{
|
||||
return (SendResult.NOT_SENT);
|
||||
}
|
||||
Attempt attempt = claim(config, delivery, endpoints.get(0));
|
||||
if(attempt == null)
|
||||
{
|
||||
return (SendResult.CLAIMED_ELSEWHERE);
|
||||
}
|
||||
RunSummary summary = new RunSummary();
|
||||
send(config, attempt, delivery, endpoints.get(0), summary);
|
||||
return (summary.getDelivered() > 0 ? SendResult.DELIVERED : SendResult.FAILED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send everything due, for every active endpoint.
|
||||
*******************************************************************************/
|
||||
public RunSummary runOnce() throws QException
|
||||
{
|
||||
return (run(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send everything due for some endpoints (null: all).
|
||||
*******************************************************************************/
|
||||
public RunSummary run(List<Integer> endpointIds) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
RunSummary summary = new RunSummary();
|
||||
if(config == null)
|
||||
{
|
||||
return (summary);
|
||||
}
|
||||
QQueryFilter endpointFilter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("activeStatus", QCriteriaOperator.EQUALS, ActiveStatus.ACTIVE.name()))
|
||||
.withCriteria(new QFilterCriteria("healthStatus", QCriteriaOperator.NOT_EQUALS, HealthStatus.UNHEALTHY.name()))
|
||||
.withOrderBy(new QFilterOrderBy("id"));
|
||||
if(endpointIds != null)
|
||||
{
|
||||
if(endpointIds.isEmpty())
|
||||
{
|
||||
return (summary);
|
||||
}
|
||||
endpointFilter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.IN, new ArrayList<>(endpointIds)));
|
||||
}
|
||||
|
||||
int budget = config.getDeliveryBatchSize();
|
||||
for(QRecord endpoint : SystemWrites.query(WebhooksTables.ENDPOINT, endpointFilter))
|
||||
{
|
||||
if(budget <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
budget -= runEndpoint(config, endpoint, budget, summary);
|
||||
}
|
||||
return (summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send due deliveries for one endpoint; returns how many were attempted.
|
||||
*******************************************************************************/
|
||||
int runEndpoint(WebhooksQBitConfig config, QRecord endpoint, int budget, RunSummary summary) throws QException
|
||||
{
|
||||
Integer endpointId = endpoint.getValueInteger("id");
|
||||
Instant now = WebhooksRuntime.clock().instant();
|
||||
int limit = budget;
|
||||
|
||||
Integer perMinute = endpoint.getValueInteger("maxPerMinute");
|
||||
if(perMinute != null && perMinute > 0)
|
||||
{
|
||||
int recent = SystemWrites.count(WebhooksTables.ATTEMPT, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("endpointId", QCriteriaOperator.EQUALS, endpointId))
|
||||
.withCriteria(new QFilterCriteria("startDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(Duration.ofMinutes(1)))));
|
||||
limit = Math.min(limit, perMinute - recent);
|
||||
}
|
||||
if(HealthStatus.PROBATION.name().equals(endpoint.getValueString("healthStatus")))
|
||||
{
|
||||
limit = Math.min(limit, 1);
|
||||
}
|
||||
|
||||
List<QRecord> due = Boolean.TRUE.equals(endpoint.getValueBoolean("serialDelivery")) ? serialHead(endpointId, now) : due(endpointId, now, Math.max(limit, 1));
|
||||
if(limit <= 0)
|
||||
{
|
||||
summary.skippedForRateLimit += due.size();
|
||||
return (0);
|
||||
}
|
||||
if(!due.isEmpty() && !Boolean.TRUE.equals(endpoint.getValueBoolean("allowUnsigned")) && EndpointSecrets.signingKeys(endpointId).isEmpty())
|
||||
{
|
||||
summary.waitingForSecret += due.size();
|
||||
return (0);
|
||||
}
|
||||
|
||||
int attempted = 0;
|
||||
for(QRecord delivery : due)
|
||||
{
|
||||
if(attempted >= limit)
|
||||
{
|
||||
summary.skippedForRateLimit++;
|
||||
continue;
|
||||
}
|
||||
Attempt attempt = claim(config, delivery, endpoint);
|
||||
if(attempt == null)
|
||||
{
|
||||
summary.claimedElsewhere++;
|
||||
continue;
|
||||
}
|
||||
attempted++;
|
||||
send(config, attempt, delivery, endpoint, summary);
|
||||
endpoint = SystemWrites.query(WebhooksTables.ENDPOINT, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, endpointId))).get(0);
|
||||
if(!ActiveStatus.ACTIVE.name().equals(endpoint.getValueString("activeStatus")) || HealthStatus.UNHEALTHY.name().equals(endpoint.getValueString("healthStatus")))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (attempted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Due deliveries: pending or waiting and due, or sending with an expired
|
||||
** lease; oldest first.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> due(Integer endpointId, Instant now, int limit) throws QException
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("endpointId", QCriteriaOperator.EQUALS, endpointId))
|
||||
.withSubFilters(List.of(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withSubFilters(List.of(
|
||||
new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, new ArrayList<>(DUE_STATUSES)))
|
||||
.withCriteria(new QFilterCriteria("nextAttemptDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)),
|
||||
new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, DeliveryStatus.SENDING.name()))
|
||||
.withCriteria(new QFilterCriteria("leaseExpiresDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now))))))
|
||||
.withOrderBy(new QFilterOrderBy("id"))
|
||||
.withLimit(limit);
|
||||
return (SystemWrites.query(WebhooksTables.DELIVERY, filter));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serial endpoints: the oldest unfinished delivery, if it is due.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> serialHead(Integer endpointId, Instant now) throws QException
|
||||
{
|
||||
List<QRecord> head = SystemWrites.query(WebhooksTables.DELIVERY, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("endpointId", QCriteriaOperator.EQUALS, endpointId))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, DeliveryStatus.PENDING.name(), DeliveryStatus.RETRY_WAIT.name(), DeliveryStatus.SENDING.name()))
|
||||
.withOrderBy(new QFilterOrderBy("id"))
|
||||
.withLimit(1));
|
||||
if(head.isEmpty())
|
||||
{
|
||||
return (head);
|
||||
}
|
||||
QRecord delivery = head.get(0);
|
||||
boolean sending = DeliveryStatus.SENDING.name().equals(delivery.getValueString("status"));
|
||||
Instant when = ValueUtils.getValueAsInstant(delivery.getValue(sending ? "leaseExpiresDate" : "nextAttemptDate"));
|
||||
return (when == null || !when.isAfter(now) ? head : List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A claimed attempt.
|
||||
*******************************************************************************/
|
||||
record Attempt(Integer id, int number, Instant start)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Claim the next attempt of a delivery, or null if another runner has it.
|
||||
*******************************************************************************/
|
||||
Attempt claim(WebhooksQBitConfig config, QRecord delivery, QRecord endpoint) throws QException
|
||||
{
|
||||
Integer deliveryId = delivery.getValueInteger("id");
|
||||
int previous = ValueUtils.getValueAsInteger(delivery.getValue("attemptCount")) == null ? 0 : delivery.getValueInteger("attemptCount");
|
||||
int number = previous + 1;
|
||||
Instant now = WebhooksRuntime.clock().instant();
|
||||
Instant lease = now.plusSeconds(config.getLeaseSeconds());
|
||||
|
||||
QRecord inserted;
|
||||
try
|
||||
{
|
||||
inserted = SystemWrites.insertOne(WebhooksTables.ATTEMPT, new QRecord()
|
||||
.withValue("deliveryId", deliveryId)
|
||||
.withValue("endpointId", endpoint.getValue("id"))
|
||||
.withValue("attemptNumber", number)
|
||||
.withValue("outcome", AttemptOutcome.IN_FLIGHT.name())
|
||||
.withValue("claimedBy", WebhooksRuntime.nodeId())
|
||||
.withValue("leaseExpiresDate", lease)
|
||||
.withValue("startDate", now)
|
||||
.withValue("requestUrl", truncate(endpoint.getValueString("url"), 500)));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
LOG.debug("Lost the claim for a webhook delivery attempt", logPair("deliveryId", deliveryId), logPair("attempt", number));
|
||||
return (null);
|
||||
}
|
||||
if(inserted == null || CollectionUtils.nullSafeHasContents(inserted.getErrors()) || inserted.getValueInteger("id") == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
if(previous > 0 && DeliveryStatus.SENDING.name().equals(delivery.getValueString("status")))
|
||||
{
|
||||
List<QRecord> stale = SystemWrites.query(WebhooksTables.ATTEMPT, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("deliveryId", QCriteriaOperator.EQUALS, deliveryId))
|
||||
.withCriteria(new QFilterCriteria("attemptNumber", QCriteriaOperator.EQUALS, previous))
|
||||
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, AttemptOutcome.IN_FLIGHT.name())));
|
||||
for(QRecord row : stale)
|
||||
{
|
||||
SystemWrites.updateOne(WebhooksTables.ATTEMPT, new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("outcome", AttemptOutcome.LEASE_EXPIRED.name()).withValue("endDate", now)
|
||||
.withValue("errorMessage", "The runner that claimed this attempt stopped before recording an outcome; the delivery was retried."));
|
||||
}
|
||||
}
|
||||
|
||||
int cycle = delivery.getValueInteger("cycleAttemptCount") == null ? 0 : delivery.getValueInteger("cycleAttemptCount");
|
||||
SystemWrites.updateOne(WebhooksTables.DELIVERY, new QRecord()
|
||||
.withValue("id", deliveryId)
|
||||
.withValue("status", DeliveryStatus.SENDING.name())
|
||||
.withValue("attemptCount", number)
|
||||
.withValue("cycleAttemptCount", cycle + 1)
|
||||
.withValue("leaseExpiresDate", lease)
|
||||
.withValue("nextAttemptDate", lease));
|
||||
delivery.setValue("cycleAttemptCount", cycle + 1);
|
||||
return (new Attempt(inserted.getValueInteger("id"), number, now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send a claimed attempt and record the outcome.
|
||||
*******************************************************************************/
|
||||
void send(WebhooksQBitConfig config, Attempt attempt, QRecord delivery, QRecord endpoint, RunSummary summary) throws QException
|
||||
{
|
||||
Integer endpointId = endpoint.getValueInteger("id");
|
||||
byte[] body = delivery.getValueString("payload") == null ? new byte[0] : delivery.getValueString("payload").getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
SafeHttpResponse response = null;
|
||||
String error = null;
|
||||
boolean policy = false;
|
||||
String signedWith = null;
|
||||
try
|
||||
{
|
||||
List<EndpointSecrets.SigningKey> keys = EndpointSecrets.signingKeys(endpointId);
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("Content-Type", "application/json");
|
||||
headers.put("webhook-attempt", String.valueOf(attempt.number()));
|
||||
if(keys.isEmpty())
|
||||
{
|
||||
if(!Boolean.TRUE.equals(endpoint.getValueBoolean("allowUnsigned")))
|
||||
{
|
||||
throw (new SafeHttpException(SafeHttpException.Reason.INVALID_URL,
|
||||
"Not sent: this endpoint has no usable signing secret (rotate one in, or allow unsigned delivery)"));
|
||||
}
|
||||
headers.put(WebhookSigner.HEADER_ID, delivery.getValueString("eventId"));
|
||||
headers.put(WebhookSigner.HEADER_TIMESTAMP, String.valueOf(WebhooksRuntime.clock().instant().getEpochSecond()));
|
||||
}
|
||||
else
|
||||
{
|
||||
List<WebhookSecret> secrets = keys.stream().map(EndpointSecrets.SigningKey::secret).toList();
|
||||
headers.putAll(WebhookSigner.headers(delivery.getValueString("eventId"), WebhooksRuntime.clock().instant(), body, secrets));
|
||||
signedWith = String.join(" ", keys.stream().map(EndpointSecrets.SigningKey::fingerprint).toList());
|
||||
}
|
||||
SafeHttpClient client = WebhooksRuntime.httpClient(WebhooksRuntime.policyFor(config, endpoint.getValueInteger("timeoutSeconds"), allowlist(endpoint)));
|
||||
response = client.post(endpoint.getValueString("url"), headers, body);
|
||||
}
|
||||
catch(SafeHttpException e)
|
||||
{
|
||||
error = e.getMessage();
|
||||
policy = e.getReason().isPolicy();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
error = "Unexpected error: " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : ": " + e.getMessage());
|
||||
LOG.warn("Unexpected error sending a webhook", e, logPair("deliveryId", delivery.getValue("id")));
|
||||
}
|
||||
|
||||
Instant end = WebhooksRuntime.clock().instant();
|
||||
long duration = response != null ? response.durationMillis() : Math.max(0, Duration.between(attempt.start(), end).toMillis());
|
||||
|
||||
AttemptOutcome outcome;
|
||||
if(response != null && response.isSuccess())
|
||||
{
|
||||
outcome = AttemptOutcome.SUCCEEDED;
|
||||
}
|
||||
else if(response != null && response.statusCode() == 429)
|
||||
{
|
||||
outcome = AttemptOutcome.RATE_LIMITED;
|
||||
}
|
||||
else if(policy)
|
||||
{
|
||||
outcome = AttemptOutcome.BLOCKED;
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = AttemptOutcome.FAILED;
|
||||
}
|
||||
if(response != null && !response.isSuccess() && error == null)
|
||||
{
|
||||
error = "HTTP " + response.statusCode() + (response.statusCode() >= 300 && response.statusCode() < 400 ? " (redirects are not followed)" : "");
|
||||
}
|
||||
|
||||
SystemWrites.updateOne(WebhooksTables.ATTEMPT, new QRecord()
|
||||
.withValue("id", attempt.id())
|
||||
.withValue("outcome", outcome.name())
|
||||
.withValue("endDate", end)
|
||||
.withValue("durationMillis", (int) duration)
|
||||
.withValue("httpStatus", response == null ? null : response.statusCode())
|
||||
.withValue("responseExcerpt", response == null ? null : response.bodyExcerpt(MAX_EXCERPT) + (response.truncated() ? "\n[truncated]" : ""))
|
||||
.withValue("errorMessage", truncate(error, MAX_ERROR))
|
||||
.withValue("remoteAddress", response == null ? null : response.remoteAddress())
|
||||
.withValue("signedWith", signedWith));
|
||||
|
||||
QRecord update = new QRecord()
|
||||
.withValue("id", delivery.getValue("id"))
|
||||
.withValue("leaseExpiresDate", null)
|
||||
.withValue("lastHttpStatus", response == null ? null : response.statusCode())
|
||||
.withValue("lastError", truncate(error, 500));
|
||||
|
||||
switch(outcome)
|
||||
{
|
||||
case SUCCEEDED ->
|
||||
{
|
||||
update.withValue("status", DeliveryStatus.DELIVERED.name()).withValue("deliveredDate", end).withValue("nextAttemptDate", null);
|
||||
summary.delivered++;
|
||||
EndpointHealth.recordSuccess(endpoint, end);
|
||||
}
|
||||
case RATE_LIMITED ->
|
||||
{
|
||||
int cycle = delivery.getValueInteger("cycleAttemptCount") == null ? 1 : delivery.getValueInteger("cycleAttemptCount");
|
||||
update.withValue("status", DeliveryStatus.RETRY_WAIT.name())
|
||||
.withValue("cycleAttemptCount", Math.max(0, cycle - 1))
|
||||
.withValue("nextAttemptDate", end.plusSeconds(retryAfterSeconds(config, response, cycle)));
|
||||
summary.rateLimited++;
|
||||
}
|
||||
default ->
|
||||
{
|
||||
int cycle = delivery.getValueInteger("cycleAttemptCount") == null ? 1 : delivery.getValueInteger("cycleAttemptCount");
|
||||
boolean probation = HealthStatus.PROBATION.name().equals(endpoint.getValueString("healthStatus"));
|
||||
if(cycle >= config.getMaxAttempts() && !probation)
|
||||
{
|
||||
update.withValue("status", DeliveryStatus.DEAD_LETTER.name()).withValue("deadLetterDate", end).withValue("nextAttemptDate", null);
|
||||
summary.deadLettered++;
|
||||
WebhooksAlerts.deadLetter(endpoint, delivery);
|
||||
}
|
||||
else
|
||||
{
|
||||
update.withValue("status", DeliveryStatus.RETRY_WAIT.name())
|
||||
.withValue("nextAttemptDate", end.plus(Duration.ofMinutes(config.retryMinutesAfterAttempt(cycle))));
|
||||
}
|
||||
summary.failed++;
|
||||
if(outcome == AttemptOutcome.FAILED)
|
||||
{
|
||||
EndpointHealth.recordFailure(config, endpoint, end, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
SystemWrites.updateOne(WebhooksTables.DELIVERY, update);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The endpoint's allowlist, only when internal targets are allowed.
|
||||
*******************************************************************************/
|
||||
static List<String> allowlist(QRecord endpoint)
|
||||
{
|
||||
if(!Boolean.TRUE.equals(endpoint.getValueBoolean("allowInternalTargets")))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
String text = endpoint.getValueString("internalAllowlist");
|
||||
if(!StringUtils.hasContent(text))
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
List<String> list = new ArrayList<>();
|
||||
for(String part : text.split("[,\\s]+"))
|
||||
{
|
||||
if(!part.isBlank())
|
||||
{
|
||||
list.add(part.trim());
|
||||
}
|
||||
}
|
||||
return (list);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seconds to wait after a 429: Retry-After (seconds or an HTTP date),
|
||||
** capped, else the normal backoff.
|
||||
*******************************************************************************/
|
||||
static long retryAfterSeconds(WebhooksQBitConfig config, SafeHttpResponse response, int cycle)
|
||||
{
|
||||
long fallback = Duration.ofMinutes(config.retryMinutesAfterAttempt(Math.max(cycle, 1))).toSeconds();
|
||||
String header = response == null ? null : response.header("retry-after");
|
||||
long seconds = fallback;
|
||||
if(StringUtils.hasContent(header))
|
||||
{
|
||||
try
|
||||
{
|
||||
seconds = Long.parseLong(header.trim());
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
try
|
||||
{
|
||||
seconds = Duration.between(WebhooksRuntime.clock().instant(), ZonedDateTime.parse(header.trim(), DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()).toSeconds();
|
||||
}
|
||||
catch(DateTimeParseException e2)
|
||||
{
|
||||
seconds = fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (Math.max(1, Math.min(seconds, config.getMaxRetryAfterSeconds())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cut text to a length.
|
||||
*******************************************************************************/
|
||||
static String truncate(String text, int max)
|
||||
{
|
||||
return (text == null || text.length() <= max ? text : text.substring(0, max));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.delivery;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.HealthStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Endpoint health: HEALTHY, then UNHEALTHY after
|
||||
** {@code unhealthyAfterConsecutiveFailures} failures in a row (no deliveries
|
||||
** are attempted), then PROBATION after {@code probationAfterMinutes} (one
|
||||
** attempt per run). A success returns it to HEALTHY; a failure on probation
|
||||
** makes it UNHEALTHY again, and after {@code autoDisableAfterProbationFailures}
|
||||
** such failures the endpoint is DISABLED. Each change raises or resolves an
|
||||
** alert when the Alerts qbit is installed.
|
||||
**
|
||||
** <p>Rate-limit (429) answers and policy blocks never count as failures.
|
||||
** Counters are read-modify-write: with several replicas a count can be off by
|
||||
** one, which only shifts the threshold slightly.</p>
|
||||
*******************************************************************************/
|
||||
public final class EndpointHealth
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EndpointHealth()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A delivery succeeded.
|
||||
*******************************************************************************/
|
||||
public static void recordSuccess(QRecord endpoint, Instant now) throws QException
|
||||
{
|
||||
QRecord update = new QRecord().withValue("id", endpoint.getValue("id")).withValue("lastSuccessDate", now);
|
||||
boolean wasUnwell = !HealthStatus.HEALTHY.name().equals(endpoint.getValueString("healthStatus"));
|
||||
if(wasUnwell || intValue(endpoint, "consecutiveFailures") > 0 || intValue(endpoint, "probationFailures") > 0)
|
||||
{
|
||||
update.withValue("consecutiveFailures", 0).withValue("probationFailures", 0)
|
||||
.withValue("healthStatus", HealthStatus.HEALTHY.name()).withValue("unhealthySinceDate", null);
|
||||
}
|
||||
SystemWrites.updateOne(WebhooksTables.ENDPOINT, update);
|
||||
if(wasUnwell)
|
||||
{
|
||||
WebhooksAlerts.resolveUnhealthy(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A delivery failed (a receiver or network failure).
|
||||
*******************************************************************************/
|
||||
public static void recordFailure(WebhooksQBitConfig config, QRecord endpoint, Instant now, String message) throws QException
|
||||
{
|
||||
int consecutive = intValue(endpoint, "consecutiveFailures") + 1;
|
||||
String health = endpoint.getValueString("healthStatus");
|
||||
QRecord update = new QRecord().withValue("id", endpoint.getValue("id"))
|
||||
.withValue("consecutiveFailures", consecutive)
|
||||
.withValue("lastFailureDate", now)
|
||||
.withValue("lastFailureMessage", DeliveryEngine.truncate(message, 500));
|
||||
|
||||
if(HealthStatus.PROBATION.name().equals(health))
|
||||
{
|
||||
int probationFailures = intValue(endpoint, "probationFailures") + 1;
|
||||
update.withValue("probationFailures", probationFailures).withValue("healthStatus", HealthStatus.UNHEALTHY.name()).withValue("unhealthySinceDate", now);
|
||||
Integer limit = config.getAutoDisableAfterProbationFailures();
|
||||
if(limit != null && limit > 0 && probationFailures >= limit)
|
||||
{
|
||||
update.withValue("activeStatus", ActiveStatus.DISABLED.name()).withValue("autoDisabledDate", now);
|
||||
SystemWrites.updateOne(WebhooksTables.ENDPOINT, update);
|
||||
WebhooksAlerts.disabled(endpoint, probationFailures);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if(HealthStatus.HEALTHY.name().equals(health) && consecutive >= config.getUnhealthyAfterConsecutiveFailures())
|
||||
{
|
||||
update.withValue("healthStatus", HealthStatus.UNHEALTHY.name()).withValue("unhealthySinceDate", now);
|
||||
SystemWrites.updateOne(WebhooksTables.ENDPOINT, update);
|
||||
WebhooksAlerts.unhealthy(endpoint, consecutive, message);
|
||||
return;
|
||||
}
|
||||
SystemWrites.updateOne(WebhooksTables.ENDPOINT, update);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Move endpoints that have been UNHEALTHY long enough to PROBATION.
|
||||
** Returns their ids.
|
||||
*******************************************************************************/
|
||||
public static List<Integer> promoteToProbation(WebhooksQBitConfig config) throws QException
|
||||
{
|
||||
Instant cutoff = WebhooksRuntime.clock().instant().minus(Duration.ofMinutes(config.getProbationAfterMinutes()));
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
List<QRecord> updates = new ArrayList<>();
|
||||
for(QRecord endpoint : SystemWrites.query(WebhooksTables.ENDPOINT, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("healthStatus", QCriteriaOperator.EQUALS, HealthStatus.UNHEALTHY.name()))
|
||||
.withCriteria(new QFilterCriteria("activeStatus", QCriteriaOperator.EQUALS, ActiveStatus.ACTIVE.name()))
|
||||
.withCriteria(new QFilterCriteria("unhealthySinceDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, cutoff))))
|
||||
{
|
||||
ids.add(endpoint.getValueInteger("id"));
|
||||
updates.add(new QRecord().withValue("id", endpoint.getValue("id")).withValue("healthStatus", HealthStatus.PROBATION.name()));
|
||||
}
|
||||
SystemWrites.update(WebhooksTables.ENDPOINT, updates, null);
|
||||
return (ids);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An integer field, 0 when null.
|
||||
*******************************************************************************/
|
||||
private static int intValue(QRecord record, String field)
|
||||
{
|
||||
Integer value = record.getValueInteger(field);
|
||||
return (value == null ? 0 : value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.delivery;
|
||||
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raises and resolves alerts through the QQQ Alerts producer SPI
|
||||
** ({@code com.kingsrook.qqq.backend.core.alerts.QAlerts}, design QRun-IO/qqq
|
||||
** #1090) when it is on the classpath. Without it every call only logs, so the
|
||||
** qbit needs no compile dependency on the Alerts SPI or qbit.
|
||||
**
|
||||
** <p>Alert types (dedup key = the endpoint id):</p>
|
||||
** <ul>
|
||||
** <li>{@code webhooks.endpoint.unhealthy} (ERROR): failures in a row
|
||||
** paused deliveries. Resolved by the next success.</li>
|
||||
** <li>{@code webhooks.endpoint.disabled} (ERROR): failed on probation too
|
||||
** often and was disabled.</li>
|
||||
** <li>{@code webhooks.delivery.dead_letter} (WARNING): a delivery ran out of
|
||||
** attempts.</li>
|
||||
** </ul>
|
||||
** <p>Titles and summaries are external-safe: endpoint names and counts only,
|
||||
** never URLs, payloads or response text.</p>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksAlerts
|
||||
{
|
||||
public static final String TYPE_UNHEALTHY = "webhooks.endpoint.unhealthy";
|
||||
public static final String TYPE_DISABLED = "webhooks.endpoint.disabled";
|
||||
public static final String TYPE_DEAD_LETTER = "webhooks.delivery.dead_letter";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(WebhooksAlerts.class);
|
||||
|
||||
private static final String QALERTS = "com.kingsrook.qqq.backend.core.alerts.QAlerts";
|
||||
private static final String ALERT_SIGNAL = "com.kingsrook.qqq.backend.core.alerts.AlertSignal";
|
||||
private static final String SEVERITY = "com.kingsrook.qqq.backend.core.alerts.AlertSeverity";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the Alerts SPI is on the classpath.
|
||||
*******************************************************************************/
|
||||
public static boolean available()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(QALERTS, false, WebhooksAlerts.class.getClassLoader());
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An endpoint became unhealthy.
|
||||
*******************************************************************************/
|
||||
public static void unhealthy(QRecord endpoint, int failures, String lastError)
|
||||
{
|
||||
raise(TYPE_UNHEALTHY, key(endpoint), "ERROR", "Webhook endpoint " + endpoint.getValueString("name") + " is unhealthy",
|
||||
failures + " deliveries failed in a row, so deliveries to it are paused until a probation attempt succeeds.", endpoint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An endpoint was disabled automatically.
|
||||
*******************************************************************************/
|
||||
public static void disabled(QRecord endpoint, int probationFailures)
|
||||
{
|
||||
raise(TYPE_DISABLED, key(endpoint), "ERROR", "Webhook endpoint " + endpoint.getValueString("name") + " was disabled",
|
||||
"It failed " + probationFailures + " probation attempts. Fix the receiver, then set the endpoint back to Active.", endpoint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A delivery was dead-lettered.
|
||||
*******************************************************************************/
|
||||
public static void deadLetter(QRecord endpoint, QRecord delivery)
|
||||
{
|
||||
raise(TYPE_DEAD_LETTER, key(endpoint), "WARNING", "Webhook deliveries to " + endpoint.getValueString("name") + " ran out of retries",
|
||||
"Dead-lettered deliveries can be replayed from the Deliveries table.", endpoint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An endpoint recovered.
|
||||
*******************************************************************************/
|
||||
public static void resolveUnhealthy(QRecord endpoint)
|
||||
{
|
||||
resolve(TYPE_UNHEALTHY, key(endpoint), "A delivery succeeded");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Dedup key.
|
||||
*******************************************************************************/
|
||||
private static String key(QRecord endpoint)
|
||||
{
|
||||
return ("endpoint:" + endpoint.getValue("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QAlerts.raise(new AlertSignal(type).withDedupKey(..).withSeverity(..)
|
||||
** .withTitle(..).withSummary(..)), or a log line.
|
||||
*******************************************************************************/
|
||||
static void raise(String type, String dedupKey, String severity, String title, String summary, QRecord endpoint)
|
||||
{
|
||||
LOG.info("Webhooks alert", logPair("type", type), logPair("dedupKey", dedupKey), logPair("title", title));
|
||||
if(!available())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
ClassLoader loader = WebhooksAlerts.class.getClassLoader();
|
||||
Class<?> signalType = Class.forName(ALERT_SIGNAL, true, loader);
|
||||
Object signal = signalType.getConstructor(String.class).newInstance(type);
|
||||
signal = signalType.getMethod("withDedupKey", String.class).invoke(signal, dedupKey);
|
||||
Class<?> severityType = Class.forName(SEVERITY, true, loader);
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
Object level = Enum.valueOf((Class<? extends Enum>) severityType.asSubclass(Enum.class), severity);
|
||||
signal = signalType.getMethod("withSeverity", severityType).invoke(signal, level);
|
||||
signal = signalType.getMethod("withTitle", String.class).invoke(signal, title);
|
||||
signal = signalType.getMethod("withSummary", String.class).invoke(signal, summary);
|
||||
Method raise = Class.forName(QALERTS, true, loader).getMethod("raise", signalType);
|
||||
raise.invoke(null, signal);
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
LOG.info("Could not raise a webhooks alert through the Alerts SPI", logPair("type", type), logPair("reason", e.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QAlerts.resolve(type, dedupKey, note), or nothing.
|
||||
*******************************************************************************/
|
||||
static void resolve(String type, String dedupKey, String note)
|
||||
{
|
||||
if(!available())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Method resolve = Class.forName(QALERTS, true, WebhooksAlerts.class.getClassLoader()).getMethod("resolve", String.class, String.class, String.class);
|
||||
resolve.invoke(null, type, dedupKey, note);
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
LOG.info("Could not resolve a webhooks alert through the Alerts SPI", logPair("type", type), logPair("reason", e.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.esb;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.events.DeliveryOutbox;
|
||||
import com.kingsrook.qbits.webhooks.events.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.EventSource;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventKind;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.esb.envelope.EsbEvent;
|
||||
import com.kingsrook.qqq.esb.model.EsbInstanceMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbProcessMetaData;
|
||||
import com.kingsrook.qqq.esb.model.EsbTrigger;
|
||||
import com.kingsrook.qqq.esb.runtime.EsbTriggerHandler;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ESB source: a hidden process with an ESB trigger on each destination
|
||||
** in the config's {@code esbDestinations} (topics such as
|
||||
** {@code kof22.cms.tables}), turning CloudEvents into webhook deliveries.
|
||||
**
|
||||
** <ul>
|
||||
** <li>{@code qqq.table.<table>.inserted|updated|deleted} becomes
|
||||
** {@code <table>.inserted|updated|deleted};</li>
|
||||
** <li>{@code qqq.process.<name>.completed|failed} becomes
|
||||
** {@code process.<name>.completed|failed};</li>
|
||||
** <li>any other type is used as-is when it is a registered custom type.</li>
|
||||
** </ul>
|
||||
**
|
||||
** <p>No double sends: events this instance published itself are skipped
|
||||
** (the in-process listeners already queued them), and every delivery's
|
||||
** dedup key starts with the CloudEvent id, so a redelivered message queues
|
||||
** nothing new. Events of unregistered types are ignored.</p>
|
||||
**
|
||||
** <p>Requires {@code qqq-esb} (an optional dependency of this qbit). Call
|
||||
** {@link #configure(QInstance)} after the ESB destinations are defined.</p>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksEsbSource
|
||||
{
|
||||
public static final String PROCESS_NAME = "webhooks_esb_ingest";
|
||||
public static final String SUBSCRIPTION_NAME = "webhooks";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(WebhooksEsbSource.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksEsbSource()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the ingest process and its triggers (no-op without destinations).
|
||||
*******************************************************************************/
|
||||
public static void configure(QInstance qInstance)
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config(qInstance);
|
||||
if(config == null || config.getEsbDestinations().isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
QProcessMetaData process = new QProcessMetaData()
|
||||
.withName(PROCESS_NAME)
|
||||
.withLabel("Queue Webhooks From ESB Events")
|
||||
.withIsHidden(true)
|
||||
.withPermissionRules(WebhooksTables.permissionRules())
|
||||
.withStep(new QBackendStepMetaData().withName("ingest").withCode(new QCodeReference(IngestStep.class)));
|
||||
EsbProcessMetaData esb = EsbProcessMetaData.ofOrWithNew(process);
|
||||
for(String destination : config.getEsbDestinations())
|
||||
{
|
||||
esb.withTrigger(new EsbTrigger()
|
||||
.withDestinationName(destination)
|
||||
.withSubscriptionName(SUBSCRIPTION_NAME)
|
||||
.withConcurrency(1)
|
||||
.withMaxAttempts(5));
|
||||
}
|
||||
qInstance.addProcess(process);
|
||||
LOG.info("Webhooks ESB source configured", logPair("destinations", config.getEsbDestinations()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The trigger's step.
|
||||
*******************************************************************************/
|
||||
public static class IngestStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Queue deliveries for the batch of events.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Object value = input.getValue(EsbTriggerHandler.VALUE_ESB_MESSAGES);
|
||||
if(!(value instanceof List<?> list))
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<WebhookEvent> events = new ArrayList<>();
|
||||
for(Object item : list)
|
||||
{
|
||||
if(item instanceof EsbEvent esbEvent)
|
||||
{
|
||||
WebhookEvent event = toWebhookEvent(QContext.getQInstance(), esbEvent);
|
||||
if(event != null)
|
||||
{
|
||||
events.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
int queued = DeliveryOutbox.enqueue(events, null).size();
|
||||
output.addValue("queued", queued);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a CloudEvent to a webhook event, or null to ignore it.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static WebhookEvent toWebhookEvent(QInstance qInstance, EsbEvent esbEvent)
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config(qInstance);
|
||||
if(config == null || esbEvent == null || !StringUtils.hasContent(esbEvent.getType()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(Boolean.TRUE.equals(config.getEsbSkipOwnEvents()) && isOwnEvent(qInstance, esbEvent))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
WebhooksRegistry registry = WebhooksRegistry.of(qInstance);
|
||||
String type = esbEvent.getType();
|
||||
Map<String, Serializable> data = esbEvent.getData() == null ? Map.of() : esbEvent.getData();
|
||||
WebhookEvent event = new WebhookEvent()
|
||||
.withSource(EventSource.ESB)
|
||||
.withSourceEventId(esbEvent.getId())
|
||||
.withSourceUri(esbEvent.getSource())
|
||||
.withOccurredAt(esbEvent.getTime() == null ? WebhooksRuntime.clock().instant() : esbEvent.getTime());
|
||||
|
||||
if(type.startsWith("qqq.table."))
|
||||
{
|
||||
String rest = type.substring("qqq.table.".length());
|
||||
int dot = rest.lastIndexOf('.');
|
||||
if(dot <= 0)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String table = rest.substring(0, dot);
|
||||
String suffix = rest.substring(dot + 1);
|
||||
WebhookEventKind kind = switch(suffix)
|
||||
{
|
||||
case "inserted" -> WebhookEventKind.TABLE_INSERT;
|
||||
case "updated" -> WebhookEventKind.TABLE_UPDATE;
|
||||
case "deleted" -> WebhookEventKind.TABLE_DELETE;
|
||||
default -> null;
|
||||
};
|
||||
if(kind == null || registry.get(WebhooksRegistry.tableTypeName(table, kind)) == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord record = toRecord(table, data.get("record"));
|
||||
QRecord oldRecord = toRecord(table, data.get("oldRecord"));
|
||||
return (event.withEventTypeName(WebhooksRegistry.tableTypeName(table, kind))
|
||||
.withSourceTable(table)
|
||||
.withSourceRecordId(esbEvent.getSubject())
|
||||
.withRecord(kind == WebhookEventKind.TABLE_DELETE ? null : record)
|
||||
.withOldRecord(oldRecord));
|
||||
}
|
||||
|
||||
if(type.startsWith("qqq.process."))
|
||||
{
|
||||
String rest = type.substring("qqq.process.".length());
|
||||
int dot = rest.lastIndexOf('.');
|
||||
if(dot <= 0)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String process = rest.substring(0, dot);
|
||||
String suffix = rest.substring(dot + 1);
|
||||
WebhookEventKind kind = "completed".equals(suffix) ? WebhookEventKind.PROCESS_COMPLETED : "failed".equals(suffix) ? WebhookEventKind.PROCESS_FAILED : null;
|
||||
if(kind == null || registry.get(WebhooksRegistry.processTypeName(process, kind)) == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (event.withEventTypeName(WebhooksRegistry.processTypeName(process, kind)).withData(new LinkedHashMap<>(data)));
|
||||
}
|
||||
|
||||
WebhookEventType custom = registry.get(type);
|
||||
if(custom == null || custom.getKind() != WebhookEventKind.CUSTOM)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (event.withEventTypeName(type).withData(new LinkedHashMap<>(data)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when this instance published the event (source qqq://<me>/...).
|
||||
*******************************************************************************/
|
||||
static boolean isOwnEvent(QInstance qInstance, EsbEvent esbEvent)
|
||||
{
|
||||
EsbInstanceMetaData esb = QSupplementalInstanceMetaData.of(qInstance, EsbInstanceMetaData.NAME);
|
||||
String name = esb == null || esb.getInstanceName() == null ? "" : esb.getInstanceName();
|
||||
return (esbEvent.getSource() != null && esbEvent.getSource().startsWith("qqq://" + name + "/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A map of values as a record.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static QRecord toRecord(String table, Object values)
|
||||
{
|
||||
if(!(values instanceof Map<?, ?> map))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord record = new QRecord().withTableName(table);
|
||||
for(Map.Entry<?, ?> entry : map.entrySet())
|
||||
{
|
||||
if(entry.getKey() != null)
|
||||
{
|
||||
record.setValue(String.valueOf(entry.getKey()), (Serializable) entry.getValue());
|
||||
}
|
||||
}
|
||||
return (record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.events;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.DeliveryStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turns events into {@code webhooks_delivery} rows: one per matching
|
||||
** subscription, each with its own event id and a stored payload, so retries
|
||||
** send identical bytes.
|
||||
**
|
||||
** <p>When called with the source write's transaction (same backend), the
|
||||
** deliveries commit or roll back with the write (a transactional outbox).
|
||||
** Every delivery has a {@code dedupKey} of {@code <sourceEventId>:<subscription>}
|
||||
** (unique), so an event that arrives twice (ESB redelivery, a retried
|
||||
** trigger) is queued once.</p>
|
||||
*******************************************************************************/
|
||||
public final class DeliveryOutbox
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(DeliveryOutbox.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private DeliveryOutbox()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue events for every matching subscription. Returns the delivery rows
|
||||
** inserted.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> enqueue(List<WebhookEvent> events, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
if(config == null || events.isEmpty())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
Set<String> typeNames = new LinkedHashSet<>();
|
||||
events.forEach(e -> typeNames.add(e.getEventTypeName()));
|
||||
Map<String, List<SubscriptionMatcher.Target>> targets = SubscriptionMatcher.targetsByType(typeNames);
|
||||
if(targets.isEmpty())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
|
||||
WebhooksRegistry registry = WebhooksRegistry.of(QContext.getQInstance());
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
for(WebhookEvent event : events)
|
||||
{
|
||||
WebhookEventType type = registry.get(event.getEventTypeName());
|
||||
for(SubscriptionMatcher.Target target : targets.getOrDefault(event.getEventTypeName(), List.of()))
|
||||
{
|
||||
if(SubscriptionMatcher.matches(config, type, event, target.subscription()))
|
||||
{
|
||||
rows.add(row(config, event, type, target.subscription(), target.endpoint().getValueInteger("id"),
|
||||
event.getSourceEventId() + ":" + target.subscription().getValue("id")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (insert(rows, transaction));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue one event for one endpoint, with no subscription (a channel
|
||||
** delivery from another qbit, or a test send).
|
||||
**
|
||||
** @param idempotencyKey makes repeated calls queue once (null: always queue)
|
||||
*******************************************************************************/
|
||||
public static QRecord enqueueDirect(WebhookEvent event, Integer endpointId, String idempotencyKey) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
if(config == null)
|
||||
{
|
||||
throw (new QException("The webhooks qbit is not installed"));
|
||||
}
|
||||
WebhookEventType type = WebhooksRegistry.of(QContext.getQInstance()).get(event.getEventTypeName());
|
||||
String dedupKey = (idempotencyKey == null ? event.getSourceEventId() : idempotencyKey) + ":endpoint:" + endpointId;
|
||||
List<QRecord> inserted = insert(List.of(row(config, event, type, null, endpointId, dedupKey)), null);
|
||||
if(inserted.isEmpty())
|
||||
{
|
||||
List<QRecord> existing = SystemWrites.query(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("dedupKey", QCriteriaOperator.EQUALS, dedupKey)));
|
||||
if(existing.isEmpty())
|
||||
{
|
||||
throw (new QException("Could not queue the webhook delivery"));
|
||||
}
|
||||
return (existing.get(0));
|
||||
}
|
||||
return (inserted.get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A delivery row.
|
||||
*******************************************************************************/
|
||||
private static QRecord row(WebhooksQBitConfig config, WebhookEvent event, WebhookEventType type, QRecord subscription, Integer endpointId, String dedupKey) throws QException
|
||||
{
|
||||
Instant now = WebhooksRuntime.clock().instant();
|
||||
String eventId = "evt_" + UUID.randomUUID().toString().replace("-", "");
|
||||
if(event.getOccurredAt() == null)
|
||||
{
|
||||
event.withOccurredAt(now);
|
||||
}
|
||||
String payload = PayloadBuilder.build(event, type, subscription, eventId);
|
||||
|
||||
QRecord row = new QRecord()
|
||||
.withValue("eventId", eventId)
|
||||
.withValue("eventTypeName", event.getEventTypeName())
|
||||
.withValue("endpointId", endpointId)
|
||||
.withValue("subscriptionId", subscription == null ? null : subscription.getValue("id"))
|
||||
.withValue("source", event.getSource().name())
|
||||
.withValue("sourceTable", event.getSourceTable())
|
||||
.withValue("sourceRecordId", event.getSourceRecordId())
|
||||
.withValue("sourceEventId", event.getSourceEventId())
|
||||
.withValue("dedupKey", dedupKey)
|
||||
.withValue("status", DeliveryStatus.PENDING.name())
|
||||
.withValue("attemptCount", 0)
|
||||
.withValue("cycleAttemptCount", 0)
|
||||
.withValue("replayCount", 0)
|
||||
.withValue("occurredDate", event.getOccurredAt())
|
||||
.withValue("nextAttemptDate", now)
|
||||
.withValue("payload", payload)
|
||||
.withValue("payloadBytes", payload.getBytes(StandardCharsets.UTF_8).length);
|
||||
|
||||
QRecord subject = event.subjectRecord();
|
||||
for(QFieldMetaData field : config.getSecurityFields())
|
||||
{
|
||||
Object value = subject == null ? null : subject.getValue(field.getName());
|
||||
if(value == null && subscription != null)
|
||||
{
|
||||
value = subscription.getValue(field.getName());
|
||||
}
|
||||
row.setValue(field.getName(), (java.io.Serializable) value);
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert, skipping rows whose dedupKey already exists.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> insert(List<QRecord> rows, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
return (rows);
|
||||
}
|
||||
List<String> keys = rows.stream().map(r -> r.getValueString("dedupKey")).toList();
|
||||
Set<String> seen = new HashSet<>();
|
||||
for(QRecord existing : SystemWrites.query(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("dedupKey", QCriteriaOperator.IN, new ArrayList<>(keys))), transaction))
|
||||
{
|
||||
seen.add(existing.getValueString("dedupKey"));
|
||||
}
|
||||
List<QRecord> fresh = new ArrayList<>();
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
if(seen.add(row.getValueString("dedupKey")))
|
||||
{
|
||||
fresh.add(row);
|
||||
}
|
||||
}
|
||||
if(fresh.isEmpty())
|
||||
{
|
||||
return (fresh);
|
||||
}
|
||||
List<QRecord> inserted = new ArrayList<>();
|
||||
for(QRecord record : SystemWrites.insert(WebhooksTables.DELIVERY, fresh, transaction).getRecords())
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
LOG.info("A webhook delivery was not queued (probably a duplicate)", logPair("dedupKey", record.getValueString("dedupKey")),
|
||||
logPair("error", record.getErrors().get(0).getMessage()));
|
||||
}
|
||||
else
|
||||
{
|
||||
inserted.add(record);
|
||||
}
|
||||
}
|
||||
return (inserted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.events;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.DetailLevel;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookPayloadCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds the JSON body of a delivery.
|
||||
**
|
||||
** <pre>
|
||||
** {
|
||||
** "id": "<eventId, same as the webhook-id header>",
|
||||
** "type": "cms_blog_post.updated",
|
||||
** "timestamp": "2026-10-12T20:15:00Z",
|
||||
** "source": "qqq://kof22/table/cms_blog_post",
|
||||
** "sourceEventId": "<the same for every endpoint that gets this event>",
|
||||
** "data": {
|
||||
** "table": "cms_blog_post", "primaryKey": "42",
|
||||
** "record": { ... }, "oldRecord": { ... }, "changedFields": [ ... ]
|
||||
** }
|
||||
** }
|
||||
** </pre>
|
||||
**
|
||||
** <p>Process and custom events put their own object in {@code data}. The
|
||||
** subscription shapes the record: its API name and version render it through
|
||||
** the QQQ API (stable, versioned field names) when the API module is present;
|
||||
** the detail level, include and exclude lists then trim it; finally the event
|
||||
** type's code customizer may change {@code data}.</p>
|
||||
*******************************************************************************/
|
||||
public final class PayloadBuilder
|
||||
{
|
||||
private static final boolean API_PRESENT = classPresent("com.kingsrook.qqq.api.actions.QRecordApiAdapter");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PayloadBuilder()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The body for one delivery.
|
||||
**
|
||||
** @param subscription the subscription row, or null for a directed delivery
|
||||
** (channel or test), which gets the full data
|
||||
*******************************************************************************/
|
||||
public static String build(WebhookEvent event, WebhookEventType type, QRecord subscription, String eventId) throws QException
|
||||
{
|
||||
Map<String, Object> envelope = new LinkedHashMap<>();
|
||||
envelope.put("id", eventId);
|
||||
envelope.put("type", event.getEventTypeName());
|
||||
envelope.put("timestamp", event.getOccurredAt() == null ? null : event.getOccurredAt().toString());
|
||||
envelope.put("source", event.getSourceUri());
|
||||
envelope.put("sourceEventId", event.getSourceEventId());
|
||||
|
||||
Map<String, Object> data = event.subjectRecord() != null && StringUtils.hasContent(event.getSourceTable())
|
||||
? recordData(event, subscription)
|
||||
: shape(new LinkedHashMap<>(event.getData()), subscription, null);
|
||||
|
||||
if(type != null && type.getCustomizer() != null)
|
||||
{
|
||||
WebhookPayloadCustomizerInterface customizer = QCodeLoader.getAdHoc(WebhookPayloadCustomizerInterface.class, type.getCustomizer());
|
||||
customizer.customizeData(event, subscription, data);
|
||||
}
|
||||
envelope.put("data", data);
|
||||
return (JsonUtils.toJsonCustomized(envelope, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The data object for a table event.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> recordData(WebhookEvent event, QRecord subscription) throws QException
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(event.getSourceTable());
|
||||
String pk = table == null ? "id" : table.getPrimaryKeyField();
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("table", event.getSourceTable());
|
||||
data.put("primaryKey", event.getSourceRecordId());
|
||||
|
||||
DetailLevel level = detailLevel(subscription);
|
||||
List<String> changed = changedFields(event);
|
||||
if(event.getRecord() != null && event.getOldRecord() != null)
|
||||
{
|
||||
data.put("changedFields", changed);
|
||||
}
|
||||
|
||||
if(event.getRecord() != null)
|
||||
{
|
||||
data.put("record", shape(values(event.getRecord(), event.getOldRecord(), table, subscription), subscription, level == DetailLevel.CHANGED_FIELDS ? changedPlus(changed, pk) : null, pk));
|
||||
}
|
||||
if(event.getOldRecord() != null && (event.getRecord() == null || level == DetailLevel.FULL))
|
||||
{
|
||||
data.put("oldRecord", shape(values(event.getOldRecord(), null, table, subscription), subscription, null, pk));
|
||||
}
|
||||
return (data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record's values: the old values overlaid with the new (an update
|
||||
** carries only the changed fields), rendered through the API if asked.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> values(QRecord record, QRecord base, QTableMetaData table, QRecord subscription) throws QException
|
||||
{
|
||||
QRecord merged = new QRecord();
|
||||
if(base != null && base.getValues() != null)
|
||||
{
|
||||
merged.getValues().putAll(base.getValues());
|
||||
}
|
||||
if(record.getValues() != null)
|
||||
{
|
||||
merged.getValues().putAll(record.getValues());
|
||||
}
|
||||
String apiName = subscription == null ? null : subscription.getValueString("apiName");
|
||||
String apiVersion = subscription == null ? null : subscription.getValueString("apiVersion");
|
||||
if(API_PRESENT && table != null && StringUtils.hasContent(apiName) && StringUtils.hasContent(apiVersion))
|
||||
{
|
||||
merged.setTableName(table.getName());
|
||||
Map<String, Serializable> api = com.kingsrook.qqq.api.actions.QRecordApiAdapter.qRecordToApiMap(merged, table.getName(), apiName, apiVersion);
|
||||
return (api == null ? new LinkedHashMap<>() : new LinkedHashMap<>(api));
|
||||
}
|
||||
return (new LinkedHashMap<>(merged.getValues()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the detail level and the include and exclude lists.
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> shape(Map<String, Object> values, QRecord subscription, Set<String> only, String... alwaysKeep)
|
||||
{
|
||||
DetailLevel level = detailLevel(subscription);
|
||||
Set<String> include = fieldList(subscription, "includeFields");
|
||||
Set<String> exclude = fieldList(subscription, "excludeFields");
|
||||
List<String> keep = alwaysKeep == null ? List.of() : Arrays.stream(alwaysKeep).filter(Objects::nonNull).toList();
|
||||
|
||||
Map<String, Object> shaped = new LinkedHashMap<>();
|
||||
for(Map.Entry<String, Object> entry : values.entrySet())
|
||||
{
|
||||
String key = entry.getKey();
|
||||
boolean required = keep.contains(key);
|
||||
if(!required)
|
||||
{
|
||||
if(level == DetailLevel.KEYS_ONLY && !keep.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(only != null && !only.contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!include.isEmpty() && !include.contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if(exclude.contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
shaped.put(key, entry.getValue());
|
||||
}
|
||||
return (shaped);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fields whose value differs between the old record and the update.
|
||||
*******************************************************************************/
|
||||
public static List<String> changedFields(WebhookEvent event)
|
||||
{
|
||||
List<String> changed = new ArrayList<>();
|
||||
if(event.getRecord() == null || event.getOldRecord() == null || event.getRecord().getValues() == null)
|
||||
{
|
||||
return (changed);
|
||||
}
|
||||
for(Map.Entry<String, Serializable> entry : event.getRecord().getValues().entrySet())
|
||||
{
|
||||
Serializable old = event.getOldRecord().getValue(entry.getKey());
|
||||
if(!Objects.equals(ValueUtils.getValueAsString(old), ValueUtils.getValueAsString(entry.getValue())))
|
||||
{
|
||||
changed.add(entry.getKey());
|
||||
}
|
||||
}
|
||||
return (changed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The changed fields plus the primary key.
|
||||
*******************************************************************************/
|
||||
private static Set<String> changedPlus(List<String> changed, String pk)
|
||||
{
|
||||
Set<String> set = new LinkedHashSet<>(changed);
|
||||
set.add(pk);
|
||||
return (set);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The subscription's detail level (FULL when none).
|
||||
*******************************************************************************/
|
||||
private static DetailLevel detailLevel(QRecord subscription)
|
||||
{
|
||||
String value = subscription == null ? null : subscription.getValueString("detailLevel");
|
||||
try
|
||||
{
|
||||
return (value == null ? DetailLevel.FULL : DetailLevel.valueOf(value));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (DetailLevel.FULL);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A comma (or whitespace) separated field list.
|
||||
*******************************************************************************/
|
||||
static Set<String> fieldList(QRecord subscription, String fieldName)
|
||||
{
|
||||
Set<String> set = new LinkedHashSet<>();
|
||||
String value = subscription == null ? null : subscription.getValueString(fieldName);
|
||||
if(StringUtils.hasContent(value))
|
||||
{
|
||||
for(String part : value.split("[,\\s]+"))
|
||||
{
|
||||
if(!part.isBlank())
|
||||
{
|
||||
set.add(part.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return (set);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when a class can be loaded.
|
||||
*******************************************************************************/
|
||||
private static boolean classPresent(String name)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(name, false, PayloadBuilder.class.getClassLoader());
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.events;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventKind;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.helpers.ValidateRecordSecurityLockHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.utils.BackendQueryFilterUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decides which subscriptions get an event.
|
||||
**
|
||||
** <p>A subscription matches when it and its endpoint are not disabled, its
|
||||
** event type is the event's type, and:</p>
|
||||
** <ol>
|
||||
** <li>for a type with a {@code fieldName}: an update changed that field
|
||||
** (to the type's {@code value}, when set);</li>
|
||||
** <li>its record filter (a QQQ query filter, as JSON) matches the record
|
||||
** (the old record for a delete);</li>
|
||||
** <li>record security: when the qbit is configured with security fields and
|
||||
** locks, the subscription's own security values must allow reading the
|
||||
** record, so a subscription never receives records its owner could not
|
||||
** see.</li>
|
||||
** </ol>
|
||||
*******************************************************************************/
|
||||
public final class SubscriptionMatcher
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SubscriptionMatcher.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A subscription with its endpoint.
|
||||
*******************************************************************************/
|
||||
public record Target(QRecord subscription, QRecord endpoint)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SubscriptionMatcher()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The live subscriptions (with endpoints) for some event types, by type.
|
||||
*******************************************************************************/
|
||||
public static Map<String, List<Target>> targetsByType(Collection<String> typeNames) throws QException
|
||||
{
|
||||
Map<String, List<Target>> byType = new HashMap<>();
|
||||
if(typeNames.isEmpty())
|
||||
{
|
||||
return (byType);
|
||||
}
|
||||
List<QRecord> subscriptions = SystemWrites.query(WebhooksTables.SUBSCRIPTION, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("eventTypeName", QCriteriaOperator.IN, new ArrayList<>(typeNames)))
|
||||
.withCriteria(new QFilterCriteria("activeStatus", QCriteriaOperator.EQUALS, ActiveStatus.ACTIVE.name())));
|
||||
if(subscriptions.isEmpty())
|
||||
{
|
||||
return (byType);
|
||||
}
|
||||
List<Serializable> endpointIds = subscriptions.stream().map(s -> s.getValue("endpointId")).filter(Objects::nonNull).distinct().map(v -> (Serializable) v).toList();
|
||||
Map<Integer, QRecord> endpoints = new HashMap<>();
|
||||
for(QRecord endpoint : SystemWrites.query(WebhooksTables.ENDPOINT, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("id", QCriteriaOperator.IN, endpointIds))
|
||||
.withCriteria(new QFilterCriteria("activeStatus", QCriteriaOperator.NOT_EQUALS, ActiveStatus.DISABLED.name()))))
|
||||
{
|
||||
endpoints.put(endpoint.getValueInteger("id"), endpoint);
|
||||
}
|
||||
for(QRecord subscription : subscriptions)
|
||||
{
|
||||
QRecord endpoint = endpoints.get(subscription.getValueInteger("endpointId"));
|
||||
if(endpoint != null)
|
||||
{
|
||||
byType.computeIfAbsent(subscription.getValueString("eventTypeName"), k -> new ArrayList<>()).add(new Target(subscription, endpoint));
|
||||
}
|
||||
}
|
||||
return (byType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the event should go to the subscription.
|
||||
*******************************************************************************/
|
||||
public static boolean matches(WebhooksQBitConfig config, WebhookEventType type, WebhookEvent event, QRecord subscription)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(type != null && StringUtils.hasContent(type.getFieldName()) && !fieldConditionMet(type, event))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QRecord subject = event.subjectRecord();
|
||||
String filterJson = subscription.getValueString("filterJson");
|
||||
if(StringUtils.hasContent(filterJson) && subject != null)
|
||||
{
|
||||
QRecord full = mergedSubject(event);
|
||||
if(!BackendQueryFilterUtils.doesRecordMatch(parseFilter(filterJson), full))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
return (subject == null || recordSecurityAllows(config, event, subscription));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not evaluate a webhook subscription; the event is not queued for it", e, logPair("subscriptionId", subscription.getValue("id")),
|
||||
logPair("eventType", event.getEventTypeName()));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a subscription filter.
|
||||
**
|
||||
** @throws QException when the JSON is not a query filter
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter parseFilter(String filterJson) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JsonUtils.toObject(filterJson, QQueryFilter.class));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("The record filter is not a valid QQQ query filter (JSON): " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record to test: old values overlaid with the new ones.
|
||||
*******************************************************************************/
|
||||
private static QRecord mergedSubject(WebhookEvent event)
|
||||
{
|
||||
QRecord merged = new QRecord();
|
||||
if(event.getOldRecord() != null && event.getOldRecord().getValues() != null)
|
||||
{
|
||||
merged.getValues().putAll(event.getOldRecord().getValues());
|
||||
}
|
||||
if(event.getRecord() != null && event.getRecord().getValues() != null)
|
||||
{
|
||||
merged.getValues().putAll(event.getRecord().getValues());
|
||||
}
|
||||
merged.setTableName(event.getSourceTable());
|
||||
return (merged);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The type's field (and value) condition.
|
||||
*******************************************************************************/
|
||||
static boolean fieldConditionMet(WebhookEventType type, WebhookEvent event)
|
||||
{
|
||||
String field = type.getFieldName();
|
||||
QRecord record = event.getRecord();
|
||||
if(record == null || record.getValues() == null || !record.getValues().containsKey(field))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
Serializable newValue = record.getValue(field);
|
||||
if(event.getOldRecord() != null && type.getKind() != WebhookEventKind.TABLE_INSERT)
|
||||
{
|
||||
Serializable oldValue = event.getOldRecord().getValue(field);
|
||||
if(Objects.equals(ValueUtils.getValueAsString(oldValue), ValueUtils.getValueAsString(newValue)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
if(type.getValue() == null)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
String newText = ValueUtils.getValueAsString(newValue);
|
||||
if(type.getValue() instanceof Collection<?> values)
|
||||
{
|
||||
return (values.stream().anyMatch(v -> Objects.equals(ValueUtils.getValueAsString((Serializable) v), newText)));
|
||||
}
|
||||
return (Objects.equals(ValueUtils.getValueAsString(type.getValue()), newText));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record security: a pseudo-session with the subscription's security
|
||||
** values (plus all-access keys for lock types the qbit does not manage)
|
||||
** must be allowed to read the record.
|
||||
*******************************************************************************/
|
||||
static boolean recordSecurityAllows(WebhooksQBitConfig config, WebhookEvent event, QRecord subscription) throws QException
|
||||
{
|
||||
QTableMetaData table = event.getSourceTable() == null ? null : QContext.getQInstance().getTable(event.getSourceTable());
|
||||
if(table == null || table.getRecordSecurityLocks() == null || table.getRecordSecurityLocks().isEmpty() || config.getRecordSecurityLocks().isEmpty())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
QSession pseudo = new QSession();
|
||||
for(RecordSecurityLock lock : config.getRecordSecurityLocks())
|
||||
{
|
||||
Serializable value = subscription.getValue(lock.getFieldName());
|
||||
if(value != null)
|
||||
{
|
||||
pseudo.withSecurityKeyValue(lock.getSecurityKeyType(), value);
|
||||
}
|
||||
}
|
||||
if(pseudo.getSecurityKeyValues() == null || pseudo.getSecurityKeyValues().isEmpty())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
List<String> managedTypes = config.getRecordSecurityLocks().stream().map(RecordSecurityLock::getSecurityKeyType).toList();
|
||||
for(QSecurityKeyType keyType : QContext.getQInstance().getSecurityKeyTypes().values())
|
||||
{
|
||||
if(!managedTypes.contains(keyType.getName()) && StringUtils.hasContent(keyType.getAllAccessKeyName()))
|
||||
{
|
||||
pseudo.withSecurityKeyValue(keyType.getAllAccessKeyName(), true);
|
||||
}
|
||||
}
|
||||
return (ValidateRecordSecurityLockHelper.allowedToReadRecord(table, mergedSubject(event), pseudo, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.events;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.EventSource;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Something that happened, before it is queued for the endpoints that
|
||||
** subscribe to it.
|
||||
**
|
||||
** <p>{@code sourceEventId} identifies the happening across every endpoint it
|
||||
** goes to (the ESB CloudEvent id for ESB events, else a new UUID). Each
|
||||
** delivery also gets its own {@code eventId}, sent as {@code webhook-id}.</p>
|
||||
*******************************************************************************/
|
||||
public class WebhookEvent
|
||||
{
|
||||
private String eventTypeName;
|
||||
private EventSource source = EventSource.CUSTOM;
|
||||
private String sourceTable;
|
||||
private String sourceRecordId;
|
||||
private String sourceEventId = UUID.randomUUID().toString();
|
||||
private String sourceUri;
|
||||
private Instant occurredAt;
|
||||
private QRecord record;
|
||||
private QRecord oldRecord;
|
||||
private Map<String, Object> data = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for eventTypeName.
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withEventTypeName(String eventTypeName)
|
||||
{
|
||||
this.eventTypeName = eventTypeName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for source.
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withSource(EventSource source)
|
||||
{
|
||||
this.source = source;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceTable.
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withSourceTable(String sourceTable)
|
||||
{
|
||||
this.sourceTable = sourceTable;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceRecordId.
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceEventId.
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withSourceEventId(String sourceEventId)
|
||||
{
|
||||
this.sourceEventId = sourceEventId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceUri (e.g. qqq://kof22/table/cms_blog_post).
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withSourceUri(String sourceUri)
|
||||
{
|
||||
this.sourceUri = sourceUri;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for occurredAt.
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for record (the new values; for a delete, null).
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withRecord(QRecord record)
|
||||
{
|
||||
this.record = record;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for oldRecord (update and delete).
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withOldRecord(QRecord oldRecord)
|
||||
{
|
||||
this.oldRecord = oldRecord;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for data (process and custom events).
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withData(Map<String, ?> data)
|
||||
{
|
||||
this.data = data == null ? new LinkedHashMap<>() : new LinkedHashMap<>(data);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record the event is about: the new one, or the old one for a delete.
|
||||
*******************************************************************************/
|
||||
public QRecord subjectRecord()
|
||||
{
|
||||
return (record != null ? record : oldRecord);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for eventTypeName.
|
||||
*******************************************************************************/
|
||||
public String getEventTypeName()
|
||||
{
|
||||
return (eventTypeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for source.
|
||||
*******************************************************************************/
|
||||
public EventSource getSource()
|
||||
{
|
||||
return (source);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceTable.
|
||||
*******************************************************************************/
|
||||
public String getSourceTable()
|
||||
{
|
||||
return (sourceTable);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceRecordId.
|
||||
*******************************************************************************/
|
||||
public String getSourceRecordId()
|
||||
{
|
||||
return (sourceRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceEventId.
|
||||
*******************************************************************************/
|
||||
public String getSourceEventId()
|
||||
{
|
||||
return (sourceEventId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceUri.
|
||||
*******************************************************************************/
|
||||
public String getSourceUri()
|
||||
{
|
||||
return (sourceUri);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for occurredAt.
|
||||
*******************************************************************************/
|
||||
public Instant getOccurredAt()
|
||||
{
|
||||
return (occurredAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for record.
|
||||
*******************************************************************************/
|
||||
public QRecord getRecord()
|
||||
{
|
||||
return (record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for oldRecord.
|
||||
*******************************************************************************/
|
||||
public QRecord getOldRecord()
|
||||
{
|
||||
return (oldRecord);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for data.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> getData()
|
||||
{
|
||||
return (data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.events;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.EventSource;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventKind;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The in-process process source: queues {@code process.<name>.completed} and
|
||||
** {@code process.<name>.failed} events for the processes in the config's
|
||||
** {@code processEventSources}.
|
||||
**
|
||||
** <p>The payload carries the process name, label, run id, table and (for a
|
||||
** failure) the error message. It never carries the process's input values,
|
||||
** which can hold anything.</p>
|
||||
*******************************************************************************/
|
||||
public class WebhooksProcessListener implements ProcessLifecycleListenerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(WebhooksProcessListener.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for processes with registered types.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean appliesTo(String processName)
|
||||
{
|
||||
if(processName == null || !WebhooksRuntime.isInstalled())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
WebhooksRegistry registry = WebhooksRegistry.of(QContext.getQInstance());
|
||||
return (!registry.forProcess(processName, WebhookEventKind.PROCESS_COMPLETED).isEmpty() || !registry.forProcess(processName, WebhookEventKind.PROCESS_FAILED).isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Nothing on start.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onProcessStarted(RunProcessInput input)
|
||||
{
|
||||
// only completion and failure are webhook events
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Completed. A run that pauses for a screen is not complete yet.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onProcessCompleted(RunProcessInput input, RunProcessOutput output)
|
||||
{
|
||||
if(output != null && output.getProcessState() != null && output.getProcessState().getNextStepName().isPresent())
|
||||
{
|
||||
return;
|
||||
}
|
||||
queue(input, WebhookEventKind.PROCESS_COMPLETED, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onProcessFailed(RunProcessInput input, Exception exception)
|
||||
{
|
||||
queue(input, WebhookEventKind.PROCESS_FAILED, exception == null ? "Unknown error" : exception.getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build and queue the events.
|
||||
*******************************************************************************/
|
||||
private static void queue(RunProcessInput input, WebhookEventKind kind, String error)
|
||||
{
|
||||
try
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
if(config == null || input == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String processName = input.getProcessName();
|
||||
List<WebhookEventType> types = WebhooksRegistry.of(QContext.getQInstance()).forProcess(processName, kind);
|
||||
if(types.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(processName);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("processName", processName);
|
||||
data.put("processLabel", process == null ? processName : process.getLabel());
|
||||
data.put("processUUID", input.getProcessUUID());
|
||||
data.put("tableName", process == null ? null : process.getTableName());
|
||||
if(error != null)
|
||||
{
|
||||
data.put("error", error.length() > 1000 ? error.substring(0, 1000) : error);
|
||||
}
|
||||
List<WebhookEvent> events = new ArrayList<>();
|
||||
for(WebhookEventType type : types)
|
||||
{
|
||||
events.add(new WebhookEvent()
|
||||
.withEventTypeName(type.getName())
|
||||
.withSource(EventSource.PROCESS)
|
||||
.withSourceUri("qqq://" + config.getSourceName() + "/process/" + processName)
|
||||
.withSourceEventId(input.getProcessUUID() == null ? java.util.UUID.randomUUID().toString() : input.getProcessUUID() + "-" + kind.getSuffix())
|
||||
.withOccurredAt(WebhooksRuntime.clock().instant())
|
||||
.withData(data));
|
||||
}
|
||||
DeliveryOutbox.enqueue(events, null);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not queue webhook deliveries for a process event", e, logPair("process", input == null ? null : input.getProcessName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.events;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.EventSource;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventKind;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The in-process table source: QQQ's record change listener hears every
|
||||
** insert, update and delete (deletes too, unlike the old post-insert and
|
||||
** post-update customizers), and queues deliveries for subscribed types.
|
||||
**
|
||||
** <p>When the write has a transaction on the qbit's own backend, deliveries
|
||||
** are inserted in it (they commit or roll back with the write). With a
|
||||
** transaction on another backend they are inserted after it commits. With no
|
||||
** transaction they are inserted at once. A failure here is logged and never
|
||||
** fails the caller's write.</p>
|
||||
*******************************************************************************/
|
||||
public class WebhooksRecordChangeListener implements RecordChangeListenerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(WebhooksRecordChangeListener.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for a table with registered event types of the change's kind.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean appliesTo(String tableName, RecordChangeType type)
|
||||
{
|
||||
if(tableName == null || WebhooksTables.ALL.contains(tableName) || !WebhooksRuntime.isInstalled())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (!WebhooksRegistry.of(QContext.getQInstance()).forTable(tableName, kinds(type)).isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue the deliveries.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onRecordsChanged(RecordChangeEvent change) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
if(config == null || change == null || change.getRecords() == null || change.getRecords().isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<WebhookEventType> types = WebhooksRegistry.of(QContext.getQInstance()).forTable(change.getTableName(), kinds(change.getType()));
|
||||
List<WebhookEvent> events = events(config, change, types);
|
||||
if(events.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QBackendTransaction transaction = change.getTransaction();
|
||||
QTableMetaData table = QContext.getQInstance().getTable(change.getTableName());
|
||||
boolean sameBackend = table != null && config.getBackendName().equals(table.getBackendName());
|
||||
if(transaction != null && !sameBackend)
|
||||
{
|
||||
transaction.addAfterCommitCallback(() -> enqueue(events, null, change.getTableName()));
|
||||
}
|
||||
else
|
||||
{
|
||||
enqueue(events, sameBackend ? transaction : null, change.getTableName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enqueue, logging any failure.
|
||||
*******************************************************************************/
|
||||
private static void enqueue(List<WebhookEvent> events, QBackendTransaction transaction, String tableName)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeliveryOutbox.enqueue(events, transaction);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not queue webhook deliveries for a record change", e, logPair("table", tableName), logPair("events", events.size()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One event per record per type.
|
||||
*******************************************************************************/
|
||||
static List<WebhookEvent> events(WebhooksQBitConfig config, RecordChangeEvent change, List<WebhookEventType> types)
|
||||
{
|
||||
List<WebhookEvent> events = new ArrayList<>();
|
||||
QTableMetaData table = QContext.getQInstance().getTable(change.getTableName());
|
||||
String pk = table == null ? "id" : table.getPrimaryKeyField();
|
||||
List<QRecord> olds = change.getOldRecords();
|
||||
for(int i = 0; i < change.getRecords().size(); i++)
|
||||
{
|
||||
QRecord record = change.getRecords().get(i);
|
||||
QRecord old = olds != null && i < olds.size() ? olds.get(i) : null;
|
||||
if(record == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean isDelete = change.getType() == RecordChangeType.DELETE;
|
||||
QRecord subject = isDelete ? (old != null ? old : record) : record;
|
||||
Object key = subject.getValue(pk);
|
||||
if(key == null && old != null)
|
||||
{
|
||||
key = old.getValue(pk);
|
||||
}
|
||||
for(WebhookEventType type : types)
|
||||
{
|
||||
events.add(new WebhookEvent()
|
||||
.withEventTypeName(type.getName())
|
||||
.withSource(EventSource.TABLE)
|
||||
.withSourceTable(change.getTableName())
|
||||
.withSourceRecordId(key == null ? null : ValueUtils.getValueAsString(key))
|
||||
.withSourceUri("qqq://" + config.getSourceName() + "/table/" + change.getTableName())
|
||||
.withOccurredAt(WebhooksRuntime.clock().instant())
|
||||
.withRecord(isDelete ? null : record)
|
||||
.withOldRecord(isDelete ? subject : old));
|
||||
}
|
||||
}
|
||||
return (events);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The event kinds a change type fires.
|
||||
*******************************************************************************/
|
||||
static List<WebhookEventKind> kinds(RecordChangeType type)
|
||||
{
|
||||
if(type == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
return (switch(type)
|
||||
{
|
||||
case INSERT -> List.of(WebhookEventKind.TABLE_INSERT, WebhookEventKind.TABLE_STORE);
|
||||
case UPDATE -> List.of(WebhookEventKind.TABLE_UPDATE, WebhookEventKind.TABLE_STORE);
|
||||
case DELETE -> List.of(WebhookEventKind.TABLE_DELETE);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.health;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.DeliveryStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.HealthStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthDetails;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthMetric;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Passive central checks over the existing endpoint state machine and outbox.
|
||||
*******************************************************************************/
|
||||
public class WebhooksHealthChecks implements HealthCheckSupplier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Register the dispatcher and dynamic endpoint supplier from the producer.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
HealthChecksMetaData.ofOrWithNew(instance)
|
||||
.withCheck(definition("webhooks.dispatcher", WebhooksHealthChecks::dispatcher))
|
||||
.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix("webhooks.endpoint").withComponent("webhooks")
|
||||
.withSupplier(new QCodeReference(WebhooksHealthChecks.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep auto-disabled endpoints visible until an operator recovers them.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<HealthCheckMetaData> supply(QInstance qInstance) throws QException
|
||||
{
|
||||
List<HealthCheckMetaData> checks = new ArrayList<>();
|
||||
for(QRecord endpoint : SystemWrites.call(() -> new QueryAction().execute(new QueryInput(WebhooksTables.ENDPOINT)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("activeStatus", QCriteriaOperator.IN, ActiveStatus.ACTIVE.name(), ActiveStatus.DISABLED.name())))
|
||||
.withFieldNamesToInclude(Set.of("id", "activeStatus", "autoDisabledDate")).withTimeoutSeconds(5)).getRecords()))
|
||||
{
|
||||
if(monitored(endpoint))
|
||||
{
|
||||
Integer id = endpoint.getValueInteger("id");
|
||||
checks.add(definition("webhooks.endpoint." + id.toString().toLowerCase(Locale.ROOT), input -> endpoint(input, id)));
|
||||
}
|
||||
}
|
||||
return (checks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Common metadata and a boundary that never exposes backend error text.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckMetaData definition(String name, HealthCheck check)
|
||||
{
|
||||
return (new HealthCheckMetaData().withName(name).withLabel(name).withComponent("webhooks")
|
||||
.withCategory(HealthCategory.INTEGRATION).withKind(HealthCheckKind.DEPENDENCY).withIntervalSeconds(300).withTimeoutMs(5000)
|
||||
.withCheckInstance(input ->
|
||||
{
|
||||
if(expired(input))
|
||||
{
|
||||
return (HealthCheckOutcome.down("Health check timed out"));
|
||||
}
|
||||
try
|
||||
{
|
||||
HealthCheckOutcome outcome = check.run(input);
|
||||
return (expired(input) ? HealthCheckOutcome.down("Health check timed out") : outcome);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (HealthCheckOutcome.down("Unable to read webhook health"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the latest EndpointHealth state, without probing or mutating it.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome endpoint(HealthCheckInput input, Integer id) throws QException
|
||||
{
|
||||
List<QRecord> records = query(input, WebhooksTables.ENDPOINT,
|
||||
new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id)).withLimit(1),
|
||||
Set.of("id", "activeStatus", "autoDisabledDate", "healthStatus", "consecutiveFailures", "unhealthySinceDate"));
|
||||
if(records.isEmpty() || !monitored(records.getFirst()))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Endpoint is absent or disabled"));
|
||||
}
|
||||
QRecord endpoint = records.getFirst();
|
||||
String health = endpoint.getValueString("healthStatus");
|
||||
HealthCheckOutcome outcome;
|
||||
if(ActiveStatus.DISABLED.name().equals(endpoint.getValueString("activeStatus")))
|
||||
{
|
||||
outcome = HealthCheckOutcome.down("Endpoint automatically disabled");
|
||||
}
|
||||
else if(HealthStatus.HEALTHY.name().equals(health))
|
||||
{
|
||||
outcome = HealthCheckOutcome.up("Endpoint healthy");
|
||||
}
|
||||
else if(HealthStatus.UNHEALTHY.name().equals(health) || HealthStatus.PROBATION.name().equals(health))
|
||||
{
|
||||
outcome = HealthCheckOutcome.degraded("Endpoint unhealthy or on probation");
|
||||
}
|
||||
else
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Endpoint health is unavailable"));
|
||||
}
|
||||
// Only known state names may enter details, even if persisted data is corrupt.
|
||||
String safeHealth = List.of("HEALTHY", "UNHEALTHY", "PROBATION").contains(health == null ? "" : health) ? health : "UNKNOWN";
|
||||
Integer failures = endpoint.getValueInteger("consecutiveFailures");
|
||||
HealthDetails details = new HealthDetails().withValue("healthStatus", safeHealth)
|
||||
.withValue("consecutiveFailures", failures == null ? 0 : failures);
|
||||
Instant unhealthySince = endpoint.getValueInstant("unhealthySinceDate");
|
||||
if(unhealthySince != null)
|
||||
{
|
||||
details.withValue("unhealthySince", unhealthySince);
|
||||
}
|
||||
return (outcome.withDetails(details));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Age of the oldest queued delivery, including retries and in-flight work.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome dispatcher(HealthCheckInput input) throws QException
|
||||
{
|
||||
List<QRecord> records = query(input, WebhooksTables.DELIVERY,
|
||||
new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN,
|
||||
DeliveryStatus.PENDING.name(), DeliveryStatus.RETRY_WAIT.name(), DeliveryStatus.SENDING.name()))
|
||||
.withOrderBy(new QFilterOrderBy("createDate", true)).withLimit(1), Set.of("createDate"));
|
||||
long seconds = 0;
|
||||
if(!records.isEmpty())
|
||||
{
|
||||
Instant queued = records.getFirst().getValueInstant("createDate");
|
||||
if(queued == null)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Pending delivery age is unavailable"));
|
||||
}
|
||||
seconds = Math.max(0, Duration.between(queued, WebhooksRuntime.clock().instant()).getSeconds());
|
||||
}
|
||||
HealthCheckOutcome outcome = seconds > 3600 ? HealthCheckOutcome.down("Delivery backlog exceeds one hour")
|
||||
: seconds > 900 ? HealthCheckOutcome.degraded("Delivery backlog exceeds fifteen minutes") : HealthCheckOutcome.up("Delivery backlog within threshold");
|
||||
return (outcome.withMetrics(List.of(new HealthMetric().withName("oldestPendingSeconds").withValue(seconds).withUnit("seconds"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only active or automatically disabled endpoints participate.
|
||||
*******************************************************************************/
|
||||
private static boolean monitored(QRecord endpoint)
|
||||
{
|
||||
String active = endpoint.getValueString("activeStatus");
|
||||
return (ActiveStatus.ACTIVE.name().equals(active)
|
||||
|| (ActiveStatus.DISABLED.name().equals(active) && endpoint.getValue("autoDisabledDate") != null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query only allow-listed fields with the remaining database time budget.
|
||||
** The runner enforces the precise deadline when the backend blocks.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(HealthCheckInput input, String table, QQueryFilter filter, Set<String> fields) throws QException
|
||||
{
|
||||
int seconds = (int) Math.max(1, Math.min(5, Duration.between(Instant.now(), input.getDeadline()).toSeconds()));
|
||||
return (SystemWrites.call(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter)
|
||||
.withFieldNamesToInclude(fields).withTimeoutSeconds(seconds)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use wall time for the runner deadline, independently of the domain clock.
|
||||
*******************************************************************************/
|
||||
private static boolean expired(HealthCheckInput input)
|
||||
{
|
||||
return (!Instant.now().isBefore(input.getDeadline()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.health;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One native status line linking to central health, using the viewer's access.
|
||||
*******************************************************************************/
|
||||
public class WebhooksHealthWidget extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read the central component rollup; never run a probe from the home.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String status = "No checks have run yet";
|
||||
if(QContext.getQInstance().getTable(HealthMetaDataProvider.COMPONENT_TABLE) != null)
|
||||
{
|
||||
List<QRecord> records = new QueryAction().execute(new QueryInput(HealthMetaDataProvider.COMPONENT_TABLE)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, "webhooks")).withLimit(1))
|
||||
.withFieldNamesToInclude(Set.of("status"))).getRecords();
|
||||
if(!records.isEmpty())
|
||||
{
|
||||
String stored = records.getFirst().getValueString("status");
|
||||
status = List.of("UP", "DEGRADED", "DOWN", "UNKNOWN").contains(stored == null ? "" : stored) ? stored : HealthStatus.UNKNOWN.name();
|
||||
}
|
||||
}
|
||||
return (new RenderWidgetOutput(new StatisticsData().withCount(status).withCountContext("Webhooks - System Health")
|
||||
.withCountURL(AiLinkBuilder.filteredTableHref(HealthMetaDataProvider.CHECK_TABLE,
|
||||
new QQueryFilter(new QFilterCriteria("component", QCriteriaOperator.EQUALS, "webhooks"))))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's enumerated values. Each is stored as its name (a string) and
|
||||
** shown with its label.
|
||||
*******************************************************************************/
|
||||
public final class WebhooksEnums
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksEnums()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an endpoint (or subscription) is used.
|
||||
*******************************************************************************/
|
||||
public enum ActiveStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
ACTIVE("Active"),
|
||||
PAUSED("Paused"),
|
||||
DISABLED("Disabled");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
ActiveStatus(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Endpoint health (set by the qbit). UNHEALTHY endpoints get no deliveries
|
||||
** until a probation attempt succeeds.
|
||||
*******************************************************************************/
|
||||
public enum HealthStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
HEALTHY("Healthy"),
|
||||
PROBATION("Probation"),
|
||||
UNHEALTHY("Unhealthy");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
HealthStatus(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a delivery is in its life.
|
||||
*******************************************************************************/
|
||||
public enum DeliveryStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
PENDING("Pending"),
|
||||
SENDING("Sending"),
|
||||
RETRY_WAIT("Waiting to retry"),
|
||||
DELIVERED("Delivered"),
|
||||
DEAD_LETTER("Dead letter"),
|
||||
CANCELLED("Cancelled");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
DeliveryStatus(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How one delivery attempt ended.
|
||||
*******************************************************************************/
|
||||
public enum AttemptOutcome implements PossibleValueEnum<String>
|
||||
{
|
||||
IN_FLIGHT("In flight"),
|
||||
SUCCEEDED("Succeeded"),
|
||||
FAILED("Failed"),
|
||||
RATE_LIMITED("Rate limited (429)"),
|
||||
BLOCKED("Blocked by policy"),
|
||||
LEASE_EXPIRED("Lease expired");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
AttemptOutcome(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What produced an event.
|
||||
*******************************************************************************/
|
||||
public enum EventSource implements PossibleValueEnum<String>
|
||||
{
|
||||
TABLE("Table change"),
|
||||
ESB("ESB event"),
|
||||
PROCESS("Process"),
|
||||
CUSTOM("Custom event"),
|
||||
CHANNEL("Channel (another qbit)"),
|
||||
TEST("Test send");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
EventSource(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A signing secret's place in rotation.
|
||||
*******************************************************************************/
|
||||
public enum SecretStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
ACTIVE("Active"),
|
||||
PREVIOUS("Previous (still signing until it expires)"),
|
||||
RETIRED("Retired");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
SecretStatus(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a signing secret's value lives.
|
||||
*******************************************************************************/
|
||||
public enum SecretSource implements PossibleValueEnum<String>
|
||||
{
|
||||
GENERATED("Generated (stored encrypted)"),
|
||||
REFERENCE("Environment reference");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
SecretSource(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How much of the record a subscription sends.
|
||||
*******************************************************************************/
|
||||
public enum DetailLevel implements PossibleValueEnum<String>
|
||||
{
|
||||
FULL("Full record"),
|
||||
CHANGED_FIELDS("Changed fields only"),
|
||||
KEYS_ONLY("Keys only");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
DetailLevel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (name());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.metadata;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-webhooks.yaml (the QQQ help-bundle format) to the
|
||||
** Webhooks app and its webhooks_* tables: what each table is for and how its
|
||||
** rows get created, shown on the table screen and when it is empty.
|
||||
** Retains inline empty-state help for hosts without the helpContent table.
|
||||
** Core owns bundle parsing, registration and persistent help synchronization.
|
||||
*******************************************************************************/
|
||||
public final class WebhooksHelp
|
||||
{
|
||||
public static final String RESOURCE = "help/qbit-webhooks.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, HelpBundleEntry> entries() throws QException
|
||||
{
|
||||
Map<String, HelpBundleEntry> entries = new LinkedHashMap<>();
|
||||
for(HelpBundleEntry entry : HelpBundles.load(RESOURCE, WebhooksHelp.class.getClassLoader()).getEntries())
|
||||
{
|
||||
entries.put(entry.getKey(), entry);
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every webhooks_* table in the instance.
|
||||
** A host's own help on a table or the app is kept (the bundle only fills
|
||||
** the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, WebhooksQBitConfig config) throws QException
|
||||
{
|
||||
Map<String, HelpBundleEntry> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
HelpBundleEntry entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(config.getAppName());
|
||||
HelpBundleEntry appEntry = entries.get("app:webhooks;slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(HelpBundleEntry entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.getSummary().strip() + "\n\n" + entry.getContent().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.events.SubscriptionMatcher;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.SecretSource;
|
||||
import com.kingsrook.qbits.webhooks.permissions.WebhooksPermissionChecker;
|
||||
import com.kingsrook.qbits.webhooks.permissions.WebhooksPermissions;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
|
||||
import com.kingsrook.qbits.webhooks.secrets.EnvironmentSecrets;
|
||||
import com.kingsrook.qbits.webhooks.shared.http.AddressRules;
|
||||
import com.kingsrook.qbits.webhooks.shared.http.SafeHttpException;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validation that runs on every write to the people-editable tables, so the
|
||||
** UI, the API and code all get the same rules.
|
||||
*******************************************************************************/
|
||||
public final class WebhooksTableCustomizers
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksTableCustomizers()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The old record for a record being updated, if any.
|
||||
*******************************************************************************/
|
||||
static QRecord old(Optional<List<QRecord>> oldRecordList, QRecord record)
|
||||
{
|
||||
if(oldRecordList == null || oldRecordList.isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Serializable id = record.getValue("id");
|
||||
for(QRecord candidate : oldRecordList.get())
|
||||
{
|
||||
if(Objects.equals(ValueUtils.getValueAsString(candidate.getValue("id")), ValueUtils.getValueAsString(id)))
|
||||
{
|
||||
return (candidate);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The value a record will have after the write: the new value if given,
|
||||
** else the old one.
|
||||
*******************************************************************************/
|
||||
static Serializable effective(QRecord record, QRecord old, String field)
|
||||
{
|
||||
if(record.getValues().containsKey(field))
|
||||
{
|
||||
return (record.getValue(field));
|
||||
}
|
||||
return (old == null ? null : old.getValue(field));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** webhooks_endpoint: URL policy, and internal-network settings for admins
|
||||
** only.
|
||||
*******************************************************************************/
|
||||
public static class EndpointCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Validate.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord old = old(oldRecordList, record);
|
||||
|
||||
boolean internalChanged = changed(record, old, "allowInternalTargets") || changed(record, old, "internalAllowlist");
|
||||
boolean internalSet = Boolean.TRUE.equals(ValueUtils.getValueAsBoolean(effective(record, old, "allowInternalTargets")))
|
||||
|| StringUtils.hasContent(ValueUtils.getValueAsString(effective(record, old, "internalAllowlist")));
|
||||
if(internalChanged && (old != null || internalSet) && !SystemWrites.active() && !WebhooksPermissionChecker.has(WebhooksPermissions.ADMIN))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Only webhook administrators (" + WebhooksPermissions.ADMIN + ") can change internal-network targets."));
|
||||
continue;
|
||||
}
|
||||
|
||||
List<String> allowlist = new ArrayList<>();
|
||||
String text = ValueUtils.getValueAsString(effective(record, old, "internalAllowlist"));
|
||||
if(StringUtils.hasContent(text))
|
||||
{
|
||||
allowlist.addAll(Arrays.stream(text.split("[,\\s]+")).filter(s -> !s.isBlank()).toList());
|
||||
try
|
||||
{
|
||||
new AddressRules(allowlist);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Internal allowlist: " + e.getMessage()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if(record.getValues().containsKey("url") || old == null)
|
||||
{
|
||||
String url = ValueUtils.getValueAsString(effective(record, old, "url"));
|
||||
boolean allowInternal = Boolean.TRUE.equals(ValueUtils.getValueAsBoolean(effective(record, old, "allowInternalTargets")));
|
||||
try
|
||||
{
|
||||
WebhooksRuntime.httpClient(WebhooksRuntime.policyFor(config, null, allowInternal ? allowlist : List.of())).checkUrl(url);
|
||||
}
|
||||
catch(SafeHttpException e)
|
||||
{
|
||||
if(e.getReason() != SafeHttpException.Reason.DNS_FAILURE)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("URL: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Integer perMinute = ValueUtils.getValueAsInteger(effective(record, old, "maxPerMinute"));
|
||||
Integer timeout = ValueUtils.getValueAsInteger(effective(record, old, "timeoutSeconds"));
|
||||
if(perMinute != null && perMinute < 1)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Max deliveries per minute must be at least 1 (leave it empty for no limit)."));
|
||||
}
|
||||
if(timeout != null && (timeout < 1 || timeout * 1000L > config.getTotalTimeoutMillis()))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Timeout must be between 1 and " + (config.getTotalTimeoutMillis() / 1000) + " seconds."));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the write sets a field to a different value.
|
||||
*******************************************************************************/
|
||||
private static boolean changed(QRecord record, QRecord old, String field)
|
||||
{
|
||||
if(!record.getValues().containsKey(field))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
Serializable value = record.getValue(field);
|
||||
if(old == null)
|
||||
{
|
||||
return (Boolean.TRUE.equals(value) || (value instanceof String text && !text.isBlank() && !"false".equalsIgnoreCase(text)));
|
||||
}
|
||||
return (!Objects.equals(ValueUtils.getValueAsString(old.getValue(field)), ValueUtils.getValueAsString(value)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** webhooks_subscription: a known event type, a valid filter, and read access
|
||||
** to the event's table for the person subscribing.
|
||||
*******************************************************************************/
|
||||
public static class SubscriptionCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Validate.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
WebhooksRegistry registry = WebhooksRegistry.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord old = old(oldRecordList, record);
|
||||
String typeName = ValueUtils.getValueAsString(effective(record, old, "eventTypeName"));
|
||||
WebhookEventType type = registry.get(typeName);
|
||||
if(type == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Unknown event type: " + typeName));
|
||||
continue;
|
||||
}
|
||||
String filterJson = ValueUtils.getValueAsString(effective(record, old, "filterJson"));
|
||||
if(StringUtils.hasContent(filterJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
SubscriptionMatcher.parseFilter(filterJson);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(e.getMessage()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if(!SystemWrites.active() && type.getTableName() != null && QContext.getQInstance().getTable(type.getTableName()) != null
|
||||
&& QContext.getQSession() != null && !PermissionsHelper.hasTablePermission(input, type.getTableName(), TablePermissionSubType.READ))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("You cannot subscribe to " + typeName + " because you cannot read the " + type.getTableName() + " table."));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** webhooks_endpoint_secret: people add environment references only;
|
||||
** generated secrets come from the Rotate Signing Secret action.
|
||||
*******************************************************************************/
|
||||
public static class SecretCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Validate.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(SystemWrites.active())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord old = old(oldRecordList, record);
|
||||
if(record.getValues().containsKey("encryptedSecret") || record.getValues().containsKey("fingerprint") && old == null)
|
||||
{
|
||||
record.removeValue("encryptedSecret");
|
||||
record.removeValue("fingerprint");
|
||||
}
|
||||
String source = ValueUtils.getValueAsString(effective(record, old, "source"));
|
||||
if(old == null && !SecretSource.REFERENCE.name().equals(source))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Add generated secrets with the Rotate Signing Secret action; here you can only add an environment reference."));
|
||||
continue;
|
||||
}
|
||||
if(SecretSource.REFERENCE.name().equals(source))
|
||||
{
|
||||
String ref = ValueUtils.getValueAsString(effective(record, old, "secretRef"));
|
||||
if(!StringUtils.hasContent(ref) || !ref.matches(EnvironmentSecrets.NAME_PATTERN))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Environment variable must be a variable name (letters, digits and _), never the secret itself."));
|
||||
continue;
|
||||
}
|
||||
String value = EnvironmentSecrets.lookup(ref);
|
||||
if(value != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
record.setValue("fingerprint", WebhookSecret.parse(value).fingerprint());
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("The secret in " + ref + " is too short (at least 16 bytes) or not a valid whsec_ value."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.DetailLevel;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.HealthStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.SecretStatus;
|
||||
import com.kingsrook.qbits.webhooks.permissions.WebhooksPermissionChecker;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventTypePossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's five tables. All are standard, visible QQQ tables (queryable,
|
||||
** exportable, audited), secured by the {@code webhooks.*} permissions and the
|
||||
** host's record security locks.
|
||||
**
|
||||
** <ul>
|
||||
** <li>{@code webhooks_endpoint}: where to send (URL, status, health,
|
||||
** limits, internal-target allowlist).</li>
|
||||
** <li>{@code webhooks_endpoint_secret}: signing secrets per endpoint
|
||||
** (encrypted or an environment reference; never plaintext).</li>
|
||||
** <li>{@code webhooks_subscription}: which event types an endpoint gets,
|
||||
** with filters and payload shaping.</li>
|
||||
** <li>{@code webhooks_delivery}: one row per event per endpoint (the
|
||||
** outbox): payload, status, retries, dead letter.</li>
|
||||
** <li>{@code webhooks_delivery_attempt}: one row per HTTP attempt (also the
|
||||
** claim: unique on delivery and attempt number).</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksTables
|
||||
{
|
||||
public static final String ENDPOINT = "webhooks_endpoint";
|
||||
public static final String SECRET = "webhooks_endpoint_secret";
|
||||
public static final String SUBSCRIPTION = "webhooks_subscription";
|
||||
public static final String DELIVERY = "webhooks_delivery";
|
||||
public static final String ATTEMPT = "webhooks_delivery_attempt";
|
||||
|
||||
public static final List<String> ALL = List.of(ENDPOINT, SECRET, SUBSCRIPTION, DELIVERY, ATTEMPT);
|
||||
|
||||
/** The tables in the app nav (the rest are relations on their parents). */
|
||||
public static final List<String> MAIN = List.of(ENDPOINT, SUBSCRIPTION, DELIVERY);
|
||||
|
||||
/** Written only by the qbit (read-only to people, through the API too). */
|
||||
public static final List<String> SYSTEM_WRITTEN = List.of(DELIVERY, ATTEMPT);
|
||||
|
||||
public static final String PVS_ACTIVE_STATUS = "webhooks_active_status";
|
||||
public static final String PVS_HEALTH_STATUS = "webhooks_health_status";
|
||||
public static final String PVS_DELIVERY_STATUS = "webhooks_delivery_status";
|
||||
public static final String PVS_ATTEMPT_OUTCOME = "webhooks_attempt_outcome";
|
||||
public static final String PVS_EVENT_SOURCE = "webhooks_event_source";
|
||||
public static final String PVS_SECRET_STATUS = "webhooks_secret_status";
|
||||
public static final String PVS_SECRET_SOURCE = "webhooks_secret_source";
|
||||
public static final String PVS_DETAIL_LEVEL = "webhooks_detail_level";
|
||||
|
||||
public static final String JOIN_ENDPOINT_SUBSCRIPTION = ENDPOINT + "_join_" + SUBSCRIPTION;
|
||||
public static final String JOIN_ENDPOINT_SECRET = ENDPOINT + "_join_" + SECRET;
|
||||
public static final String JOIN_ENDPOINT_DELIVERY = ENDPOINT + "_join_" + DELIVERY;
|
||||
public static final String JOIN_SUBSCRIPTION_DELIVERY = SUBSCRIPTION + "_join_" + DELIVERY;
|
||||
public static final String JOIN_DELIVERY_ATTEMPT = DELIVERY + "_join_" + ATTEMPT;
|
||||
|
||||
public static final String WIDGET_ENDPOINT_SUBSCRIPTIONS = "webhooksEndpointSubscriptions";
|
||||
public static final String WIDGET_ENDPOINT_SECRETS = "webhooksEndpointSecrets";
|
||||
public static final String WIDGET_ENDPOINT_DELIVERIES = "webhooksEndpointDeliveries";
|
||||
public static final String WIDGET_SUBSCRIPTION_DELIVERIES = "webhooksSubscriptionDeliveries";
|
||||
public static final String WIDGET_DELIVERY_ATTEMPTS = "webhooksDeliveryAttempts";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksTables()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission rules shared by every qbit table, process, widget and the app.
|
||||
** The custom checker maps actions to webhooks.view / manage / admin.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules permissionRules()
|
||||
{
|
||||
return (new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName("webhooks")
|
||||
.withCustomPermissionChecker(new QCodeReference(WebhooksPermissionChecker.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible-value sources.
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
return (List.of(
|
||||
QPossibleValueSource.newForEnum(PVS_ACTIVE_STATUS, WebhooksEnums.ActiveStatus.values()),
|
||||
QPossibleValueSource.newForEnum(PVS_HEALTH_STATUS, WebhooksEnums.HealthStatus.values()),
|
||||
QPossibleValueSource.newForEnum(PVS_DELIVERY_STATUS, WebhooksEnums.DeliveryStatus.values()),
|
||||
QPossibleValueSource.newForEnum(PVS_ATTEMPT_OUTCOME, WebhooksEnums.AttemptOutcome.values()),
|
||||
QPossibleValueSource.newForEnum(PVS_EVENT_SOURCE, WebhooksEnums.EventSource.values()),
|
||||
QPossibleValueSource.newForEnum(PVS_SECRET_STATUS, WebhooksEnums.SecretStatus.values()),
|
||||
QPossibleValueSource.newForEnum(PVS_SECRET_SOURCE, WebhooksEnums.SecretSource.values()),
|
||||
QPossibleValueSource.newForEnum(PVS_DETAIL_LEVEL, WebhooksEnums.DetailLevel.values()),
|
||||
WebhookEventTypePossibleValueSource.definition(),
|
||||
QPossibleValueSource.newForTable(ENDPOINT),
|
||||
QPossibleValueSource.newForTable(SUBSCRIPTION),
|
||||
QPossibleValueSource.newForTable(DELIVERY)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> tables(WebhooksQBitConfig config)
|
||||
{
|
||||
List<QTableMetaData> tables = List.of(endpoint(config), secret(config), subscription(config), delivery(config), attempt(config));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
if(SYSTEM_WRITTEN.contains(table.getName()))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// no New / Edit / Delete in the UI or API: people act on these //
|
||||
// through the actions; the qbit writes them itself //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
}
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Joins between the tables.
|
||||
*******************************************************************************/
|
||||
public static List<QJoinMetaData> joins()
|
||||
{
|
||||
return (List.of(
|
||||
join(JOIN_ENDPOINT_SUBSCRIPTION, ENDPOINT, SUBSCRIPTION, "endpointId"),
|
||||
join(JOIN_ENDPOINT_SECRET, ENDPOINT, SECRET, "endpointId"),
|
||||
join(JOIN_ENDPOINT_DELIVERY, ENDPOINT, DELIVERY, "endpointId"),
|
||||
join(JOIN_SUBSCRIPTION_DELIVERY, SUBSCRIPTION, DELIVERY, "subscriptionId"),
|
||||
join(JOIN_DELIVERY_ATTEMPT, DELIVERY, ATTEMPT, "deliveryId")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The child-record widgets that show sub-tables on their parent records.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> childWidgets()
|
||||
{
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_ENDPOINT_SUBSCRIPTION))
|
||||
.withName(WIDGET_ENDPOINT_SUBSCRIPTIONS).withLabel("Subscriptions").withCanAddChildRecord(true).withMaxRows(100)
|
||||
.withDisabledFieldsForNewChildRecords(Set.of("endpointId"))
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_ENDPOINT_SECRET))
|
||||
.withName(WIDGET_ENDPOINT_SECRETS).withLabel("Signing Secrets").withMaxRows(20)
|
||||
.withOmitFieldNames(List.of("encryptedSecret", "endpointId"))
|
||||
.withOrderBys(List.of(new QFilterOrderBy("id", false)))
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_ENDPOINT_DELIVERY))
|
||||
.withName(WIDGET_ENDPOINT_DELIVERIES).withLabel("Recent Deliveries").withMaxRows(25)
|
||||
.withOnlyIncludeFieldNames(List.of("id", "eventTypeName", "status", "attemptCount", "lastHttpStatus", "occurredDate", "deliveredDate"))
|
||||
.withOrderBys(List.of(new QFilterOrderBy("id", false)))
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_SUBSCRIPTION_DELIVERY))
|
||||
.withName(WIDGET_SUBSCRIPTION_DELIVERIES).withLabel("Recent Deliveries").withMaxRows(25)
|
||||
.withOnlyIncludeFieldNames(List.of("id", "status", "attemptCount", "lastHttpStatus", "occurredDate", "deliveredDate"))
|
||||
.withOrderBys(List.of(new QFilterOrderBy("id", false)))
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joinFor(JOIN_DELIVERY_ATTEMPT))
|
||||
.withName(WIDGET_DELIVERY_ATTEMPTS).withLabel("Attempts").withMaxRows(50)
|
||||
.withOnlyIncludeFieldNames(List.of("attemptNumber", "outcome", "httpStatus", "durationMillis", "startDate", "errorMessage"))
|
||||
.withOrderBys(List.of(new QFilterOrderBy("attemptNumber", true)))
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules()));
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One-to-many join on a parent id field.
|
||||
*******************************************************************************/
|
||||
private static QJoinMetaData join(String name, String parent, String child, String childField)
|
||||
{
|
||||
return (new QJoinMetaData()
|
||||
.withName(name)
|
||||
.withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(parent)
|
||||
.withRightTable(child)
|
||||
.withJoinOn(new JoinOn("id", childField)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A join by name (from {@link #joins()}).
|
||||
*******************************************************************************/
|
||||
private static QJoinMetaData joinFor(String name)
|
||||
{
|
||||
for(QJoinMetaData join : joins())
|
||||
{
|
||||
if(join.getName().equals(name))
|
||||
{
|
||||
return (join);
|
||||
}
|
||||
}
|
||||
throw (new IllegalArgumentException("No join " + name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table with the common id and dates.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData base(WebhooksQBitConfig config, String name, String label, String icon)
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData()
|
||||
.withName(name)
|
||||
.withLabel(label)
|
||||
.withIcon(new QIcon(icon))
|
||||
.withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id")
|
||||
.withPermissionRules(permissionRules())
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withIsEditable(false));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add the host's security fields and locks.
|
||||
*******************************************************************************/
|
||||
private static List<String> addSecurity(WebhooksQBitConfig config, QTableMetaData table)
|
||||
{
|
||||
List<String> names = new ArrayList<>();
|
||||
for(QFieldMetaData field : config.getSecurityFields())
|
||||
{
|
||||
table.addField(field.clone());
|
||||
names.add(field.getName());
|
||||
}
|
||||
config.getRecordSecurityLocks().forEach(table::withRecordSecurityLock);
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A read-only field.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData ro(String name, QFieldType type)
|
||||
{
|
||||
return (new QFieldMetaData(name, type).withIsEditable(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A T1 section with the given fields plus the security fields.
|
||||
*******************************************************************************/
|
||||
private static QFieldSection t1(List<String> security, String... fields)
|
||||
{
|
||||
List<String> names = new ArrayList<>(List.of(fields));
|
||||
names.addAll(security);
|
||||
return (SectionFactory.defaultT1(names.toArray(new String[0])));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** webhooks_endpoint.
|
||||
*******************************************************************************/
|
||||
static QTableMetaData endpoint(WebhooksQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, ENDPOINT, "Webhook Endpoint", "webhook")
|
||||
.withRecordLabelFormatAndFields("%s", "name")
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING).withIsRequired(true).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("description", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("url", QFieldType.STRING).withLabel("URL").withIsRequired(true).withMaxLength(500))
|
||||
.withField(chip(new QFieldMetaData("activeStatus", QFieldType.STRING).withLabel("Status").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PVS_ACTIVE_STATUS).withDefaultValue(ActiveStatus.ACTIVE.name()),
|
||||
new String[][] {{"ACTIVE", "play_circle", AdornmentType.ChipValues.COLOR_SUCCESS}, {"PAUSED", "pause_circle", AdornmentType.ChipValues.COLOR_WARNING},
|
||||
{"DISABLED", "block", AdornmentType.ChipValues.COLOR_ERROR}}))
|
||||
.withField(chip(ro("healthStatus", QFieldType.STRING).withLabel("Health").withPossibleValueSourceName(PVS_HEALTH_STATUS)
|
||||
.withDefaultValue(HealthStatus.HEALTHY.name()),
|
||||
new String[][] {{"HEALTHY", "favorite", AdornmentType.ChipValues.COLOR_SUCCESS}, {"PROBATION", "healing", AdornmentType.ChipValues.COLOR_WARNING},
|
||||
{"UNHEALTHY", "heart_broken", AdornmentType.ChipValues.COLOR_ERROR}}))
|
||||
.withField(new QFieldMetaData("serialDelivery", QFieldType.BOOLEAN).withLabel("Deliver In Order").withDefaultValue(false))
|
||||
.withField(new QFieldMetaData("maxPerMinute", QFieldType.INTEGER).withLabel("Max Deliveries Per Minute"))
|
||||
.withField(new QFieldMetaData("timeoutSeconds", QFieldType.INTEGER).withLabel("Timeout (Seconds)"))
|
||||
.withField(new QFieldMetaData("allowUnsigned", QFieldType.BOOLEAN).withLabel("Allow Unsigned Delivery").withDefaultValue(false))
|
||||
.withField(new QFieldMetaData("allowInternalTargets", QFieldType.BOOLEAN).withLabel("Allow Internal Targets").withDefaultValue(false))
|
||||
.withField(new QFieldMetaData("internalAllowlist", QFieldType.TEXT).withLabel("Internal Allowlist"))
|
||||
.withField(ro("consecutiveFailures", QFieldType.INTEGER).withDefaultValue(0))
|
||||
.withField(ro("probationFailures", QFieldType.INTEGER).withDefaultValue(0))
|
||||
.withField(ro("lastSuccessDate", QFieldType.DATE_TIME).withLabel("Last Success"))
|
||||
.withField(ro("lastFailureDate", QFieldType.DATE_TIME).withLabel("Last Failure"))
|
||||
.withField(ro("lastFailureMessage", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(ro("unhealthySinceDate", QFieldType.DATE_TIME).withLabel("Unhealthy Since"))
|
||||
.withField(ro("autoDisabledDate", QFieldType.DATE_TIME).withLabel("Auto-Disabled"));
|
||||
List<String> security = addSecurity(config, table);
|
||||
table.withUniqueKey(new UniqueKey("name"))
|
||||
.withSection(t1(security, "id", "name", "url", "activeStatus", "healthStatus"))
|
||||
.withSection(SectionFactory.customT2("delivery", new QIcon("send"), "description", "serialDelivery", "maxPerMinute", "timeoutSeconds", "allowUnsigned"))
|
||||
.withSection(SectionFactory.customT2("health", new QIcon("monitor_heart"), "consecutiveFailures", "probationFailures", "lastSuccessDate", "lastFailureDate",
|
||||
"lastFailureMessage", "unhealthySinceDate", "autoDisabledDate"))
|
||||
.withSection(SectionFactory.customT2("network", new QIcon("lan"), "allowInternalTargets", "internalAllowlist").withLabel("Internal Network (Admin Only)"))
|
||||
.withSection(SectionFactory.customT2("subscriptions", new QIcon("rule")).withWidgetName(WIDGET_ENDPOINT_SUBSCRIPTIONS))
|
||||
.withSection(SectionFactory.customT2("secrets", new QIcon("key")).withWidgetName(WIDGET_ENDPOINT_SECRETS))
|
||||
.withSection(SectionFactory.customT2("deliveries", new QIcon("outbox")).withWidgetName(WIDGET_ENDPOINT_DELIVERIES))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Subscriptions").withJoinTable(SUBSCRIPTION).withJoinPath(List.of(JOIN_ENDPOINT_SUBSCRIPTION)));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** webhooks_endpoint_secret.
|
||||
*******************************************************************************/
|
||||
static QTableMetaData secret(WebhooksQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, SECRET, "Webhook Signing Secret", "key")
|
||||
.withRecordLabelFormatAndFields("Secret %s", "fingerprint")
|
||||
.withField(new QFieldMetaData("endpointId", QFieldType.INTEGER).withLabel("Endpoint").withIsRequired(true).withPossibleValueSourceName(ENDPOINT))
|
||||
.withField(chip(new QFieldMetaData("status", QFieldType.STRING).withIsRequired(true).withPossibleValueSourceName(PVS_SECRET_STATUS)
|
||||
.withDefaultValue(SecretStatus.ACTIVE.name()),
|
||||
new String[][] {{"ACTIVE", "check_circle", AdornmentType.ChipValues.COLOR_SUCCESS}, {"PREVIOUS", "history", AdornmentType.ChipValues.COLOR_WARNING},
|
||||
{"RETIRED", "do_not_disturb_on", AdornmentType.ChipValues.COLOR_DEFAULT}}))
|
||||
.withField(new QFieldMetaData("source", QFieldType.STRING).withIsRequired(true).withPossibleValueSourceName(PVS_SECRET_SOURCE))
|
||||
.withField(new QFieldMetaData("secretRef", QFieldType.STRING).withLabel("Environment Variable").withMaxLength(100))
|
||||
.withField(ro("encryptedSecret", QFieldType.TEXT))
|
||||
.withField(ro("fingerprint", QFieldType.STRING).withMaxLength(20))
|
||||
.withField(new QFieldMetaData("expiresDate", QFieldType.DATE_TIME).withLabel("Signs Until"))
|
||||
.withField(ro("retiredDate", QFieldType.DATE_TIME))
|
||||
.withField(new QFieldMetaData("note", QFieldType.STRING).withMaxLength(250));
|
||||
table.withSection(SectionFactory.defaultT1("id", "endpointId", "status", "fingerprint"))
|
||||
.withSection(SectionFactory.defaultT2("source", "secretRef", "expiresDate", "retiredDate", "note", "encryptedSecret"))
|
||||
.withSection(SectionFactory.customT2("value", new QIcon("visibility")).withWidgetName("webhooksSecretValue"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** webhooks_subscription.
|
||||
*******************************************************************************/
|
||||
static QTableMetaData subscription(WebhooksQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, SUBSCRIPTION, "Webhook Subscription", "rule")
|
||||
.withRecordLabelFormatAndFields("%s: %s", "endpointId", "eventTypeName")
|
||||
.withField(new QFieldMetaData("endpointId", QFieldType.INTEGER).withLabel("Endpoint").withIsRequired(true).withPossibleValueSourceName(ENDPOINT))
|
||||
.withField(new QFieldMetaData("eventTypeName", QFieldType.STRING).withLabel("Event Type").withIsRequired(true).withMaxLength(200)
|
||||
.withPossibleValueSourceName(WebhookEventTypePossibleValueSource.NAME))
|
||||
.withField(chip(new QFieldMetaData("activeStatus", QFieldType.STRING).withLabel("Status").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PVS_ACTIVE_STATUS).withDefaultValue(ActiveStatus.ACTIVE.name()),
|
||||
new String[][] {{"ACTIVE", "play_circle", AdornmentType.ChipValues.COLOR_SUCCESS}, {"PAUSED", "pause_circle", AdornmentType.ChipValues.COLOR_WARNING},
|
||||
{"DISABLED", "block", AdornmentType.ChipValues.COLOR_ERROR}}))
|
||||
.withField(new QFieldMetaData("description", QFieldType.STRING).withMaxLength(250))
|
||||
.withField(new QFieldMetaData("filterJson", QFieldType.TEXT).withLabel("Record Filter (JSON)")
|
||||
.withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json"))))
|
||||
.withField(new QFieldMetaData("detailLevel", QFieldType.STRING).withIsRequired(true).withPossibleValueSourceName(PVS_DETAIL_LEVEL)
|
||||
.withDefaultValue(DetailLevel.FULL.name()))
|
||||
.withField(new QFieldMetaData("includeFields", QFieldType.STRING).withLabel("Only These Fields").withMaxLength(1000))
|
||||
.withField(new QFieldMetaData("excludeFields", QFieldType.STRING).withLabel("Never These Fields").withMaxLength(1000))
|
||||
.withField(new QFieldMetaData("apiName", QFieldType.STRING).withLabel("API Name").withMaxLength(100).withDefaultValue(config.getDefaultApiName()))
|
||||
.withField(new QFieldMetaData("apiVersion", QFieldType.STRING).withLabel("API Version").withMaxLength(50).withDefaultValue(config.getDefaultApiVersion()));
|
||||
List<String> security = addSecurity(config, table);
|
||||
table.withSection(t1(security, "id", "endpointId", "eventTypeName", "activeStatus"))
|
||||
.withSection(SectionFactory.customT2("filter", new QIcon("filter_alt"), "description", "filterJson"))
|
||||
.withSection(SectionFactory.customT2("payload", new QIcon("data_object"), "detailLevel", "includeFields", "excludeFields", "apiName", "apiVersion"))
|
||||
.withSection(SectionFactory.customT2("deliveries", new QIcon("outbox")).withWidgetName(WIDGET_SUBSCRIPTION_DELIVERIES))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Endpoint").withJoinTable(ENDPOINT).withJoinPath(List.of(JOIN_ENDPOINT_SUBSCRIPTION)));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** webhooks_delivery.
|
||||
*******************************************************************************/
|
||||
static QTableMetaData delivery(WebhooksQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, DELIVERY, "Webhook Delivery", "outbox")
|
||||
.withRecordLabelFormatAndFields("%s (%s)", "eventTypeName", "id")
|
||||
.withField(ro("eventId", QFieldType.STRING).withLabel("Event Id (webhook-id)").withMaxLength(40))
|
||||
.withField(ro("eventTypeName", QFieldType.STRING).withLabel("Event Type").withMaxLength(200)
|
||||
.withPossibleValueSourceName(WebhookEventTypePossibleValueSource.NAME))
|
||||
.withField(ro("endpointId", QFieldType.INTEGER).withLabel("Endpoint").withPossibleValueSourceName(ENDPOINT))
|
||||
.withField(ro("subscriptionId", QFieldType.INTEGER).withLabel("Subscription").withPossibleValueSourceName(SUBSCRIPTION))
|
||||
.withField(ro("source", QFieldType.STRING).withPossibleValueSourceName(PVS_EVENT_SOURCE))
|
||||
.withField(ro("sourceTable", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(ro("sourceRecordId", QFieldType.STRING).withLabel("Source Record").withMaxLength(100))
|
||||
.withField(ro("sourceEventId", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(ro("dedupKey", QFieldType.STRING).withMaxLength(250))
|
||||
.withField(chip(ro("status", QFieldType.STRING).withPossibleValueSourceName(PVS_DELIVERY_STATUS),
|
||||
new String[][] {{"PENDING", "pending", AdornmentType.ChipValues.COLOR_DEFAULT}, {"SENDING", "send", AdornmentType.ChipValues.COLOR_INFO},
|
||||
{"RETRY_WAIT", "hourglass_top", AdornmentType.ChipValues.COLOR_WARNING}, {"DELIVERED", "check_circle", AdornmentType.ChipValues.COLOR_SUCCESS},
|
||||
{"DEAD_LETTER", "report", AdornmentType.ChipValues.COLOR_ERROR}, {"CANCELLED", "cancel", AdornmentType.ChipValues.COLOR_DEFAULT}}))
|
||||
.withField(ro("attemptCount", QFieldType.INTEGER).withLabel("Attempts").withDefaultValue(0))
|
||||
.withField(ro("cycleAttemptCount", QFieldType.INTEGER).withLabel("Attempts Since Replay").withDefaultValue(0))
|
||||
.withField(ro("replayCount", QFieldType.INTEGER).withLabel("Replays").withDefaultValue(0))
|
||||
.withField(ro("occurredDate", QFieldType.DATE_TIME).withLabel("Occurred"))
|
||||
.withField(ro("nextAttemptDate", QFieldType.DATE_TIME).withLabel("Next Attempt"))
|
||||
.withField(ro("leaseExpiresDate", QFieldType.DATE_TIME).withLabel("Lease Expires"))
|
||||
.withField(ro("lastHttpStatus", QFieldType.INTEGER).withLabel("Last HTTP Status"))
|
||||
.withField(ro("lastError", QFieldType.STRING).withMaxLength(500))
|
||||
.withField(ro("deliveredDate", QFieldType.DATE_TIME).withLabel("Delivered"))
|
||||
.withField(ro("deadLetterDate", QFieldType.DATE_TIME).withLabel("Dead-Lettered"))
|
||||
.withField(ro("payloadBytes", QFieldType.INTEGER))
|
||||
.withField(ro("payload", QFieldType.TEXT)
|
||||
.withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json"))));
|
||||
List<String> security = addSecurity(config, table);
|
||||
table.withUniqueKey(new UniqueKey("dedupKey"))
|
||||
.withSection(t1(security, "id", "eventTypeName", "endpointId", "status"))
|
||||
.withSection(SectionFactory.customT2("progress", new QIcon("timeline"), "attemptCount", "cycleAttemptCount", "replayCount", "occurredDate",
|
||||
"nextAttemptDate", "lastHttpStatus", "lastError", "deliveredDate", "deadLetterDate"))
|
||||
.withSection(SectionFactory.customT2("source", new QIcon("input"), "eventId", "source", "sourceTable", "sourceRecordId", "sourceEventId",
|
||||
"subscriptionId", "dedupKey", "leaseExpiresDate", "payloadBytes"))
|
||||
.withSection(SectionFactory.customT2("attempts", new QIcon("history")).withWidgetName(WIDGET_DELIVERY_ATTEMPTS))
|
||||
.withSection(SectionFactory.customT2("payloadSection", new QIcon("data_object"), "payload").withLabel("Payload").withGridColumns(12))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Endpoint").withJoinTable(ENDPOINT).withJoinPath(List.of(JOIN_ENDPOINT_DELIVERY)));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** webhooks_delivery_attempt.
|
||||
*******************************************************************************/
|
||||
static QTableMetaData attempt(WebhooksQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, ATTEMPT, "Webhook Delivery Attempt", "history")
|
||||
.withRecordLabelFormatAndFields("Attempt %s of delivery %s", "attemptNumber", "deliveryId")
|
||||
.withField(ro("deliveryId", QFieldType.INTEGER).withLabel("Delivery").withPossibleValueSourceName(DELIVERY))
|
||||
.withField(ro("endpointId", QFieldType.INTEGER).withLabel("Endpoint").withPossibleValueSourceName(ENDPOINT))
|
||||
.withField(ro("attemptNumber", QFieldType.INTEGER))
|
||||
.withField(chip(ro("outcome", QFieldType.STRING).withPossibleValueSourceName(PVS_ATTEMPT_OUTCOME),
|
||||
new String[][] {{"IN_FLIGHT", "send", AdornmentType.ChipValues.COLOR_INFO}, {"SUCCEEDED", "check_circle", AdornmentType.ChipValues.COLOR_SUCCESS},
|
||||
{"FAILED", "error", AdornmentType.ChipValues.COLOR_ERROR}, {"RATE_LIMITED", "speed", AdornmentType.ChipValues.COLOR_WARNING},
|
||||
{"BLOCKED", "block", AdornmentType.ChipValues.COLOR_ERROR}, {"LEASE_EXPIRED", "timer_off", AdornmentType.ChipValues.COLOR_WARNING}}))
|
||||
.withField(ro("claimedBy", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(ro("leaseExpiresDate", QFieldType.DATE_TIME))
|
||||
.withField(ro("startDate", QFieldType.DATE_TIME).withLabel("Started"))
|
||||
.withField(ro("endDate", QFieldType.DATE_TIME).withLabel("Ended"))
|
||||
.withField(ro("durationMillis", QFieldType.INTEGER).withLabel("Duration (ms)"))
|
||||
.withField(ro("httpStatus", QFieldType.INTEGER).withLabel("HTTP Status"))
|
||||
.withField(ro("responseExcerpt", QFieldType.TEXT))
|
||||
.withField(ro("errorMessage", QFieldType.STRING).withMaxLength(1000))
|
||||
.withField(ro("remoteAddress", QFieldType.STRING).withMaxLength(64))
|
||||
.withField(ro("requestUrl", QFieldType.STRING).withLabel("URL").withMaxLength(500))
|
||||
.withField(ro("signedWith", QFieldType.STRING).withLabel("Signed With (Fingerprints)").withMaxLength(100));
|
||||
table.withUniqueKey(new UniqueKey("deliveryId", "attemptNumber"))
|
||||
.withSection(SectionFactory.defaultT1("id", "deliveryId", "endpointId", "attemptNumber", "outcome"))
|
||||
.withSection(SectionFactory.defaultT2("httpStatus", "durationMillis", "startDate", "endDate", "errorMessage", "responseExcerpt",
|
||||
"requestUrl", "remoteAddress", "signedWith", "claimedBy", "leaseExpiresDate"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a chip adornment (value, icon, color triples).
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData chip(QFieldMetaData field, String[][] values)
|
||||
{
|
||||
FieldAdornment adornment = new FieldAdornment(AdornmentType.CHIP);
|
||||
for(String[] value : values)
|
||||
{
|
||||
adornment.withValues(AdornmentType.ChipValues.iconAndColorValues(value[0], value[1], value[2]));
|
||||
}
|
||||
return (field.withFieldAdornment(adornment));
|
||||
}
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.actions.ClearWebhookSubscriptionHelperMemoizationsTableCustomizer;
|
||||
import com.kingsrook.qbits.webhooks.processes.SyncWebhookScheduledJobProcess;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QAssociation;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.scheduler.processes.BaseSyncToScheduledJobTableCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QRecord Entity for Webhook table
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingEntity(
|
||||
producePossibleValueSource = true,
|
||||
produceTableMetaData = true,
|
||||
tableMetaDataCustomizer = Webhook.TableMetaDataCustomizer.class,
|
||||
childTables = {
|
||||
@ChildTable(
|
||||
childTableEntityClass = WebhookSubscription.class,
|
||||
joinFieldName = "webhookId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Subscriptions", enabled = true, maxRows = 250, canAddChildRecords = true, manageAssociationName = Webhook.SUBSCRIPTIONS_ASSOCIATION_NAME)),
|
||||
@ChildTable(
|
||||
childTableEntityClass = WebhookEvent.class,
|
||||
joinFieldName = "webhookId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Events", enabled = true, maxRows = 25, widgetMetaDataCustomizer = Webhook.EventAndEventSendLogChildRecordWidgetMetaDataCustomizer.class)),
|
||||
@ChildTable(
|
||||
childTableEntityClass = WebhookEventSendLog.class,
|
||||
joinFieldName = "webhookId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Event Send Log", enabled = true, maxRows = 25, widgetMetaDataCustomizer = Webhook.EventAndEventSendLogChildRecordWidgetMetaDataCustomizer.class))
|
||||
}
|
||||
)
|
||||
public class Webhook extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "webhook";
|
||||
public static final String SUBSCRIPTIONS_ASSOCIATION_NAME = "subscriptions";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface<QTableMetaData>
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
String subscriptionChildJoinName = QJoinMetaData.makeInferredJoinName(Webhook.TABLE_NAME, WebhookSubscription.TABLE_NAME);
|
||||
String eventChildJoinName = QJoinMetaData.makeInferredJoinName(Webhook.TABLE_NAME, WebhookEvent.TABLE_NAME);
|
||||
String eventSendLogChildJoinName = QJoinMetaData.makeInferredJoinName(Webhook.TABLE_NAME, WebhookEventSendLog.TABLE_NAME);
|
||||
|
||||
QFieldSection t1section = SectionFactory.defaultT1("id", "name", "description");
|
||||
|
||||
table
|
||||
.withIcon(new QIcon().withName("webhook"))
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("name")
|
||||
.withSection(t1section)
|
||||
.withSection(SectionFactory.defaultT2("url", "activeStatusId", "healthStatusId"))
|
||||
.withSection(SectionFactory.customT2("subscriptions", new QIcon("subscriptions")).withWidgetName(subscriptionChildJoinName))
|
||||
.withSection(SectionFactory.customT2("events", new QIcon("notifications")).withWidgetName(eventChildJoinName))
|
||||
.withSection(SectionFactory.customT2("sendLog", new QIcon("receipt_long")).withWidgetName(eventSendLogChildJoinName))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Subscriptions").withJoinPath(List.of(subscriptionChildJoinName)).withJoinTable(WebhookSubscription.TABLE_NAME))
|
||||
.withAssociation(new Association().withName(SUBSCRIPTIONS_ASSOCIATION_NAME).withAssociatedTableName(WebhookSubscription.TABLE_NAME).withJoinName(subscriptionChildJoinName));
|
||||
|
||||
addActiveStatusChipAdornment(table.getField("activeStatusId"));
|
||||
addHealthStatusChipAdornment(table.getField("healthStatusId"));
|
||||
|
||||
QBitConfig qBitConfig = QBitProductionContext.peekQBitConfig();
|
||||
if(qBitConfig instanceof WebhooksQBitConfig webhooksQBitConfig)
|
||||
{
|
||||
t1section.setFieldNames(new MutableList<>(t1section.getFieldNames()));
|
||||
for(QFieldMetaData fieldMetaData : CollectionUtils.nonNullList(webhooksQBitConfig.getSecurityFields()))
|
||||
{
|
||||
table.addField(fieldMetaData.clone());
|
||||
t1section.getFieldNames().add(fieldMetaData.getName());
|
||||
}
|
||||
|
||||
for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(webhooksQBitConfig.getRecordSecurityLocks()))
|
||||
{
|
||||
table.withRecordSecurityLock(recordSecurityLock);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// we need 2 customizers to run post insert, update, and delete: //
|
||||
// first, to clear the webhookSubscriptionHelper class's memoizations //
|
||||
// second, to sync scheduled jobs for this table //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
QCodeReference syncScheduledJobCustomizer = BaseSyncToScheduledJobTableCustomizer.makeCodeReference(table, new SyncWebhookScheduledJobProcess());
|
||||
QCodeReference postEverythingMultiCustomizer = MultiCustomizer.of(new QCodeReference(ClearWebhookSubscriptionHelperMemoizationsTableCustomizer.class), syncScheduledJobCustomizer);
|
||||
table.withCustomizer(TableCustomizers.POST_INSERT_RECORD, postEverythingMultiCustomizer);
|
||||
table.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, postEverythingMultiCustomizer);
|
||||
table.withCustomizer(TableCustomizers.POST_DELETE_RECORD, postEverythingMultiCustomizer);
|
||||
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
public static QFieldMetaData addActiveStatusChipAdornment(QFieldMetaData field)
|
||||
{
|
||||
return field.withFieldAdornment(new FieldAdornment(AdornmentType.CHIP)
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookActiveStatus.ACTIVE, "play_circle_outline", AdornmentType.ChipValues.COLOR_SUCCESS))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookActiveStatus.PAUSED, "pause_circle_outline", AdornmentType.ChipValues.COLOR_WARNING))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookActiveStatus.DISABLED, "not_interested", AdornmentType.ChipValues.COLOR_ERROR)));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static QFieldMetaData addHealthStatusChipAdornment(QFieldMetaData field)
|
||||
{
|
||||
return field.withFieldAdornment(new FieldAdornment(AdornmentType.CHIP)
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookHealthStatus.HEALTHY, "favorite_border", AdornmentType.ChipValues.COLOR_SUCCESS))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookHealthStatus.UNHEALTHY, "heart_broken", AdornmentType.ChipValues.COLOR_ERROR))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookHealthStatus.PROBATION, "healing", AdornmentType.ChipValues.COLOR_WARNING)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static class EventAndEventSendLogChildRecordWidgetMetaDataCustomizer implements MetaDataCustomizerInterface<QWidgetMetaData>
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData customizeMetaData(QInstance qInstance, QWidgetMetaData widget) throws QException
|
||||
{
|
||||
////////////////////////////
|
||||
// sort events by id desc //
|
||||
////////////////////////////
|
||||
ArrayList<QFilterOrderBy> orderBys = new ArrayList<>(List.of(new QFilterOrderBy("id", false)));
|
||||
return widget.withDefaultValue("orderBy", orderBys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR, isRequired = true)
|
||||
private String name;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR)
|
||||
private String description;
|
||||
|
||||
@QField(maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR, isRequired = true, label = "URL")
|
||||
private String url;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = WebhookActiveStatus.NAME, defaultValue = WebhookActiveStatus.DEFAULT_VALUE)
|
||||
private Integer activeStatusId;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = WebhookHealthStatus.NAME, defaultValue = WebhookHealthStatus.DEFAULT_VALUE, isEditable = false)
|
||||
private Integer healthStatusId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
@QAssociation(name = SUBSCRIPTIONS_ASSOCIATION_NAME)
|
||||
private List<WebhookSubscription> subscriptions;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor
|
||||
*******************************************************************************/
|
||||
public Webhook()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that takes a QRecord
|
||||
*******************************************************************************/
|
||||
public Webhook(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public Webhook withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public Webhook withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for description
|
||||
*******************************************************************************/
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public Webhook withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for url
|
||||
*******************************************************************************/
|
||||
public String getUrl()
|
||||
{
|
||||
return (this.url);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for url
|
||||
*******************************************************************************/
|
||||
public void setUrl(String url)
|
||||
{
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for url
|
||||
*******************************************************************************/
|
||||
public Webhook withUrl(String url)
|
||||
{
|
||||
this.url = url;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public Webhook withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Webhook withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subscriptions
|
||||
*******************************************************************************/
|
||||
public List<WebhookSubscription> getSubscriptions()
|
||||
{
|
||||
return (this.subscriptions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for subscriptions
|
||||
*******************************************************************************/
|
||||
public void setSubscriptions(List<WebhookSubscription> subscriptions)
|
||||
{
|
||||
this.subscriptions = subscriptions;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subscriptions
|
||||
*******************************************************************************/
|
||||
public Webhook withSubscriptions(List<WebhookSubscription> subscriptions)
|
||||
{
|
||||
this.subscriptions = subscriptions;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for activeStatusId
|
||||
*******************************************************************************/
|
||||
public Integer getActiveStatusId()
|
||||
{
|
||||
return (this.activeStatusId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for activeStatusId
|
||||
*******************************************************************************/
|
||||
public void setActiveStatusId(Integer activeStatusId)
|
||||
{
|
||||
this.activeStatusId = activeStatusId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for activeStatusId
|
||||
*******************************************************************************/
|
||||
public Webhook withActiveStatusId(Integer activeStatusId)
|
||||
{
|
||||
this.activeStatusId = activeStatusId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for healthStatusId
|
||||
*******************************************************************************/
|
||||
public Integer getHealthStatusId()
|
||||
{
|
||||
return (this.healthStatusId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for healthStatusId
|
||||
*******************************************************************************/
|
||||
public void setHealthStatusId(Integer healthStatusId)
|
||||
{
|
||||
this.healthStatusId = healthStatusId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for healthStatusId
|
||||
*******************************************************************************/
|
||||
public Webhook withHealthStatusId(Integer healthStatusId)
|
||||
{
|
||||
this.healthStatusId = healthStatusId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingPossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingPossibleValueEnum()
|
||||
public enum WebhookActiveStatus implements PossibleValueEnum<Integer>
|
||||
{
|
||||
ACTIVE(1, "Active"),
|
||||
PAUSED(2, "Paused"),
|
||||
DISABLED(3, "Disabled");
|
||||
|
||||
private final Integer id;
|
||||
private final String label;
|
||||
|
||||
public static final String NAME = "WebhookActiveStatus";
|
||||
|
||||
public static final String DEFAULT_VALUE = "1";
|
||||
|
||||
static
|
||||
{
|
||||
Objects.requireNonNull(getById(Integer.parseInt(DEFAULT_VALUE)), "Default value '" + DEFAULT_VALUE + " in WebhookActiveStatus is not a defined enum value");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
WebhookActiveStatus(Integer id, String label)
|
||||
{
|
||||
this.id = id;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get instance by id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static WebhookActiveStatus getById(Integer id)
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
for(WebhookActiveStatus value : WebhookActiveStatus.values())
|
||||
{
|
||||
if(Objects.equals(value.id, id))
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer getPossibleValueId()
|
||||
{
|
||||
return (getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (getLabel());
|
||||
}
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventTypePossibleValueSource;
|
||||
import com.kingsrook.qbits.webhooks.tables.WebhookEventTableCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QAssociation;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.tables.QQQTable;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MutableList;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QRecord Entity for WebhookEvent table
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingEntity(
|
||||
producePossibleValueSource = true,
|
||||
produceTableMetaData = true,
|
||||
tableMetaDataCustomizer = WebhookEvent.TableMetaDataCustomizer.class,
|
||||
childTables = {
|
||||
@ChildTable(
|
||||
childTableEntityClass = WebhookEventSendLog.class,
|
||||
joinFieldName = "webhookEventId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Send Log", enabled = true)),
|
||||
@ChildTable(
|
||||
childTableEntityClass = WebhookEventContent.class,
|
||||
joinFieldName = "webhookEventId",
|
||||
childJoin = @ChildJoin(enabled = true, isOneToOne = true))
|
||||
}
|
||||
)
|
||||
public class WebhookEvent extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "webhookEvent";
|
||||
public static final String CONTENT_ASSOCIATION_NAME = "content";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface<QTableMetaData>
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
String webhookParentJoinName = QJoinMetaData.makeInferredJoinName(Webhook.TABLE_NAME, WebhookEvent.TABLE_NAME);
|
||||
String webhookSubscriptionParentJoinName = QJoinMetaData.makeInferredJoinName(WebhookSubscription.TABLE_NAME, WebhookEvent.TABLE_NAME);
|
||||
String contentChildJoinName = QJoinMetaData.makeInferredJoinName(WebhookEvent.TABLE_NAME, WebhookEventContent.TABLE_NAME);
|
||||
String sendLogChildJoinName = QJoinMetaData.makeInferredJoinName(WebhookEvent.TABLE_NAME, WebhookEventSendLog.TABLE_NAME);
|
||||
|
||||
QFieldSection t1section = SectionFactory.defaultT1("id", "webhookId", "webhookSubscriptionId", "webhookEventTypeName");
|
||||
table
|
||||
.withIcon(new QIcon().withName("notifications"))
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("id")
|
||||
.withSection(t1section)
|
||||
.withSection(SectionFactory.defaultT2("eventStatusId", "eventSourceRecordQqqTableId", "eventSourceRecordId", "nextAttemptTimestamp"))
|
||||
.withSection(SectionFactory.customT2("sendLogs", new QIcon("receipt_long")).withWidgetName(sendLogChildJoinName))
|
||||
.withSection(SectionFactory.customT2("content", new QIcon("description"), WebhookEventContent.TABLE_NAME + ".postBody"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Webhook").withJoinPath(List.of(webhookParentJoinName)).withJoinTable(Webhook.TABLE_NAME))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Subscription").withJoinPath(List.of(webhookSubscriptionParentJoinName)).withJoinTable(WebhookSubscription.TABLE_NAME))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Send Log").withJoinPath(List.of(sendLogChildJoinName)).withJoinTable(WebhookEventSendLog.TABLE_NAME))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Content").withJoinPath(List.of(contentChildJoinName)).withJoinTable(WebhookEventContent.TABLE_NAME))
|
||||
.withAssociation(new Association().withName(CONTENT_ASSOCIATION_NAME).withJoinName(contentChildJoinName).withAssociatedTableName(WebhookEventContent.TABLE_NAME));
|
||||
|
||||
QBitConfig qBitConfig = QBitProductionContext.peekQBitConfig();
|
||||
if(qBitConfig instanceof WebhooksQBitConfig webhooksQBitConfig)
|
||||
{
|
||||
t1section.setFieldNames(new MutableList<>(t1section.getFieldNames()));
|
||||
for(QFieldMetaData fieldMetaData : CollectionUtils.nonNullList(webhooksQBitConfig.getSecurityFields()))
|
||||
{
|
||||
table.addField(fieldMetaData.clone());
|
||||
t1section.getFieldNames().add(fieldMetaData.getName());
|
||||
}
|
||||
|
||||
for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(webhooksQBitConfig.getRecordSecurityLocks()))
|
||||
{
|
||||
table.withRecordSecurityLock(recordSecurityLock);
|
||||
}
|
||||
}
|
||||
|
||||
table.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(WebhookEventTableCustomizer.class));
|
||||
table.getField("eventSourceRecordId").withFieldAdornment(new FieldAdornment(AdornmentType.LINK).withValue(AdornmentType.LinkValues.TO_RECORD_FROM_TABLE_DYNAMIC, true));
|
||||
|
||||
QFieldMetaData eventStatusIdField = table.getField("eventStatusId");
|
||||
addEventStatusChipAdornment(eventStatusIdField);
|
||||
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static QFieldMetaData addEventStatusChipAdornment(QFieldMetaData eventStatusIdField)
|
||||
{
|
||||
eventStatusIdField.withFieldAdornment(new FieldAdornment(AdornmentType.CHIP)
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookEventStatus.NEW, "pending", AdornmentType.ChipValues.COLOR_DEFAULT))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookEventStatus.SENDING, "send", AdornmentType.ChipValues.COLOR_INFO))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookEventStatus.DELIVERED, "check", AdornmentType.ChipValues.COLOR_SUCCESS))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookEventStatus.AWAITING_RETRY, "hourglass_top", AdornmentType.ChipValues.COLOR_WARNING))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookEventStatus.FAILED, "error", AdornmentType.ChipValues.COLOR_ERROR)));
|
||||
return eventStatusIdField;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(possibleValueSourceName = Webhook.TABLE_NAME)
|
||||
private Integer webhookId;
|
||||
|
||||
@QField(possibleValueSourceName = WebhookSubscription.TABLE_NAME)
|
||||
private Integer webhookSubscriptionId;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR, isRequired = true, possibleValueSourceName = WebhookEventTypePossibleValueSource.NAME, label = "Event Type")
|
||||
private String webhookEventTypeName;
|
||||
|
||||
@QField(label = "Event Source Record Table", possibleValueSourceName = QQQTable.TABLE_NAME)
|
||||
private Integer eventSourceRecordQqqTableId;
|
||||
|
||||
@QField()
|
||||
private Integer eventSourceRecordId;
|
||||
|
||||
@QField(possibleValueSourceName = WebhookEventStatus.NAME)
|
||||
private Integer eventStatusId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant nextAttemptTimestamp;
|
||||
|
||||
@QAssociation(name = CONTENT_ASSOCIATION_NAME)
|
||||
private List<WebhookEventContent> content;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor
|
||||
*******************************************************************************/
|
||||
public WebhookEvent()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that takes a QRecord
|
||||
*******************************************************************************/
|
||||
public WebhookEvent(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for webhookId
|
||||
*******************************************************************************/
|
||||
public Integer getWebhookId()
|
||||
{
|
||||
return (this.webhookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for webhookId
|
||||
*******************************************************************************/
|
||||
public void setWebhookId(Integer webhookId)
|
||||
{
|
||||
this.webhookId = webhookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for webhookId
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withWebhookId(Integer webhookId)
|
||||
{
|
||||
this.webhookId = webhookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for webhookSubscriptionId
|
||||
*******************************************************************************/
|
||||
public Integer getWebhookSubscriptionId()
|
||||
{
|
||||
return (this.webhookSubscriptionId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for webhookSubscriptionId
|
||||
*******************************************************************************/
|
||||
public void setWebhookSubscriptionId(Integer webhookSubscriptionId)
|
||||
{
|
||||
this.webhookSubscriptionId = webhookSubscriptionId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for webhookSubscriptionId
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withWebhookSubscriptionId(Integer webhookSubscriptionId)
|
||||
{
|
||||
this.webhookSubscriptionId = webhookSubscriptionId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for webhookEventTypeName
|
||||
*******************************************************************************/
|
||||
public String getWebhookEventTypeName()
|
||||
{
|
||||
return (this.webhookEventTypeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for webhookEventTypeName
|
||||
*******************************************************************************/
|
||||
public void setWebhookEventTypeName(String webhookEventTypeName)
|
||||
{
|
||||
this.webhookEventTypeName = webhookEventTypeName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for webhookEventTypeName
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withWebhookEventTypeName(String webhookEventTypeName)
|
||||
{
|
||||
this.webhookEventTypeName = webhookEventTypeName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for eventSourceRecordQqqTableId
|
||||
*******************************************************************************/
|
||||
public Integer getEventSourceRecordQqqTableId()
|
||||
{
|
||||
return (this.eventSourceRecordQqqTableId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for eventSourceRecordQqqTableId
|
||||
*******************************************************************************/
|
||||
public void setEventSourceRecordQqqTableId(Integer eventSourceRecordQqqTableId)
|
||||
{
|
||||
this.eventSourceRecordQqqTableId = eventSourceRecordQqqTableId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for eventSourceRecordQqqTableId
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withEventSourceRecordQqqTableId(Integer eventSourceRecordQqqTableId)
|
||||
{
|
||||
this.eventSourceRecordQqqTableId = eventSourceRecordQqqTableId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for eventSourceRecordId
|
||||
*******************************************************************************/
|
||||
public Integer getEventSourceRecordId()
|
||||
{
|
||||
return (this.eventSourceRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for eventSourceRecordId
|
||||
*******************************************************************************/
|
||||
public void setEventSourceRecordId(Integer eventSourceRecordId)
|
||||
{
|
||||
this.eventSourceRecordId = eventSourceRecordId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for eventSourceRecordId
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withEventSourceRecordId(Integer eventSourceRecordId)
|
||||
{
|
||||
this.eventSourceRecordId = eventSourceRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for eventStatusId
|
||||
*******************************************************************************/
|
||||
public Integer getEventStatusId()
|
||||
{
|
||||
return (this.eventStatusId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for eventStatusId
|
||||
*******************************************************************************/
|
||||
public void setEventStatusId(Integer eventStatusId)
|
||||
{
|
||||
this.eventStatusId = eventStatusId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for eventStatusId
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withEventStatusId(Integer eventStatusId)
|
||||
{
|
||||
this.eventStatusId = eventStatusId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for content
|
||||
*******************************************************************************/
|
||||
public List<WebhookEventContent> getContent()
|
||||
{
|
||||
return (this.content);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for content
|
||||
*******************************************************************************/
|
||||
public void setContent(List<WebhookEventContent> content)
|
||||
{
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for content
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withContent(List<WebhookEventContent> content)
|
||||
{
|
||||
this.content = content;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for nextAttemptTimestamp
|
||||
*******************************************************************************/
|
||||
public Instant getNextAttemptTimestamp()
|
||||
{
|
||||
return (this.nextAttemptTimestamp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for nextAttemptTimestamp
|
||||
*******************************************************************************/
|
||||
public void setNextAttemptTimestamp(Instant nextAttemptTimestamp)
|
||||
{
|
||||
this.nextAttemptTimestamp = nextAttemptTimestamp;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for nextAttemptTimestamp
|
||||
*******************************************************************************/
|
||||
public WebhookEvent withNextAttemptTimestamp(Instant nextAttemptTimestamp)
|
||||
{
|
||||
this.nextAttemptTimestamp = nextAttemptTimestamp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingPossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** WebhookEventCategory - possible value enum
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingPossibleValueEnum()
|
||||
public enum WebhookEventCategory implements PossibleValueEnum<String>
|
||||
{
|
||||
INSERT("insert", "Record Inserted", Kind.INSERT, true, false, false),
|
||||
INSERT_WITH_FIELD("insert-with-field", "Record Inserted with Field", Kind.INSERT, true, true, false),
|
||||
INSERT_WITH_VALUE("insert-with-value", "Record Inserted with Specific Value", Kind.INSERT, true, true, true),
|
||||
|
||||
UPDATE("update", "Record Updated", Kind.UPDATE, true, false, false),
|
||||
UPDATE_WITH_FIELD("update-with-field", "Record Updated with Field", Kind.UPDATE, true, true, false),
|
||||
UPDATE_WITH_VALUE("update-with-value", "Record Updated with Specific Value", Kind.UPDATE, true, true, true),
|
||||
|
||||
STORE("store", "Record Stored", Kind.STORE, true, false, false),
|
||||
STORE_WITH_FIELD("store-with-field", "Record Stored with Field", Kind.STORE, true, true, false),
|
||||
STORE_WITH_VALUE("store-with-value", "Record Stored with Specific Value", Kind.STORE, true, true, true),
|
||||
|
||||
AD_HOC("ad-hoc", "Ad Hoc", Kind.AD_HOC, false, false, false);
|
||||
|
||||
private final String id;
|
||||
private final String label;
|
||||
private final Kind kind;
|
||||
private final boolean requiresTable;
|
||||
private final boolean requiresField;
|
||||
private final boolean requiresValue;
|
||||
|
||||
public static final String NAME = "WebhookEventCategory";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public enum Kind
|
||||
{
|
||||
INSERT,
|
||||
UPDATE,
|
||||
STORE,
|
||||
AD_HOC
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
WebhookEventCategory(String id, String label, Kind kind, boolean requiresTable, boolean requiresField, boolean requiresValue)
|
||||
{
|
||||
this.id = id;
|
||||
this.label = label;
|
||||
this.kind = kind;
|
||||
this.requiresTable = requiresTable;
|
||||
this.requiresField = requiresField;
|
||||
this.requiresValue = requiresValue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get instance by id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static WebhookEventCategory getById(String id)
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
for(WebhookEventCategory value : WebhookEventCategory.values())
|
||||
{
|
||||
if(Objects.equals(value.id, id))
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getId()
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (getLabel());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requiresTable
|
||||
**
|
||||
*******************************************************************************/
|
||||
public boolean getRequiresTable()
|
||||
{
|
||||
return requiresTable;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requiresField
|
||||
**
|
||||
*******************************************************************************/
|
||||
public boolean getRequiresField()
|
||||
{
|
||||
return requiresField;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requiresValue
|
||||
**
|
||||
*******************************************************************************/
|
||||
public boolean getRequiresValue()
|
||||
{
|
||||
return requiresValue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kind
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Kind getKind()
|
||||
{
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QRecord Entity for WebhookEventContent table
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingEntity(
|
||||
producePossibleValueSource = true,
|
||||
produceTableMetaData = true,
|
||||
tableMetaDataCustomizer = WebhookEventContent.TableMetaDataCustomizer.class
|
||||
)
|
||||
public class WebhookEventContent extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "webhookEventContent";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface<QTableMetaData>
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
String parentJoinName = QJoinMetaData.makeInferredJoinName(WebhookEvent.TABLE_NAME, WebhookEventContent.TABLE_NAME);
|
||||
|
||||
table
|
||||
.withIcon(new QIcon().withName("description"))
|
||||
.withUniqueKey(new UniqueKey("webhookEventId"))
|
||||
.withRecordLabelFormat("Event %s")
|
||||
.withRecordLabelFields("webhookEventId")
|
||||
.withSection(SectionFactory.defaultT1("id", "webhookEventId"))
|
||||
.withSection(SectionFactory.defaultT2("postBody"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Event").withJoinPath(List.of(parentJoinName)).withJoinTable(WebhookEvent.TABLE_NAME));
|
||||
|
||||
table.getField("postBody").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR)
|
||||
.withValue(AdornmentType.CodeEditorValues.languageMode("json")));
|
||||
|
||||
QBitConfig qBitConfig = QBitProductionContext.peekQBitConfig();
|
||||
if(qBitConfig instanceof WebhooksQBitConfig webhooksQBitConfig)
|
||||
{
|
||||
for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(webhooksQBitConfig.getRecordSecurityLocks()))
|
||||
{
|
||||
RecordSecurityLock lockClone = recordSecurityLock.clone();
|
||||
lockClone.setFieldName(WebhookEvent.TABLE_NAME + "." + lockClone.getFieldName());
|
||||
lockClone.setJoinNameChain(List.of(parentJoinName));
|
||||
table.withRecordSecurityLock(lockClone);
|
||||
}
|
||||
}
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(possibleValueSourceName = WebhookEvent.TABLE_NAME)
|
||||
private Integer webhookEventId;
|
||||
|
||||
///////////////////////////
|
||||
// text, so no maxLength //
|
||||
///////////////////////////
|
||||
@QField()
|
||||
private String postBody;
|
||||
|
||||
// todo? ///////////////////////////
|
||||
// todo? // text, so no maxLength //
|
||||
// todo? ///////////////////////////
|
||||
// todo? @QField()
|
||||
// todo? private String headers;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor
|
||||
*******************************************************************************/
|
||||
public WebhookEventContent()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that takes a QRecord
|
||||
*******************************************************************************/
|
||||
public WebhookEventContent(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public WebhookEventContent withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for webhookEventId
|
||||
*******************************************************************************/
|
||||
public Integer getWebhookEventId()
|
||||
{
|
||||
return (this.webhookEventId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for webhookEventId
|
||||
*******************************************************************************/
|
||||
public void setWebhookEventId(Integer webhookEventId)
|
||||
{
|
||||
this.webhookEventId = webhookEventId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for webhookEventId
|
||||
*******************************************************************************/
|
||||
public WebhookEventContent withWebhookEventId(Integer webhookEventId)
|
||||
{
|
||||
this.webhookEventId = webhookEventId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for postBody
|
||||
*******************************************************************************/
|
||||
public String getPostBody()
|
||||
{
|
||||
return (this.postBody);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for postBody
|
||||
*******************************************************************************/
|
||||
public void setPostBody(String postBody)
|
||||
{
|
||||
this.postBody = postBody;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for postBody
|
||||
*******************************************************************************/
|
||||
public WebhookEventContent withPostBody(String postBody)
|
||||
{
|
||||
this.postBody = postBody;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QRecord Entity for WebhookEventSendLog table
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingEntity(
|
||||
producePossibleValueSource = true,
|
||||
produceTableMetaData = true,
|
||||
tableMetaDataCustomizer = WebhookEventSendLog.TableMetaDataCustomizer.class
|
||||
)
|
||||
public class WebhookEventSendLog extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "webhookEventSendLog";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface<QTableMetaData>
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
String parentJoinName = QJoinMetaData.makeInferredJoinName(WebhookEvent.TABLE_NAME, WebhookEventSendLog.TABLE_NAME);
|
||||
|
||||
table
|
||||
.withIcon(new QIcon().withName("receipt_long"))
|
||||
.withRecordLabelFormat("Event %s Attempt %s")
|
||||
.withRecordLabelFields("webhookEventId", "attemptNo")
|
||||
.withSection(SectionFactory.defaultT1("id", "webhookId", "webhookEventId", "attemptNo"))
|
||||
.withSection(SectionFactory.defaultT2("successful", "httpStatusCode", "errorMessage"))
|
||||
.withSection(SectionFactory.defaultT3("startTimestamp", "endTimestamp"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Event").withJoinPath(List.of(parentJoinName)).withJoinTable(WebhookEvent.TABLE_NAME));
|
||||
|
||||
table.getField("successful").withFieldAdornment(new FieldAdornment(AdornmentType.CHIP)
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(true, "done", AdornmentType.ChipValues.COLOR_SUCCESS))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(false, "error", AdornmentType.ChipValues.COLOR_ERROR)));
|
||||
|
||||
QBitConfig qBitConfig = QBitProductionContext.peekQBitConfig();
|
||||
if(qBitConfig instanceof WebhooksQBitConfig webhooksQBitConfig)
|
||||
{
|
||||
for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(webhooksQBitConfig.getRecordSecurityLocks()))
|
||||
{
|
||||
RecordSecurityLock lockClone = recordSecurityLock.clone();
|
||||
lockClone.setFieldName(WebhookEvent.TABLE_NAME + "." + lockClone.getFieldName());
|
||||
lockClone.setJoinNameChain(List.of(parentJoinName));
|
||||
table.withRecordSecurityLock(lockClone);
|
||||
}
|
||||
}
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(possibleValueSourceName = Webhook.TABLE_NAME)
|
||||
private Integer webhookId;
|
||||
|
||||
@QField(possibleValueSourceName = WebhookEvent.TABLE_NAME)
|
||||
private Integer webhookEventId;
|
||||
|
||||
@QField(label = "HTTP Status Code")
|
||||
private Integer httpStatusCode;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String errorMessage;
|
||||
|
||||
@QField()
|
||||
private Boolean successful;
|
||||
|
||||
@QField()
|
||||
private Integer attemptNo;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant startTimestamp;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant endTimestamp;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that takes a QRecord
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for webhookEventId
|
||||
*******************************************************************************/
|
||||
public Integer getWebhookEventId()
|
||||
{
|
||||
return (this.webhookEventId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for webhookEventId
|
||||
*******************************************************************************/
|
||||
public void setWebhookEventId(Integer webhookEventId)
|
||||
{
|
||||
this.webhookEventId = webhookEventId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for webhookEventId
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withWebhookEventId(Integer webhookEventId)
|
||||
{
|
||||
this.webhookEventId = webhookEventId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for httpStatusCode
|
||||
*******************************************************************************/
|
||||
public Integer getHttpStatusCode()
|
||||
{
|
||||
return (this.httpStatusCode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for httpStatusCode
|
||||
*******************************************************************************/
|
||||
public void setHttpStatusCode(Integer httpStatusCode)
|
||||
{
|
||||
this.httpStatusCode = httpStatusCode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for httpStatusCode
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withHttpStatusCode(Integer httpStatusCode)
|
||||
{
|
||||
this.httpStatusCode = httpStatusCode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for successful
|
||||
*******************************************************************************/
|
||||
public Boolean getSuccessful()
|
||||
{
|
||||
return (this.successful);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for successful
|
||||
*******************************************************************************/
|
||||
public void setSuccessful(Boolean successful)
|
||||
{
|
||||
this.successful = successful;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for successful
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withSuccessful(Boolean successful)
|
||||
{
|
||||
this.successful = successful;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for attemptNo
|
||||
*******************************************************************************/
|
||||
public Integer getAttemptNo()
|
||||
{
|
||||
return (this.attemptNo);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for attemptNo
|
||||
*******************************************************************************/
|
||||
public void setAttemptNo(Integer attemptNo)
|
||||
{
|
||||
this.attemptNo = attemptNo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for attemptNo
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withAttemptNo(Integer attemptNo)
|
||||
{
|
||||
this.attemptNo = attemptNo;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startTimestamp
|
||||
*******************************************************************************/
|
||||
public Instant getStartTimestamp()
|
||||
{
|
||||
return (this.startTimestamp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for startTimestamp
|
||||
*******************************************************************************/
|
||||
public void setStartTimestamp(Instant startTimestamp)
|
||||
{
|
||||
this.startTimestamp = startTimestamp;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for startTimestamp
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withStartTimestamp(Instant startTimestamp)
|
||||
{
|
||||
this.startTimestamp = startTimestamp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for endTimestamp
|
||||
*******************************************************************************/
|
||||
public Instant getEndTimestamp()
|
||||
{
|
||||
return (this.endTimestamp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for endTimestamp
|
||||
*******************************************************************************/
|
||||
public void setEndTimestamp(Instant endTimestamp)
|
||||
{
|
||||
this.endTimestamp = endTimestamp;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for endTimestamp
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withEndTimestamp(Instant endTimestamp)
|
||||
{
|
||||
this.endTimestamp = endTimestamp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errorMessage
|
||||
*******************************************************************************/
|
||||
public String getErrorMessage()
|
||||
{
|
||||
return (this.errorMessage);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for errorMessage
|
||||
*******************************************************************************/
|
||||
public void setErrorMessage(String errorMessage)
|
||||
{
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for errorMessage
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withErrorMessage(String errorMessage)
|
||||
{
|
||||
this.errorMessage = errorMessage;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for webhookId
|
||||
*******************************************************************************/
|
||||
public Integer getWebhookId()
|
||||
{
|
||||
return (this.webhookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for webhookId
|
||||
*******************************************************************************/
|
||||
public void setWebhookId(Integer webhookId)
|
||||
{
|
||||
this.webhookId = webhookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for webhookId
|
||||
*******************************************************************************/
|
||||
public WebhookEventSendLog withWebhookId(Integer webhookId)
|
||||
{
|
||||
this.webhookId = webhookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingPossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingPossibleValueEnum()
|
||||
public enum WebhookEventStatus implements PossibleValueEnum<Integer>
|
||||
{
|
||||
NEW(1, "New"),
|
||||
SENDING(2, "Sending"),
|
||||
DELIVERED(3, "Delivered"),
|
||||
AWAITING_RETRY(4, "Awaiting Retry"),
|
||||
FAILED(5, "Failed");
|
||||
|
||||
private final Integer id;
|
||||
private final String label;
|
||||
|
||||
public static final String NAME = "WebhookEventStatus";
|
||||
|
||||
public static final String DEFAULT_VALUE = "1";
|
||||
|
||||
static
|
||||
{
|
||||
Objects.requireNonNull(getById(Integer.parseInt(DEFAULT_VALUE)), "Default value '" + DEFAULT_VALUE + " in WebhookEventStatus is not a defined enum value");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
WebhookEventStatus(Integer id, String label)
|
||||
{
|
||||
this.id = id;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get instance by id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static WebhookEventStatus getById(Integer id)
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
for(WebhookEventStatus value : WebhookEventStatus.values())
|
||||
{
|
||||
if(Objects.equals(value.id, id))
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer getPossibleValueId()
|
||||
{
|
||||
return (getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (getLabel());
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingPossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingPossibleValueEnum()
|
||||
public enum WebhookHealthStatus implements PossibleValueEnum<Integer>
|
||||
{
|
||||
HEALTHY(1, "Healthy"),
|
||||
UNHEALTHY(2, "Unhealthy"),
|
||||
PROBATION(3, "Probation");
|
||||
|
||||
private final Integer id;
|
||||
private final String label;
|
||||
|
||||
public static final String NAME = "WebhookHealthStatus";
|
||||
|
||||
public static final String DEFAULT_VALUE = "1";
|
||||
|
||||
static
|
||||
{
|
||||
Objects.requireNonNull(getById(Integer.parseInt(DEFAULT_VALUE)), "Default value '" + DEFAULT_VALUE + " in WebhookHealthStatus is not a defined enum value");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
WebhookHealthStatus(Integer id, String label)
|
||||
{
|
||||
this.id = id;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get instance by id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static WebhookHealthStatus getById(Integer id)
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
for(WebhookHealthStatus value : WebhookHealthStatus.values())
|
||||
{
|
||||
if(Objects.equals(value.id, id))
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
**
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
**
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer getPossibleValueId()
|
||||
{
|
||||
return (getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (getLabel());
|
||||
}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.actions.ClearWebhookSubscriptionHelperMemoizationsTableCustomizer;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventTypePossibleValueSource;
|
||||
import com.kingsrook.qqq.api.model.metadata.ApiInstanceMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QRecord Entity for WebhookSubscription table
|
||||
*******************************************************************************/
|
||||
@QMetaDataProducingEntity(
|
||||
producePossibleValueSource = true,
|
||||
produceTableMetaData = true,
|
||||
tableMetaDataCustomizer = WebhookSubscription.TableMetaDataCustomizer.class,
|
||||
childTables = {
|
||||
@ChildTable(
|
||||
childTableEntityClass = WebhookEvent.class,
|
||||
joinFieldName = "webhookId",
|
||||
childJoin = @ChildJoin(enabled = true),
|
||||
childRecordListWidget = @ChildRecordListWidget(label = "Events", enabled = true, maxRows = 25, widgetMetaDataCustomizer = WebhookSubscription.EventChildRecordWidgetMetaDataCustomizer.class))
|
||||
}
|
||||
)
|
||||
public class WebhookSubscription extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "webhookSubscription";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface<QTableMetaData>
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QTableMetaData customizeMetaData(QInstance qInstance, QTableMetaData table) throws QException
|
||||
{
|
||||
String parentJoinName = QJoinMetaData.makeInferredJoinName(Webhook.TABLE_NAME, WebhookSubscription.TABLE_NAME);
|
||||
String childJoinName = QJoinMetaData.makeInferredJoinName(WebhookSubscription.TABLE_NAME, WebhookEvent.TABLE_NAME);
|
||||
|
||||
table
|
||||
.withIcon(new QIcon().withName("subscriptions"))
|
||||
.withRecordLabelFormat("%s - %s")
|
||||
.withRecordLabelFields("webhookId", "webhookEventTypeName")
|
||||
.withUniqueKey(new UniqueKey("webhookEventTypeName", "webhookId"))
|
||||
.withSection(SectionFactory.defaultT1("id", "webhookId", "webhookEventTypeName"))
|
||||
.withSection(SectionFactory.defaultT2("activeStatusId", "apiName", "apiVersion"))
|
||||
// todo - if adding filters in future .withSection(SectionFactory.customT2("hidden", new QIcon().withName("hidden"), "queryFilterJson").withIsHidden(true))
|
||||
// todo - if adding filters in future .withSection(SectionFactory.customT2("filter", new QIcon("filter_alt")).withWidgetName(WebhookSubscriptionQueryFilterWidgetMetaDataProducer.NAME))
|
||||
.withSection(SectionFactory.customT2("events", new QIcon("notifications")).withWidgetName(childJoinName))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
|
||||
.withExposedJoin(new ExposedJoin().withLabel("Webhook").withJoinPath(List.of(parentJoinName)).withJoinTable(Webhook.TABLE_NAME));
|
||||
|
||||
QBitConfig qBitConfig = QBitProductionContext.peekQBitConfig();
|
||||
if(qBitConfig instanceof WebhooksQBitConfig webhooksQBitConfig)
|
||||
{
|
||||
if(StringUtils.hasContent(webhooksQBitConfig.getDefaultApiNameForNewSubscriptions()))
|
||||
{
|
||||
table.getField("apiName").withDefaultValue(webhooksQBitConfig.getDefaultApiNameForNewSubscriptions());
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(webhooksQBitConfig.getDefaultApiVersionForNewSubscriptions()))
|
||||
{
|
||||
table.getField("apiVersion").withDefaultValue(webhooksQBitConfig.getDefaultApiVersionForNewSubscriptions());
|
||||
}
|
||||
|
||||
for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(webhooksQBitConfig.getRecordSecurityLocks()))
|
||||
{
|
||||
RecordSecurityLock lockClone = recordSecurityLock.clone();
|
||||
lockClone.setFieldName(Webhook.TABLE_NAME + "." + lockClone.getFieldName());
|
||||
lockClone.setJoinNameChain(List.of(parentJoinName));
|
||||
table.withRecordSecurityLock(lockClone);
|
||||
}
|
||||
}
|
||||
|
||||
table.getField("activeStatusId").withFieldAdornment(new FieldAdornment(AdornmentType.CHIP)
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookActiveStatus.ACTIVE, "play_circle_outline", AdornmentType.ChipValues.COLOR_SUCCESS))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookActiveStatus.PAUSED, "pause_circle_outline", AdornmentType.ChipValues.COLOR_WARNING))
|
||||
.withValues(AdornmentType.ChipValues.iconAndColorValues(WebhookActiveStatus.DISABLED, "not_interested", AdornmentType.ChipValues.COLOR_ERROR)));
|
||||
|
||||
// todo - if adding filters in future table.getField("queryFilterJson").withBehavior(new FilterJsonFieldDisplayValueFormatter());
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// to clear the webhookSubscriptionHelper class's memoizations post insert/update/delete //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
QCodeReference clearWebhookSubscriptionHerlpMemoizations = new QCodeReference(ClearWebhookSubscriptionHelperMemoizationsTableCustomizer.class);
|
||||
table.withCustomizer(TableCustomizers.POST_INSERT_RECORD, clearWebhookSubscriptionHerlpMemoizations);
|
||||
table.withCustomizer(TableCustomizers.POST_UPDATE_RECORD, clearWebhookSubscriptionHerlpMemoizations);
|
||||
table.withCustomizer(TableCustomizers.POST_DELETE_RECORD, clearWebhookSubscriptionHerlpMemoizations);
|
||||
|
||||
return (table);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static class EventChildRecordWidgetMetaDataCustomizer implements MetaDataCustomizerInterface<QWidgetMetaData>
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData customizeMetaData(QInstance qInstance, QWidgetMetaData widget) throws QException
|
||||
{
|
||||
////////////////////////////
|
||||
// sort events by id desc //
|
||||
////////////////////////////
|
||||
ArrayList<QFilterOrderBy> orderBys = new ArrayList<>(List.of(new QFilterOrderBy("id", false)));
|
||||
return widget.withDefaultValue("orderBy", orderBys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = Webhook.TABLE_NAME)
|
||||
private Integer webhookId;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.ERROR, isRequired = true, possibleValueSourceName = WebhookEventTypePossibleValueSource.NAME, label = "Event Type")
|
||||
private String webhookEventTypeName;
|
||||
|
||||
//////////////////////////////
|
||||
// text, so omit max length //
|
||||
//////////////////////////////
|
||||
// todo - if adding filters in future @QField(label = "Filter")
|
||||
// todo - if adding filters in future private String queryFilterJson;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = WebhookActiveStatus.NAME, defaultValue = WebhookActiveStatus.DEFAULT_VALUE)
|
||||
private Integer activeStatusId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 50, valueTooLongBehavior = ValueTooLongBehavior.ERROR, label = "API Name", possibleValueSourceName = ApiInstanceMetaDataProvider.API_NAME_PVS_NAME)
|
||||
private String apiName;
|
||||
|
||||
@QField(isRequired = true, maxLength = 50, valueTooLongBehavior = ValueTooLongBehavior.ERROR, label = "API Version", possibleValueSourceName = ApiInstanceMetaDataProvider.API_VERSION_PVS_NAME)
|
||||
private String apiVersion;
|
||||
|
||||
@QField(isRequired = true, isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that takes a QRecord
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for webhookId
|
||||
*******************************************************************************/
|
||||
public Integer getWebhookId()
|
||||
{
|
||||
return (this.webhookId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for webhookId
|
||||
*******************************************************************************/
|
||||
public void setWebhookId(Integer webhookId)
|
||||
{
|
||||
this.webhookId = webhookId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for webhookId
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription withWebhookId(Integer webhookId)
|
||||
{
|
||||
this.webhookId = webhookId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for webhookEventTypeName
|
||||
*******************************************************************************/
|
||||
public String getWebhookEventTypeName()
|
||||
{
|
||||
return (this.webhookEventTypeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for webhookEventTypeName
|
||||
*******************************************************************************/
|
||||
public void setWebhookEventTypeName(String webhookEventTypeName)
|
||||
{
|
||||
this.webhookEventTypeName = webhookEventTypeName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for webhookEventTypeName
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription withWebhookEventTypeName(String webhookEventTypeName)
|
||||
{
|
||||
this.webhookEventTypeName = webhookEventTypeName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for apiName
|
||||
*******************************************************************************/
|
||||
public String getApiName()
|
||||
{
|
||||
return (this.apiName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for apiName
|
||||
*******************************************************************************/
|
||||
public void setApiName(String apiName)
|
||||
{
|
||||
this.apiName = apiName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for apiName
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription withApiName(String apiName)
|
||||
{
|
||||
this.apiName = apiName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for apiVersion
|
||||
*******************************************************************************/
|
||||
public String getApiVersion()
|
||||
{
|
||||
return (this.apiVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for apiVersion
|
||||
*******************************************************************************/
|
||||
public void setApiVersion(String apiVersion)
|
||||
{
|
||||
this.apiVersion = apiVersion;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for apiVersion
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription withApiVersion(String apiVersion)
|
||||
{
|
||||
this.apiVersion = apiVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for activeStatusId
|
||||
*******************************************************************************/
|
||||
public Integer getActiveStatusId()
|
||||
{
|
||||
return (this.activeStatusId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for activeStatusId
|
||||
*******************************************************************************/
|
||||
public void setActiveStatusId(Integer activeStatusId)
|
||||
{
|
||||
this.activeStatusId = activeStatusId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for activeStatusId
|
||||
*******************************************************************************/
|
||||
public WebhookSubscription withActiveStatusId(Integer activeStatusId)
|
||||
{
|
||||
this.activeStatusId = activeStatusId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.model;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** flags that can be put in an insert/update/delete event, to control webhook
|
||||
** events.
|
||||
*******************************************************************************/
|
||||
public enum WebhooksActionFlags implements ActionFlag
|
||||
{
|
||||
/***************************************************************************
|
||||
** cause webhook events to not be fired for the action.
|
||||
***************************************************************************/
|
||||
OMIT_WEBHOOKS
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.permissions;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.processes.WebhooksProcesses;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Maps every action on the qbit's tables, processes, widgets and app to one
|
||||
** of {@code webhooks.view}, {@code webhooks.manage} or {@code webhooks.admin}
|
||||
** (admin implies manage, manage implies view).
|
||||
**
|
||||
** <p>Deliveries and attempts are written only by the qbit (its own writes run
|
||||
** in an authorized scope); people and the API can read them and act on them
|
||||
** through the processes.</p>
|
||||
*******************************************************************************/
|
||||
public class WebhooksPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check, or throw.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData) throws QPermissionDeniedException
|
||||
{
|
||||
String needed = required(actionInput, metaData);
|
||||
if(needed == null)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Webhook deliveries and attempts are written only by the webhooks qbit. Use the Send Now, Replay or Cancel actions."));
|
||||
}
|
||||
if(!has(needed))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission an action needs (null: nobody may do it directly).
|
||||
*******************************************************************************/
|
||||
static String required(AbstractActionInput actionInput, MetaDataWithPermissionRules metaData)
|
||||
{
|
||||
boolean write = actionInput instanceof InsertInput || actionInput instanceof UpdateInput || actionInput instanceof DeleteInput;
|
||||
if(metaData instanceof QTableMetaData table)
|
||||
{
|
||||
if(!write)
|
||||
{
|
||||
return (WebhooksPermissions.VIEW);
|
||||
}
|
||||
if(WebhooksTables.SYSTEM_WRITTEN.contains(table.getName()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(WebhooksTables.SECRET.equals(table.getName()) || (actionInput instanceof DeleteInput && WebhooksTables.ENDPOINT.equals(table.getName())))
|
||||
{
|
||||
return (WebhooksPermissions.ADMIN);
|
||||
}
|
||||
return (WebhooksPermissions.MANAGE);
|
||||
}
|
||||
if(metaData instanceof QProcessMetaData process)
|
||||
{
|
||||
return (WebhooksProcesses.permissionFor(process.getName()));
|
||||
}
|
||||
if(metaData instanceof com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData widget
|
||||
&& com.kingsrook.qbits.webhooks.app.WebhooksApp.SECRET_VALUE_WIDGET.equals(widget.getName()))
|
||||
{
|
||||
return (WebhooksPermissions.ADMIN);
|
||||
}
|
||||
return (WebhooksPermissions.VIEW);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the current session holds the permission (or a higher one).
|
||||
*******************************************************************************/
|
||||
public static boolean has(String permission)
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(session.hasPermission(WebhooksPermissions.ADMIN))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(WebhooksPermissions.ADMIN.equals(permission))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(session.hasPermission(WebhooksPermissions.MANAGE))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
return (WebhooksPermissions.VIEW.equals(permission) && session.hasPermission(WebhooksPermissions.VIEW));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.permissions;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's permissions (full qbit name, {@code webhooks.*}).
|
||||
**
|
||||
** <ul>
|
||||
** <li>{@code webhooks.view}: the app, its home and every table, read-only.</li>
|
||||
** <li>{@code webhooks.manage}: create and edit endpoints and subscriptions;
|
||||
** Send Now, Replay, Cancel and Send Test Event.</li>
|
||||
** <li>{@code webhooks.admin}: signing secrets (rotate, add, retire), the
|
||||
** internal-network settings of an endpoint, deleting endpoints, and
|
||||
** purging old deliveries.</li>
|
||||
** </ul>
|
||||
**
|
||||
** <p>Hosts grant {@link #ALL} (plus {@code webhooks.hasAccess}, the base
|
||||
** permission QQQ derives for the app) to their administrators.</p>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksPermissions
|
||||
{
|
||||
public static final String VIEW = "webhooks.view";
|
||||
public static final String MANAGE = "webhooks.manage";
|
||||
public static final String ADMIN = "webhooks.admin";
|
||||
public static final String HAS_ACCESS = "webhooks.hasAccess";
|
||||
|
||||
public static final List<String> ALL = List.of(VIEW, MANAGE, ADMIN, HAS_ACCESS);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksPermissions()
|
||||
{
|
||||
}
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.LoadViaUpdateStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Meta Data Producer for ManageWebhookHealth
|
||||
*******************************************************************************/
|
||||
public class ManageWebhookHealthProcessMetaDataProducer extends MetaDataProducer<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "ManageWebhookHealth";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
QProcessMetaData processMetaData = StreamedETLWithFrontendProcess.processMetaDataBuilder()
|
||||
.withName(NAME)
|
||||
.withTableName(Webhook.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("healing"))
|
||||
.withExtractStepClass(ExtractViaQueryStep.class)
|
||||
.withTransformStepClass(ManageWebhookHealthTransformStep.class)
|
||||
.withLoadStepClass(LoadViaUpdateStep.class)
|
||||
.withSourceTable(Webhook.TABLE_NAME)
|
||||
.withDestinationTable(Webhook.TABLE_NAME)
|
||||
.withPreviewMessage(StreamedETLWithFrontendProcess.DEFAULT_PREVIEW_MESSAGE_FOR_UPDATE)
|
||||
.withReviewStepRecordFields(List.of(
|
||||
new QFieldMetaData("id", QFieldType.INTEGER),
|
||||
new QFieldMetaData("name", QFieldType.STRING),
|
||||
Webhook.TableMetaDataCustomizer.addHealthStatusChipAdornment(new QFieldMetaData("healthStatusId", QFieldType.INTEGER).withPossibleValueSourceName(WebhookHealthStatus.NAME))
|
||||
))
|
||||
.getProcessMetaData();
|
||||
|
||||
return (processMetaData);
|
||||
}
|
||||
|
||||
}
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventSendLog;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** check if any unhealthy webhooks are ready to go into Probation
|
||||
*******************************************************************************/
|
||||
public class ManageWebhookHealthTransformStep extends AbstractTransformStep
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(ManageWebhookHealthTransformStep.class);
|
||||
|
||||
private ProcessSummaryLine willPutOnProbationLine = new ProcessSummaryLine(Status.OK)
|
||||
.withMessageSuffix(" updated from Unhealthy to Probation")
|
||||
.withSingularFutureMessage("will be")
|
||||
.withPluralFutureMessage("will be")
|
||||
.withSingularPastMessage("was")
|
||||
.withPluralPastMessage("were");
|
||||
|
||||
private ProcessSummaryLine notUnhealthyLine = new ProcessSummaryLine(Status.INFO)
|
||||
.withMessageSuffix(" not currently Unhealthy")
|
||||
.withSingularFutureMessage("is")
|
||||
.withPluralFutureMessage("are")
|
||||
.withSingularPastMessage("was")
|
||||
.withPluralPastMessage("were");
|
||||
|
||||
private ProcessSummaryLine notUnhealthyLongEnoughLine = new ProcessSummaryLine(Status.ERROR)
|
||||
.withMessageSuffix(" Unhealthy long enough to be put on Probation")
|
||||
.withSingularFutureMessage("has not been")
|
||||
.withPluralFutureMessage("have not been")
|
||||
.withSingularPastMessage("was not")
|
||||
.withPluralPastMessage("were not");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
|
||||
willPutOnProbationLine.addSelfToListIfAnyCount(rs);
|
||||
notUnhealthyLine.addSelfToListIfAnyCount(rs);
|
||||
notUnhealthyLongEnoughLine.addSelfToListIfAnyCount(rs);
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
|
||||
{
|
||||
Set<Webhook> unhealthy = new HashSet<>();
|
||||
for(Webhook webhook : runBackendStepInput.getRecordsAsEntities(Webhook.class))
|
||||
{
|
||||
if(WebhookHealthStatus.UNHEALTHY.getId().equals(webhook.getHealthStatusId()))
|
||||
{
|
||||
unhealthy.add(webhook);
|
||||
}
|
||||
else
|
||||
{
|
||||
notUnhealthyLine.incrementCountAndAddPrimaryKey(webhook.getId());
|
||||
}
|
||||
}
|
||||
|
||||
if(CollectionUtils.nullSafeIsEmpty(unhealthy))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Integer timeoutMinutes = WebhooksQBitConfig.getConfigValue(config -> config.getUnhealthyToProbationTimeoutMinutes());
|
||||
Instant limitTime = Instant.now().minus(timeoutMinutes, ChronoUnit.MINUTES);
|
||||
|
||||
for(Webhook webhook : unhealthy)
|
||||
{
|
||||
List<WebhookEventSendLog> eventLogs = new QueryAction().execute(new QueryInput(WebhookEventSendLog.TABLE_NAME).withFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("webhookId", QCriteriaOperator.EQUALS, webhook.getId()))
|
||||
.withOrderBy(new QFilterOrderBy("id", false))
|
||||
.withLimit(1))).getRecordEntities(WebhookEventSendLog.class);
|
||||
Instant lastEventTimestamp = Instant.now();
|
||||
if(!eventLogs.isEmpty() && eventLogs.get(0).getStartTimestamp() != null)
|
||||
{
|
||||
lastEventTimestamp = eventLogs.get(0).getStartTimestamp();
|
||||
}
|
||||
|
||||
if(lastEventTimestamp.isBefore(limitTime))
|
||||
{
|
||||
willPutOnProbationLine.incrementCountAndAddPrimaryKey(webhook.getId());
|
||||
runBackendStepOutput.addRecord(new QRecord()
|
||||
.withValue("id", webhook.getId())
|
||||
.withValue("name", webhook.getName()) // just for preview screen
|
||||
.withValue("healthStatusId", WebhookHealthStatus.PROBATION.getId()));
|
||||
}
|
||||
else
|
||||
{
|
||||
notUnhealthyLongEnoughLine.incrementCountAndAddPrimaryKey(webhook.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.actions.WebhookEventSender;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventContent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventSendLog;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.general.ProcessSummaryWarningsAndErrorsRollup;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sends test events to webhooks.
|
||||
*******************************************************************************/
|
||||
public class SendTestEventToWebhookLoadStep extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SendTestEventToWebhookLoadStep.class);
|
||||
|
||||
private ProcessSummaryLine okLine = new ProcessSummaryLine(Status.OK)
|
||||
.withMessageSuffix(" delivered successfully")
|
||||
.withSingularPastMessage("was")
|
||||
.withPluralPastMessage("were");
|
||||
|
||||
private ProcessSummaryLine updatedToProbationLine = new ProcessSummaryLine(Status.INFO)
|
||||
.withMessageSuffix(" updated from Unhealthy to Probation")
|
||||
.withSingularPastMessage("was")
|
||||
.withPluralPastMessage("were");
|
||||
|
||||
private ProcessSummaryWarningsAndErrorsRollup errorsRollup = new ProcessSummaryWarningsAndErrorsRollup()
|
||||
.withDoReplaceSingletonCountLinesWithSuffixOnly(false)
|
||||
.withErrorTemplate(new ProcessSummaryLine(Status.ERROR)
|
||||
.withSingularPastMessage("failed to send with error message: ")
|
||||
.withPluralPastMessage("failed to send with error message: "))
|
||||
.withOtherErrorsSummary(new ProcessSummaryLine(Status.ERROR)
|
||||
.withSingularPastMessage("had an other error.")
|
||||
.withPluralPastMessage("had other errors."));
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
|
||||
okLine.addSelfToListIfAnyCount(rs);
|
||||
updatedToProbationLine.addSelfToListIfAnyCount(rs);
|
||||
errorsRollup.addToList(rs);
|
||||
return rs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
|
||||
{
|
||||
boolean uponSuccessUpdateUnhealthyWebhooksToProbation = BooleanUtils.isTrue(runBackendStepInput.getValueBoolean("uponSuccessUpdateUnhealthyWebhooksToProbation"));
|
||||
List<QRecord> webhooksToUpdate = new ArrayList<>();
|
||||
|
||||
for(Webhook webhook : runBackendStepInput.getRecordsAsEntities(Webhook.class))
|
||||
{
|
||||
WebhookEventSendLog sendLog;
|
||||
try
|
||||
{
|
||||
WebhookEvent event = new WebhookEvent().withContent(List.of(new WebhookEventContent()
|
||||
.withPostBody("""
|
||||
{
|
||||
"webhookEventDetails":
|
||||
{
|
||||
"webhookEventTypeName": "testEvent",
|
||||
"eventTimestamp": "%s",
|
||||
"tableName": null,
|
||||
"apiName": null,
|
||||
"apiVersion": null
|
||||
},
|
||||
"record":
|
||||
{
|
||||
}
|
||||
}""".formatted(Instant.now().toString()))
|
||||
));
|
||||
|
||||
sendLog = getWebhookEventSender().post(event, webhook);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
sendLog = new WebhookEventSendLog()
|
||||
.withSuccessful(false)
|
||||
.withErrorMessage(e.getMessage());
|
||||
}
|
||||
|
||||
if(sendLog.getSuccessful())
|
||||
{
|
||||
if(WebhookHealthStatus.UNHEALTHY.getId().equals(webhook.getHealthStatusId()))
|
||||
{
|
||||
webhooksToUpdate.add(new QRecord().withValue("id", webhook.getId()).withValue("healthStatusId", WebhookHealthStatus.PROBATION.getId()));
|
||||
updatedToProbationLine.incrementCountAndAddPrimaryKey(webhook.getId());
|
||||
}
|
||||
|
||||
okLine.incrementCountAndAddPrimaryKey(webhook.getId());
|
||||
}
|
||||
else
|
||||
{
|
||||
if(sendLog.getHttpStatusCode() != null)
|
||||
{
|
||||
errorsRollup.addError("Status Code: " + sendLog.getHttpStatusCode() + "; " + sendLog.getErrorMessage(), webhook.getId());
|
||||
}
|
||||
else
|
||||
{
|
||||
errorsRollup.addError(sendLog.getErrorMessage(), webhook.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(uponSuccessUpdateUnhealthyWebhooksToProbation && !webhooksToUpdate.isEmpty())
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput(Webhook.TABLE_NAME).withRecords(webhooksToUpdate));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
protected WebhookEventSender getWebhookEventSender()
|
||||
{
|
||||
return new WebhookEventSender();
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.NoopTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Meta Data Producer for SendTestEventToWebhook
|
||||
*******************************************************************************/
|
||||
public class SendTestEventToWebhookProcessMetaDataProducer extends MetaDataProducer<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "SendTestEventToWebhook";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
QProcessMetaData processMetaData = StreamedETLWithFrontendProcess.processMetaDataBuilder()
|
||||
.withName(NAME)
|
||||
.withTableName(Webhook.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("quiz"))
|
||||
.withExtractStepClass(ExtractViaQueryStep.class)
|
||||
.withTransformStepClass(NoopTransformStep.class)
|
||||
.withLoadStepClass(SendTestEventToWebhookLoadStep.class)
|
||||
.withSourceTable(Webhook.TABLE_NAME)
|
||||
.withDestinationTable(Webhook.TABLE_NAME)
|
||||
.withPreviewMessage(StreamedETLWithFrontendProcess.DEFAULT_PREVIEW_MESSAGE_PREFIX + " sent a test event")
|
||||
.withReviewStepRecordFields(List.of(
|
||||
new QFieldMetaData("id", QFieldType.INTEGER),
|
||||
new QFieldMetaData("name", QFieldType.STRING),
|
||||
new QFieldMetaData("url", QFieldType.STRING),
|
||||
Webhook.TableMetaDataCustomizer.addHealthStatusChipAdornment(new QFieldMetaData("healthStatusId", QFieldType.INTEGER).withPossibleValueSourceName(WebhookHealthStatus.NAME))
|
||||
))
|
||||
.getProcessMetaData();
|
||||
|
||||
processMetaData.withStep(0, new QFrontendStepMetaData()
|
||||
.withName("input")
|
||||
.withComponent(new QFrontendComponentMetaData()
|
||||
.withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("uponSuccessUpdateUnhealthyWebhooksToProbation", QFieldType.BOOLEAN)
|
||||
.withDefaultValue("true")
|
||||
.withLabel("Upon Success Update Unhealthy Webhooks To Probation?")));
|
||||
|
||||
return (processMetaData);
|
||||
}
|
||||
|
||||
}
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class SendWebhookEventExtractStep extends ExtractViaQueryStep
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SendWebhookEventExtractStep.class);
|
||||
|
||||
private static QQueryFilter falseFilter = new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.FALSE));
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected QQueryFilter getQueryFilter(RunBackendStepInput runBackendStepInput) throws QException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we've been asked to run for a specific webhook, then look up events that need sent for that hook //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
Integer webhookId = runBackendStepInput.getValueInteger("webhookId");
|
||||
if(webhookId != null)
|
||||
{
|
||||
return getQueryFilterForSingleWebhook(webhookId);
|
||||
}
|
||||
|
||||
return super.getQueryFilter(runBackendStepInput);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
private static QQueryFilter getQueryFilterForSingleWebhook(Integer webhookId) throws QException
|
||||
{
|
||||
QRecord webhookRecord = GetAction.execute(Webhook.TABLE_NAME, webhookId);
|
||||
if(webhookRecord == null)
|
||||
{
|
||||
LOG.debug("Running for non-existing webhook - returning query to find no rows.", logPair("webhookId", webhookId));
|
||||
return (falseFilter);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// only return records for active webhooks //
|
||||
/////////////////////////////////////////////
|
||||
Webhook webhook = new Webhook(webhookRecord);
|
||||
if(!WebhookActiveStatus.ACTIVE.getId().equals(webhook.getActiveStatusId()))
|
||||
{
|
||||
LOG.debug("Running for non-active webhook - returning query to find no rows.", logPair("webhookId", webhookId));
|
||||
return falseFilter;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// don't return records for unhealthy webhooks //
|
||||
/////////////////////////////////////////////////
|
||||
if(WebhookHealthStatus.UNHEALTHY.getId().equals(webhook.getHealthStatusId()))
|
||||
{
|
||||
LOG.debug("Running for non-healthy webhook - returning query to find no rows.", logPair("webhookId", webhookId));
|
||||
return falseFilter;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// webhook_id = ? //
|
||||
// AND //
|
||||
// ( //
|
||||
// (status = NEW) //
|
||||
// OR (status IN (SENDING, AWAITING_RETRY) AND next_attempt_timestamp < now()) //
|
||||
// ) //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
QQueryFilter filter = new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("webhookId", QCriteriaOperator.EQUALS, webhookId));
|
||||
|
||||
QQueryFilter orStatusFilters = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR);
|
||||
filter.addSubFilter(orStatusFilters);
|
||||
|
||||
orStatusFilters.addSubFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("eventStatusId", QCriteriaOperator.EQUALS, WebhookEventStatus.NEW.getId())));
|
||||
|
||||
orStatusFilters.addSubFilter(new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("eventStatusId", QCriteriaOperator.IN, WebhookEventStatus.SENDING.getId(), WebhookEventStatus.AWAITING_RETRY.getId()))
|
||||
.withCriteria(new QFilterCriteria("nextAttemptTimestamp", QCriteriaOperator.IS_NOT_BLANK))
|
||||
.withCriteria(new QFilterCriteria("nextAttemptTimestamp", QCriteriaOperator.LESS_THAN, Instant.now())));
|
||||
|
||||
filter.addOrderBy(new QFilterOrderBy("id"));
|
||||
return (filter);
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Stack;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.actions.WebhookEventSender;
|
||||
import com.kingsrook.qbits.webhooks.actions.WebhookHealthManager;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventSendLog;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ListingHash;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.IN;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class SendWebhookEventLoadStep extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SendWebhookEventLoadStep.class);
|
||||
|
||||
///////////////////////
|
||||
// most recent first //
|
||||
///////////////////////
|
||||
private boolean doHealthChecks = false;
|
||||
private Integer repeatedFailsToGoUnhealthy;
|
||||
private Stack<WebhookEventSendLog> recentSendLogs;
|
||||
|
||||
private ProcessSummaryLine okLine = new ProcessSummaryLine(Status.OK)
|
||||
.withMessageSuffix(" delivered")
|
||||
.withSingularPastMessage("was")
|
||||
.withPluralPastMessage("were");
|
||||
|
||||
private ProcessSummaryLine failLine = new ProcessSummaryLine(Status.ERROR, "failed to deliver");
|
||||
|
||||
private ProcessSummaryLine becameUnhealthyLine = new ProcessSummaryLine(Status.WARNING)
|
||||
.withMessageSuffix(" associated webhook became unhealthy.")
|
||||
.withSingularPastMessage("was not sent because its")
|
||||
.withPluralPastMessage("were not sent because their");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> rs = getTransformStep().getProcessSummary(runBackendStepOutput, isForResultScreen);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// replace the OK and WARNING lines from the transform step with our own //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
rs.removeIf(p -> p instanceof ProcessSummaryLine psl && (psl.getStatus().equals(Status.OK) || psl.getStatus().equals(Status.WARNING)));
|
||||
|
||||
if(okLine.getCount() != null && okLine.getCount() > 0)
|
||||
{
|
||||
rs.add(0, okLine);
|
||||
}
|
||||
|
||||
if(failLine.getCount() != null && failLine.getCount() > 0)
|
||||
{
|
||||
rs.add(0, failLine);
|
||||
}
|
||||
|
||||
becameUnhealthyLine.addSelfToListIfAnyCount(rs);
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void preRun(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
|
||||
{
|
||||
repeatedFailsToGoUnhealthy = WebhooksQBitConfig.getConfigValue(config -> config.getRepeatedFailsToGoUnhealthy());
|
||||
if(repeatedFailsToGoUnhealthy != null)
|
||||
{
|
||||
recentSendLogs = newSendLogStack(repeatedFailsToGoUnhealthy);
|
||||
doHealthChecks = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
|
||||
{
|
||||
if(runBackendStepInput.getRecords().isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Webhook webhook = ((SendWebhookEventTransformStep) getTransformStep()).getWebhook();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// look up the last several send logs for this webhook, to seed the recentSuccesses list //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(doHealthChecks)
|
||||
{
|
||||
List<WebhookEventSendLog> recentSendLogs = QueryAction.execute(WebhookEventSendLog.TABLE_NAME, WebhookEventSendLog.class, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("webhookId", QCriteriaOperator.EQUALS, webhook.getId()))
|
||||
.withOrderBy(new QFilterOrderBy("id", false))
|
||||
.withLimit(repeatedFailsToGoUnhealthy));
|
||||
Collections.reverse(recentSendLogs);
|
||||
this.recentSendLogs.addAll(recentSendLogs);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// look up send logs for all events in this page //
|
||||
///////////////////////////////////////////////////
|
||||
List<WebhookEvent> webhookEvents = runBackendStepInput.getRecordsAsEntities(WebhookEvent.class);
|
||||
List<WebhookEventSendLog> eventLogs = QueryAction.execute(WebhookEventSendLog.TABLE_NAME, WebhookEventSendLog.class, new QQueryFilter(
|
||||
new QFilterCriteria("webhookEventId", IN, webhookEvents.stream().map(e -> e.getId()).toList())));
|
||||
ListingHash<Integer, WebhookEventSendLog> eventLogsByEventId = CollectionUtils.listToListingHash(eventLogs, l -> l.getWebhookEventId());
|
||||
|
||||
boolean becameUnhealthy = false;
|
||||
for(WebhookEvent webhookEvent : webhookEvents)
|
||||
{
|
||||
if(becameUnhealthy)
|
||||
{
|
||||
becameUnhealthyLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
boolean lastSuccessful = newWebhookEventSender().handleEvent(webhookEvent, webhook, eventLogsByEventId.getOrDefault(webhookEvent.getId(), Collections.emptyList()));
|
||||
|
||||
if(lastSuccessful)
|
||||
{
|
||||
okLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
}
|
||||
else
|
||||
{
|
||||
failLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we're to update health, then add this last-successful boolean to the stack, and call the health manager //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(doHealthChecks)
|
||||
{
|
||||
recentSendLogs.push(new WebhookEventSendLog()
|
||||
.withSuccessful(lastSuccessful));
|
||||
webhook = new WebhookHealthManager().updateWebhookHealth(recentSendLogs, webhook);
|
||||
}
|
||||
|
||||
if(WebhookHealthStatus.UNHEALTHY.getId().equals(webhook.getHealthStatusId()))
|
||||
{
|
||||
LOG.info("Webhook is now unhealthy - not attempting any more sends.", logPair("id", webhook.getId()));
|
||||
becameUnhealthy = true;
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Error handing webhook event", e, logPair("id", webhookEvent.getId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** meant so class can be overridden to use a different sender
|
||||
***************************************************************************/
|
||||
protected WebhookEventSender newWebhookEventSender()
|
||||
{
|
||||
return new WebhookEventSender();
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* meant for tests - to make a stack of send log events
|
||||
***************************************************************************/
|
||||
public Stack<WebhookEventSendLog> newSendLogStack(int maxSize)
|
||||
{
|
||||
return (new FixedStack<>(maxSize));
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
static class FixedStack<T> extends Stack<T>
|
||||
{
|
||||
private final int maxSize;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
public FixedStack(int maxSize)
|
||||
{
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public T push(T item)
|
||||
{
|
||||
if(size() >= maxSize)
|
||||
{
|
||||
remove(0); // remove bottom element
|
||||
}
|
||||
return super.push(item); // then push on top
|
||||
}
|
||||
}
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookSubscription;
|
||||
import com.kingsrook.qbits.webhooks.registry.WebhookEventTypePossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.tables.QQQTable;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Meta Data Producer for SendWebhookEventProcess
|
||||
*******************************************************************************/
|
||||
public class SendWebhookEventProcessMetaDataProducer extends MetaDataProducer<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "SendWebhookEvent";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
QProcessMetaData processMetaData = StreamedETLWithFrontendProcess.processMetaDataBuilder()
|
||||
.withName(NAME)
|
||||
.withTableName(WebhookEvent.TABLE_NAME)
|
||||
.withIcon(new QIcon().withName("send"))
|
||||
.withExtractStepClass(SendWebhookEventExtractStep.class)
|
||||
.withTransformStepClass(SendWebhookEventTransformStep.class)
|
||||
.withLoadStepClass(SendWebhookEventLoadStep.class)
|
||||
.withSourceTable(WebhookEvent.TABLE_NAME)
|
||||
.withDestinationTable(WebhookEvent.TABLE_NAME)
|
||||
.withPreviewMessage(StreamedETLWithFrontendProcess.DEFAULT_PREVIEW_MESSAGE_PREFIX + " considered for sending")
|
||||
.withReviewStepRecordFields(List.of(
|
||||
new QFieldMetaData("id", QFieldType.INTEGER),
|
||||
new QFieldMetaData("webhookId", QFieldType.INTEGER).withPossibleValueSourceName(Webhook.TABLE_NAME),
|
||||
new QFieldMetaData("webhookSubscriptionId", QFieldType.INTEGER).withPossibleValueSourceName(WebhookSubscription.TABLE_NAME),
|
||||
new QFieldMetaData("webhookEventTypeName", QFieldType.STRING).withLabel("Event Type").withPossibleValueSourceName(WebhookEventTypePossibleValueSource.NAME),
|
||||
new QFieldMetaData("eventSourceRecordQqqTableId", QFieldType.INTEGER).withLabel("Event Source Record Table").withPossibleValueSourceName(QQQTable.TABLE_NAME),
|
||||
new QFieldMetaData("eventSourceRecordId", QFieldType.INTEGER),
|
||||
WebhookEvent.TableMetaDataCustomizer.addEventStatusChipAdornment(new QFieldMetaData("eventStatusId", QFieldType.INTEGER).withPossibleValueSourceName(WebhookEventStatus.NAME))
|
||||
))
|
||||
.withInputFieldDefaultValue(StreamedETLWithFrontendProcess.FIELD_INCLUDE_ASSOCIATIONS, true)
|
||||
.getProcessMetaData();
|
||||
|
||||
return (processMetaData);
|
||||
}
|
||||
|
||||
}
|
||||
-313
@@ -1,313 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookHealthStatus;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookSubscription;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class SendWebhookEventTransformStep extends AbstractTransformStep
|
||||
{
|
||||
private Integer webhookId;
|
||||
private Webhook webhook;
|
||||
|
||||
private Map<Integer, WebhookSubscription> subscriptionMap = new HashMap<>();
|
||||
|
||||
private ProcessSummaryLine okLine = new ProcessSummaryLine(Status.OK)
|
||||
.withMessageSuffix(" sent")
|
||||
.withSingularFutureMessage("will be")
|
||||
.withPluralFutureMessage("will be")
|
||||
.withSingularPastMessage("was")
|
||||
.withPluralPastMessage("were");
|
||||
|
||||
private ProcessSummaryLine differentWebhookLine = new ProcessSummaryLine(Status.ERROR)
|
||||
.withMessageSuffix(" sent because events were found for more than one webhook")
|
||||
.withSingularFutureMessage("will not be")
|
||||
.withPluralFutureMessage("will not be")
|
||||
.withSingularPastMessage("was not")
|
||||
.withPluralPastMessage("were not");
|
||||
|
||||
private ProcessSummaryLine missingWebhookLine = new ProcessSummaryLine(Status.ERROR)
|
||||
.withMessageSuffix(" associated webhook was not found")
|
||||
.withSingularFutureMessage("will not be sent because its")
|
||||
.withPluralFutureMessage("will not be sent because their")
|
||||
.withSingularPastMessage("was not sent because its")
|
||||
.withPluralPastMessage("were not sent because their");
|
||||
|
||||
private ProcessSummaryLine disabledSubscriptionLine = new ProcessSummaryLine(Status.ERROR)
|
||||
.withSingularFutureMessage("will not be sent because its associated subscription is disabled")
|
||||
.withPluralFutureMessage("will not be sent because their associated subscriptions are disabled")
|
||||
.withSingularPastMessage("was not sent because its associated subscription is disabled")
|
||||
.withPluralPastMessage("were not sent because their associated subscriptions are disabled");
|
||||
|
||||
private ProcessSummaryLine disabledWebhookLine = new ProcessSummaryLine(Status.ERROR)
|
||||
.withSingularFutureMessage("will not be sent because its associated webhook is disabled")
|
||||
.withPluralFutureMessage("will not be sent because their associated webhooks disabled")
|
||||
.withSingularPastMessage("was not sent because its associated webhook is disabled")
|
||||
.withPluralPastMessage("were not sent because their associated webhooks are disabled");
|
||||
|
||||
private ProcessSummaryLine unhealthyWebhookLine = new ProcessSummaryLine(Status.ERROR)
|
||||
.withSingularFutureMessage("will not be sent because its associated webhook is unhealthy")
|
||||
.withPluralFutureMessage("will not be sent because their associated webhooks unhealthy")
|
||||
.withSingularPastMessage("was not sent because its associated webhook is unhealthy")
|
||||
.withPluralPastMessage("were not sent because their associated webhooks are unhealthy");
|
||||
|
||||
private ProcessSummaryLine missingSubscriptionLine = new ProcessSummaryLine(Status.ERROR)
|
||||
.withMessageSuffix(" associated subscription was not found")
|
||||
.withSingularFutureMessage("will not be sent because its")
|
||||
.withPluralFutureMessage("will not be sent because their")
|
||||
.withSingularPastMessage("was not sent because its")
|
||||
.withPluralPastMessage("were not sent because their");
|
||||
|
||||
private ProcessSummaryLine alreadyDeliveredWarningLine = new ProcessSummaryLine(Status.WARNING)
|
||||
.withSingularFutureMessage("was already successfully delivered, but will be sent again")
|
||||
.withPluralFutureMessage("were already successfully delivered, but will be sent again")
|
||||
.withSingularPastMessage("was already successfully delivered, but was sent again")
|
||||
.withPluralPastMessage("were already successfully delivered, but were sent again");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void preRun(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// capture if we're running for a single specified webhook Id //
|
||||
////////////////////////////////////////////////////////////////
|
||||
webhookId = runBackendStepInput.getValueInteger("webhookId");
|
||||
super.preRun(runBackendStepInput, runBackendStepOutput);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
|
||||
okLine.addSelfToListIfAnyCount(rs);
|
||||
alreadyDeliveredWarningLine.addSelfToListIfAnyCount(rs);
|
||||
differentWebhookLine.addSelfToListIfAnyCount(rs);
|
||||
missingWebhookLine.addSelfToListIfAnyCount(rs);
|
||||
missingSubscriptionLine.addSelfToListIfAnyCount(rs);
|
||||
disabledWebhookLine.addSelfToListIfAnyCount(rs);
|
||||
unhealthyWebhookLine.addSelfToListIfAnyCount(rs);
|
||||
disabledSubscriptionLine.addSelfToListIfAnyCount(rs);
|
||||
return rs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
|
||||
{
|
||||
////////////////////////////////////////////////////
|
||||
// fetch the subscriptions for the webhook events //
|
||||
////////////////////////////////////////////////////
|
||||
List<WebhookEvent> webhookEvents = runBackendStepInput.getRecordsAsEntities(WebhookEvent.class);
|
||||
Set<Integer> subscriptionIds = webhookEvents.stream().map(we -> we.getWebhookSubscriptionId()).collect(Collectors.toSet());
|
||||
loadSubscriptions(subscriptionIds);
|
||||
|
||||
for(WebhookEvent webhookEvent : webhookEvents)
|
||||
{
|
||||
if(webhookEvent.getWebhookId() == null)
|
||||
{
|
||||
//////////////////////////////////////////////////
|
||||
// a little bit of a misnomer, but close enough //
|
||||
//////////////////////////////////////////////////
|
||||
missingWebhookLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if(webhookEvent.getWebhookSubscriptionId() == null || !subscriptionIds.contains(webhookEvent.getWebhookSubscriptionId()))
|
||||
{
|
||||
missingSubscriptionLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we aren't running for a specific webhook, then grab the id off the first one we find //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(webhookId == null)
|
||||
{
|
||||
webhookId = webhookEvent.getWebhookId();
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// in case events from multiple webhooks were found, only allow them for one //
|
||||
// (the first one we found). this will simplify sending, at least at this time. //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
if(!Objects.equals(webhookId, webhookEvent.getWebhookId()))
|
||||
{
|
||||
differentWebhookLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we haven't loaded the webhook record yet, then do so now (should be on the first/only one we're using) //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(webhook == null && webhookId != null)
|
||||
{
|
||||
QRecord webhookRecord = GetAction.execute(Webhook.TABLE_NAME, webhookId);
|
||||
if(webhookRecord == null)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if that webhook is missing, skip this line. //
|
||||
// note, if we had many records selected, all for a non-existing webhook, we'd do this //
|
||||
// query a bunch, but, not too concerned about that scenario realistically happening. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
missingWebhookLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
webhook = new Webhook(webhookRecord);
|
||||
}
|
||||
|
||||
if(webhook == null)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// I think this is redundant, but it stops compiler warnings about webhook maybe being null //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
missingWebhookLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if(Objects.equals(WebhookActiveStatus.DISABLED.getId(), webhook.getActiveStatusId()))
|
||||
{
|
||||
disabledWebhookLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if(Objects.equals(WebhookHealthStatus.UNHEALTHY.getId(), webhook.getHealthStatusId()))
|
||||
{
|
||||
unhealthyWebhookLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
WebhookSubscription webhookSubscription = subscriptionMap.get(webhookEvent.getWebhookSubscriptionId());
|
||||
if(webhookSubscription == null)
|
||||
{
|
||||
missingSubscriptionLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if(Objects.equals(WebhookActiveStatus.DISABLED.getId(), webhookSubscription.getActiveStatusId()))
|
||||
{
|
||||
disabledSubscriptionLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
continue;
|
||||
}
|
||||
|
||||
if(WebhookEventStatus.DELIVERED.getId().equals(webhookEvent.getEventStatusId()))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// already delivered gets a warning, but still proceeds to load step //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
alreadyDeliveredWarningLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
runBackendStepOutput.addRecordEntity(webhookEvent);
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////
|
||||
// finally all ok - move the record to the load step //
|
||||
///////////////////////////////////////////////////////
|
||||
okLine.incrementCountAndAddPrimaryKey(webhookEvent.getId());
|
||||
runBackendStepOutput.addRecordEntity(webhookEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
private void loadSubscriptions(Set<Integer> subscriptionIds) throws QException
|
||||
{
|
||||
Set<Integer> subscriptionsToLookup = new HashSet<>(subscriptionIds);
|
||||
subscriptionsToLookup.removeAll(subscriptionMap.keySet());
|
||||
if(!subscriptionsToLookup.isEmpty())
|
||||
{
|
||||
List<WebhookSubscription> subscriptions = QueryAction.execute(WebhookSubscription.TABLE_NAME, WebhookSubscription.class, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, subscriptionsToLookup)));
|
||||
for(WebhookSubscription subscription : subscriptions)
|
||||
{
|
||||
subscriptionMap.put(subscription.getId(), subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
Webhook getWebhook()
|
||||
{
|
||||
return webhook;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** originally meant for tests
|
||||
***************************************************************************/
|
||||
SendWebhookEventTransformStep withWebhook(Webhook webhook)
|
||||
{
|
||||
this.webhook = webhook;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.model.Webhook;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookActiveStatus;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.scheduledjobs.ScheduledJob;
|
||||
import com.kingsrook.qqq.backend.core.scheduler.processes.AbstractRecordSyncToScheduledJobProcess;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class SyncWebhookScheduledJobProcess extends AbstractRecordSyncToScheduledJobProcess
|
||||
{
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected ScheduledJob customizeScheduledJob(ScheduledJob scheduledJob, QRecord sourceRecord) throws QException
|
||||
{
|
||||
scheduledJob.setRepeatSeconds(WebhooksQBitConfig.getConfigValue(config -> config.getSendWebhookEventProcessRepeatSeconds()));
|
||||
|
||||
boolean isActive = (WebhookActiveStatus.ACTIVE.getId().equals(sourceRecord.getValueInteger("activeStatusId")));
|
||||
scheduledJob.setIsActive(isActive);
|
||||
|
||||
if(!StringUtils.hasContent(scheduledJob.getSchedulerName()))
|
||||
{
|
||||
scheduledJob.setSchedulerName(WebhooksQBitConfig.getConfigValue(config -> config.getSchedulerName()));
|
||||
}
|
||||
|
||||
return (scheduledJob);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getScheduledJobForeignKeyType()
|
||||
{
|
||||
return (Webhook.TABLE_NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getRecordForeignKeyFieldName()
|
||||
{
|
||||
return ("id");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getRecordForeignKeyPossibleValueSourceName()
|
||||
{
|
||||
return (Webhook.TABLE_NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getSourceTableName()
|
||||
{
|
||||
return (Webhook.TABLE_NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
protected String getProcessNameScheduledJobParameter()
|
||||
{
|
||||
return (SendWebhookEventProcessMetaDataProducer.NAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.delivery.DeliveryEngine;
|
||||
import com.kingsrook.qbits.webhooks.delivery.EndpointHealth;
|
||||
import com.kingsrook.qbits.webhooks.events.DeliveryOutbox;
|
||||
import com.kingsrook.qbits.webhooks.events.WebhookEvent;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.DeliveryStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.EventSource;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.permissions.WebhooksPermissions;
|
||||
import com.kingsrook.qbits.webhooks.secrets.EndpointSecrets;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit's processes. People run them as table or record actions (never
|
||||
** from the nav), each with the standard review and result screens; three
|
||||
** run on the scheduler.
|
||||
**
|
||||
** <table>
|
||||
** <caption>Processes</caption>
|
||||
** <tr><th>Name</th><th>Where</th><th>Permission</th></tr>
|
||||
** <tr><td>webhooks_send_now</td><td>Deliveries (selected)</td><td>manage</td></tr>
|
||||
** <tr><td>webhooks_replay</td><td>Deliveries (selected)</td><td>manage</td></tr>
|
||||
** <tr><td>webhooks_cancel</td><td>Deliveries (selected)</td><td>manage</td></tr>
|
||||
** <tr><td>webhooks_send_test</td><td>Endpoint (record)</td><td>manage</td></tr>
|
||||
** <tr><td>webhooks_rotate_secret</td><td>Endpoint (record)</td><td>admin</td></tr>
|
||||
** <tr><td>webhooks_delete_deliveries</td><td>Deliveries (selected, finished)</td><td>admin</td></tr>
|
||||
** <tr><td>webhooks_deliver</td><td>scheduler</td><td>manage</td></tr>
|
||||
** <tr><td>webhooks_maintain</td><td>scheduler (health, secrets)</td><td>admin</td></tr>
|
||||
** <tr><td>webhooks_retention</td><td>scheduler (daily purge)</td><td>admin</td></tr>
|
||||
** </table>
|
||||
*******************************************************************************/
|
||||
public final class WebhooksProcesses
|
||||
{
|
||||
public static final String SEND_NOW = "webhooks_send_now";
|
||||
public static final String REPLAY = "webhooks_replay";
|
||||
public static final String CANCEL = "webhooks_cancel";
|
||||
public static final String SEND_TEST = "webhooks_send_test";
|
||||
public static final String ROTATE_SECRET = "webhooks_rotate_secret";
|
||||
public static final String DELETE_DELIVERIES = "webhooks_delete_deliveries";
|
||||
public static final String DELIVER = "webhooks_deliver";
|
||||
public static final String MAINTAIN = "webhooks_maintain";
|
||||
public static final String RETENTION = "webhooks_retention";
|
||||
|
||||
/** The custom event type a test send uses. */
|
||||
public static final String TEST_EVENT_TYPE = "webhooks.test";
|
||||
|
||||
private static final Map<String, String> PERMISSIONS = Map.of(
|
||||
SEND_NOW, WebhooksPermissions.MANAGE,
|
||||
REPLAY, WebhooksPermissions.MANAGE,
|
||||
CANCEL, WebhooksPermissions.MANAGE,
|
||||
SEND_TEST, WebhooksPermissions.MANAGE,
|
||||
ROTATE_SECRET, WebhooksPermissions.ADMIN,
|
||||
DELETE_DELIVERIES, WebhooksPermissions.ADMIN,
|
||||
DELIVER, WebhooksPermissions.MANAGE,
|
||||
MAINTAIN, WebhooksPermissions.ADMIN,
|
||||
RETENTION, WebhooksPermissions.ADMIN);
|
||||
|
||||
private static final List<String> FINAL_STATUSES = List.of(DeliveryStatus.DELIVERED.name(), DeliveryStatus.DEAD_LETTER.name(), DeliveryStatus.CANCELLED.name());
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private WebhooksProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission a process needs.
|
||||
*******************************************************************************/
|
||||
public static String permissionFor(String processName)
|
||||
{
|
||||
return (PERMISSIONS.getOrDefault(processName, WebhooksPermissions.ADMIN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All processes.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes(WebhooksQBitConfig config)
|
||||
{
|
||||
List<QProcessMetaData> list = new ArrayList<>();
|
||||
list.add(etl(SEND_NOW, "Send Now", "send", WebhooksTables.DELIVERY, SendNowTransform.class, SendNowLoad.class,
|
||||
"These deliveries will be sent now (an attempt is made even if the endpoint is unhealthy)."));
|
||||
list.add(etl(REPLAY, "Replay", "replay", WebhooksTables.DELIVERY, ReplayTransform.class, ReplayLoad.class,
|
||||
"These deliveries will be queued again with the same event id and a fresh set of attempts."));
|
||||
list.add(etl(CANCEL, "Cancel Delivery", "cancel", WebhooksTables.DELIVERY, CancelTransform.class, CancelLoad.class,
|
||||
"These deliveries will not be sent."));
|
||||
list.add(etl(DELETE_DELIVERIES, "Delete Deliveries", "delete", WebhooksTables.DELIVERY, DeleteTransform.class, DeleteLoad.class,
|
||||
"These finished deliveries and their attempts will be deleted."));
|
||||
list.add(etl(SEND_TEST, "Send Test Event", "science", WebhooksTables.ENDPOINT, SendTestTransform.class, SendTestLoad.class,
|
||||
"A signed test event (type " + TEST_EVENT_TYPE + ") will be sent to this endpoint now."));
|
||||
list.add(etl(ROTATE_SECRET, "Rotate Signing Secret", "key", WebhooksTables.ENDPOINT, RotateTransform.class, RotateLoad.class,
|
||||
"A new signing secret will be created. The current one keeps signing for " + config.getRotationOverlapHours() + " hours."));
|
||||
|
||||
list.add(scheduled(config, DELIVER, "Deliver Webhooks", DeliverStep.class, config.getDeliveryIntervalSeconds()));
|
||||
list.add(scheduled(config, MAINTAIN, "Maintain Webhook Endpoints", MaintainStep.class, 300));
|
||||
list.add(scheduled(config, RETENTION, "Purge Old Webhook Deliveries", RetentionStep.class, 86400));
|
||||
return (list);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A streamed ETL table or record action.
|
||||
*******************************************************************************/
|
||||
static QProcessMetaData etl(String name, String label, String icon, String table, Class<? extends AbstractTransformStep> transform,
|
||||
Class<? extends AbstractLoadStep> load, String preview)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview);
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, transform, load, values);
|
||||
process.setName(name);
|
||||
process.setLabel(label);
|
||||
process.setIcon(new QIcon(icon));
|
||||
process.setTableName(table);
|
||||
process.setPermissionRules(WebhooksTables.permissionRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A hidden backend process on the scheduler (when one is configured).
|
||||
*******************************************************************************/
|
||||
static QProcessMetaData scheduled(WebhooksQBitConfig config, String name, String label, Class<? extends BackendStep> step, int seconds)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData()
|
||||
.withName(name)
|
||||
.withLabel(label)
|
||||
.withIsHidden(true)
|
||||
.withPermissionRules(WebhooksTables.permissionRules())
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(step)));
|
||||
if(config.getSchedulerName() != null)
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withInitialDelaySeconds(Math.min(seconds, 30)));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A summary line with the four tenses.
|
||||
*******************************************************************************/
|
||||
static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(singularFuture);
|
||||
line.setPluralFutureMessage(pluralFuture);
|
||||
line.setSingularPastMessage(singularPast);
|
||||
line.setPluralPastMessage(pluralPast);
|
||||
return (line);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A one-off info line.
|
||||
*******************************************************************************/
|
||||
static ProcessSummaryLine info(Status status, String message)
|
||||
{
|
||||
return (new ProcessSummaryLine(status, null, message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Base transform: lines for "will happen" and "skipped, with reason".
|
||||
*******************************************************************************/
|
||||
abstract static class FilteringTransform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
protected final ProcessSummaryLine ok;
|
||||
protected final Map<String, ProcessSummaryLine> skipped = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
FilteringTransform(String singularFuture, String pluralFuture, String singularPast, String pluralPast)
|
||||
{
|
||||
ok = line(Status.OK, singularFuture, pluralFuture, singularPast, pluralPast);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Why a record is skipped, or null to keep it.
|
||||
*******************************************************************************/
|
||||
abstract String skipReason(QRecord record) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sort the page.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String reason = skipReason(record);
|
||||
if(reason == null)
|
||||
{
|
||||
ok.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped.computeIfAbsent(reason, r -> line(Status.INFO, "record " + r + " (no change)", "records " + r + " (no change)",
|
||||
"record " + r + " (no change)", "records " + r + " (no change)")).incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
ok.addSelfToListIfAnyCount(lines);
|
||||
skipped.values().forEach(l -> l.addSelfToListIfAnyCount(lines));
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send Now: unfinished deliveries.
|
||||
*******************************************************************************/
|
||||
public static class SendNowTransform extends FilteringTransform
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public SendNowTransform()
|
||||
{
|
||||
super("delivery will be sent now", "deliveries will be sent now", "delivery was attempted", "deliveries were attempted");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Skip finished ones.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String skipReason(QRecord record)
|
||||
{
|
||||
String status = record.getValueString("status");
|
||||
if(DeliveryStatus.DELIVERED.name().equals(status))
|
||||
{
|
||||
return ("was already delivered (use Replay to send it again)");
|
||||
}
|
||||
if(DeliveryStatus.DEAD_LETTER.name().equals(status))
|
||||
{
|
||||
return ("is a dead letter (use Replay)");
|
||||
}
|
||||
if(DeliveryStatus.CANCELLED.name().equals(status))
|
||||
{
|
||||
return ("is cancelled");
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send Now: claim and send each, recording results.
|
||||
*******************************************************************************/
|
||||
public static class SendNowLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine delivered = line(Status.OK, "", "", "delivery was delivered", "deliveries were delivered");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "", "", "delivery failed (see its attempts; it will be retried)",
|
||||
"deliveries failed (see their attempts; they will be retried)");
|
||||
private final ProcessSummaryLine busy = line(Status.INFO, "", "", "delivery was being sent by another runner",
|
||||
"deliveries were being sent by another runner");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
DeliveryEngine.SendResult result = new DeliveryEngine().sendNow(record.getValueInteger("id"));
|
||||
switch(result)
|
||||
{
|
||||
case DELIVERED -> delivered.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
case CLAIMED_ELSEWHERE, NOT_SENT -> busy.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
default -> failed.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
}
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
delivered.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
busy.addSelfToListIfAnyCount(lines);
|
||||
if(getTransformStep() instanceof FilteringTransform transform)
|
||||
{
|
||||
transform.skipped.values().forEach(l -> l.addSelfToListIfAnyCount(lines));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replay: finished deliveries.
|
||||
*******************************************************************************/
|
||||
public static class ReplayTransform extends FilteringTransform
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public ReplayTransform()
|
||||
{
|
||||
super("delivery will be queued again", "deliveries will be queued again", "delivery was queued again", "deliveries were queued again");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Skip unfinished ones.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String skipReason(QRecord record)
|
||||
{
|
||||
return (FINAL_STATUSES.contains(record.getValueString("status")) ? null : "is still being delivered");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replay: back to PENDING with a fresh attempt budget.
|
||||
*******************************************************************************/
|
||||
public static class ReplayLoad extends AbstractLoadStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Queue again.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
Instant now = WebhooksRuntime.clock().instant();
|
||||
List<QRecord> updates = new ArrayList<>();
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
Integer replays = record.getValueInteger("replayCount");
|
||||
updates.add(new QRecord().withValue("id", record.getValue("id"))
|
||||
.withValue("status", DeliveryStatus.PENDING.name())
|
||||
.withValue("cycleAttemptCount", 0)
|
||||
.withValue("replayCount", (replays == null ? 0 : replays) + 1)
|
||||
.withValue("nextAttemptDate", now)
|
||||
.withValue("deadLetterDate", null)
|
||||
.withValue("deliveredDate", null));
|
||||
}
|
||||
output.getRecords().addAll(SystemWrites.update(WebhooksTables.DELIVERY, updates, null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel: unfinished, not in flight.
|
||||
*******************************************************************************/
|
||||
public static class CancelTransform extends FilteringTransform
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public CancelTransform()
|
||||
{
|
||||
super("delivery will be cancelled", "deliveries will be cancelled", "delivery was cancelled", "deliveries were cancelled");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only pending or waiting.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String skipReason(QRecord record)
|
||||
{
|
||||
String status = record.getValueString("status");
|
||||
if(DeliveryStatus.SENDING.name().equals(status))
|
||||
{
|
||||
return ("is being sent right now");
|
||||
}
|
||||
return (FINAL_STATUSES.contains(status) ? "is already finished" : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancel.
|
||||
*******************************************************************************/
|
||||
public static class CancelLoad extends AbstractLoadStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Mark cancelled.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<QRecord> updates = new ArrayList<>();
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
updates.add(new QRecord().withValue("id", record.getValue("id")).withValue("status", DeliveryStatus.CANCELLED.name()).withValue("nextAttemptDate", null));
|
||||
}
|
||||
output.getRecords().addAll(SystemWrites.update(WebhooksTables.DELIVERY, updates, null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete: finished deliveries only.
|
||||
*******************************************************************************/
|
||||
public static class DeleteTransform extends FilteringTransform
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public DeleteTransform()
|
||||
{
|
||||
super("delivery will be deleted", "deliveries will be deleted", "delivery was deleted", "deliveries were deleted");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only finished.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String skipReason(QRecord record)
|
||||
{
|
||||
return (FINAL_STATUSES.contains(record.getValueString("status")) ? null : "is not finished (cancel it first)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete deliveries and their attempts.
|
||||
*******************************************************************************/
|
||||
public static class DeleteLoad extends AbstractLoadStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Delete.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<Serializable> ids = input.getRecords().stream().map(r -> r.getValue("id")).toList();
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
Retention.deleteDeliveries(new ArrayList<>(ids));
|
||||
}
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send Test: an endpoint that is not disabled.
|
||||
*******************************************************************************/
|
||||
public static class SendTestTransform extends FilteringTransform
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public SendTestTransform()
|
||||
{
|
||||
super("endpoint will get a test event", "endpoints will get a test event", "endpoint was sent a test event", "endpoints were sent a test event");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Skip disabled.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String skipReason(QRecord record)
|
||||
{
|
||||
return (ActiveStatus.DISABLED.name().equals(record.getValueString("activeStatus")) ? "is disabled" : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send Test: queue a test delivery and send it now.
|
||||
*******************************************************************************/
|
||||
public static class SendTestLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final List<ProcessSummaryLineInterface> results = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord endpoint : input.getRecords())
|
||||
{
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("message", "This is a test event from QQQ webhooks.");
|
||||
data.put("endpoint", endpoint.getValueString("name"));
|
||||
WebhookEvent event = new WebhookEvent()
|
||||
.withEventTypeName(TEST_EVENT_TYPE)
|
||||
.withSource(EventSource.TEST)
|
||||
.withSourceUri("qqq://" + WebhooksRuntime.config().getSourceName() + "/test")
|
||||
.withOccurredAt(WebhooksRuntime.clock().instant())
|
||||
.withData(data);
|
||||
QRecord delivery = DeliveryOutbox.enqueueDirect(event, endpoint.getValueInteger("id"), null);
|
||||
DeliveryEngine.SendResult result = new DeliveryEngine().sendNow(delivery.getValueInteger("id"));
|
||||
QRecord after = SystemWrites.query(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, delivery.getValue("id")))).get(0);
|
||||
String name = endpoint.getValueString("name");
|
||||
if(result == DeliveryEngine.SendResult.DELIVERED)
|
||||
{
|
||||
results.add(info(Status.OK, name + ": delivered (HTTP " + after.getValue("lastHttpStatus") + ")."));
|
||||
}
|
||||
else
|
||||
{
|
||||
results.add(info(Status.ERROR, name + ": not delivered. " + (after.getValueString("lastError") == null ? "" : after.getValueString("lastError"))
|
||||
+ " (delivery " + after.getValue("id") + " has the details)"));
|
||||
}
|
||||
output.getRecords().add(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(results));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rotate: every selected endpoint (admin only).
|
||||
*******************************************************************************/
|
||||
public static class RotateTransform extends FilteringTransform
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public RotateTransform()
|
||||
{
|
||||
super("endpoint will get a new signing secret", "endpoints will get a new signing secret", "endpoint got a new signing secret",
|
||||
"endpoints got a new signing secret");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refuse when secrets cannot be generated.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
String skipReason(QRecord record) throws QException
|
||||
{
|
||||
return (EndpointSecrets.canGenerate() ? null : "cannot get a generated secret: the encryption key is not set (add an environment reference instead)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rotate. The secret itself is never put in the result (result screens can
|
||||
** be stored by process tracing): it is shown on the new secret's record to
|
||||
** webhook admins.
|
||||
*******************************************************************************/
|
||||
public static class RotateLoad extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine rotated = line(Status.OK, "", "", "endpoint has a new signing secret: open its Signing Secrets to copy the value",
|
||||
"endpoints have new signing secrets: open their Signing Secrets to copy the values");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rotate.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord endpoint : input.getRecords())
|
||||
{
|
||||
EndpointSecrets.rotate(endpoint.getValueInteger("id"), "Rotated");
|
||||
rotated.incrementCountAndAddPrimaryKey(endpoint.getValue("id"));
|
||||
output.getRecords().add(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
rotated.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled: deliver.
|
||||
*******************************************************************************/
|
||||
public static class DeliverStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Run.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
DeliveryEngine.RunSummary summary = new DeliveryEngine().runOnce();
|
||||
output.addValue("delivered", summary.getDelivered());
|
||||
output.addValue("failed", summary.getFailed());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled: probation and secret retirement.
|
||||
*******************************************************************************/
|
||||
public static class MaintainStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Run.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.addValue("probation", EndpointHealth.promoteToProbation(WebhooksRuntime.config()).size());
|
||||
output.addValue("retiredSecrets", EndpointSecrets.retireExpired());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled: retention.
|
||||
*******************************************************************************/
|
||||
public static class RetentionStep implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Run.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.addValue("deleted", Retention.purge(WebhooksRuntime.config()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention purge.
|
||||
*******************************************************************************/
|
||||
public static final class Retention
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private Retention()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete finished deliveries (and their attempts) past their retention,
|
||||
** and attempts past theirs. 0 days means keep forever. Returns the
|
||||
** number of deliveries deleted.
|
||||
*******************************************************************************/
|
||||
public static int purge(WebhooksQBitConfig config) throws QException
|
||||
{
|
||||
Instant now = WebhooksRuntime.clock().instant();
|
||||
int deleted = 0;
|
||||
deleted += purgeStatus(List.of(DeliveryStatus.DELIVERED.name(), DeliveryStatus.CANCELLED.name()), config.getDeliveredRetentionDays(), now);
|
||||
deleted += purgeStatus(List.of(DeliveryStatus.DEAD_LETTER.name()), config.getDeadLetterRetentionDays(), now);
|
||||
Integer attemptDays = config.getAttemptRetentionDays();
|
||||
if(attemptDays != null && attemptDays > 0)
|
||||
{
|
||||
SystemWrites.delete(WebhooksTables.ATTEMPT, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("startDate", QCriteriaOperator.LESS_THAN, now.minus(Duration.ofDays(attemptDays))))
|
||||
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.NOT_EQUALS, "IN_FLIGHT")));
|
||||
}
|
||||
return (deleted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete deliveries in some statuses older than a number of days.
|
||||
*******************************************************************************/
|
||||
private static int purgeStatus(List<String> statuses, Integer days, Instant now) throws QException
|
||||
{
|
||||
if(days == null || days <= 0)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
List<QRecord> old = SystemWrites.query(WebhooksTables.DELIVERY, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, new ArrayList<>(statuses)))
|
||||
.withCriteria(new QFilterCriteria("modifyDate", QCriteriaOperator.LESS_THAN, now.minus(Duration.ofDays(days))))
|
||||
.withLimit(5000));
|
||||
List<Serializable> ids = old.stream().map(r -> r.getValue("id")).toList();
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
deleteDeliveries(new ArrayList<>(ids));
|
||||
return (ids.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete deliveries by id, with their attempts.
|
||||
*******************************************************************************/
|
||||
public static void deleteDeliveries(List<Serializable> ids) throws QException
|
||||
{
|
||||
SystemWrites.delete(WebhooksTables.ATTEMPT, new QQueryFilter(new QFilterCriteria("deliveryId", QCriteriaOperator.IN, ids)));
|
||||
SystemWrites.delete(WebhooksTables.DELIVERY, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, ids)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.registry;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What makes an event of a type happen.
|
||||
**
|
||||
** <ul>
|
||||
** <li>TABLE_INSERT, TABLE_UPDATE, TABLE_DELETE: a record change in a table
|
||||
** (in-process, or from the ESB for remote tables).</li>
|
||||
** <li>TABLE_STORE: an insert or an update.</li>
|
||||
** <li>PROCESS_COMPLETED, PROCESS_FAILED: a process run ended.</li>
|
||||
** <li>CUSTOM: fired from code (a domain event such as "post published",
|
||||
** or a channel delivery from another qbit).</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public enum WebhookEventKind
|
||||
{
|
||||
TABLE_INSERT(true, "inserted"),
|
||||
TABLE_UPDATE(true, "updated"),
|
||||
TABLE_DELETE(true, "deleted"),
|
||||
TABLE_STORE(true, "stored"),
|
||||
PROCESS_COMPLETED(false, "completed"),
|
||||
PROCESS_FAILED(false, "failed"),
|
||||
CUSTOM(false, null);
|
||||
|
||||
private final boolean table;
|
||||
private final String suffix;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
WebhookEventKind(boolean table, String suffix)
|
||||
{
|
||||
this.table = table;
|
||||
this.suffix = suffix;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for the table kinds.
|
||||
*******************************************************************************/
|
||||
public boolean isTable()
|
||||
{
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for the process kinds.
|
||||
*******************************************************************************/
|
||||
public boolean isProcess()
|
||||
{
|
||||
return (this == PROCESS_COMPLETED || this == PROCESS_FAILED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The name suffix of auto-registered types ("inserted", ...).
|
||||
*******************************************************************************/
|
||||
public String getSuffix()
|
||||
{
|
||||
return (suffix);
|
||||
}
|
||||
}
|
||||
@@ -5,65 +5,57 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.registry;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventCategory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A specific event type for which the application can send webhook events.
|
||||
** A kind of event that endpoints can subscribe to, such as
|
||||
** {@code cms_blog_post.updated} or {@code post.published}.
|
||||
**
|
||||
** <p>Table and process types are registered automatically from the qbit
|
||||
** config ({@code tableEventSources}, {@code processEventSources},
|
||||
** {@code esbTableEventSources}). Custom types are registered in code with
|
||||
** {@link WebhooksRegistry#register(WebhookEventType)}.</p>
|
||||
**
|
||||
** <p>Optional {@code fieldName} narrows a table type: an update fires only
|
||||
** when that field changed, and with {@code value} only when it changed to that
|
||||
** value (a collection means any of them). The optional {@code customizer}
|
||||
** shapes the payload in code ({@link WebhookPayloadCustomizerInterface}).</p>
|
||||
*******************************************************************************/
|
||||
public class WebhookEventType implements Serializable
|
||||
{
|
||||
private String name;
|
||||
private String label;
|
||||
private WebhookEventCategory category;
|
||||
private String tableName;
|
||||
private String fieldName;
|
||||
private Serializable value;
|
||||
private QCodeReference customizer;
|
||||
private String name;
|
||||
private String label;
|
||||
private String description;
|
||||
private WebhookEventKind kind;
|
||||
private String tableName;
|
||||
private String processName;
|
||||
private String fieldName;
|
||||
private Serializable value;
|
||||
private Boolean remote = false;
|
||||
private QCodeReference customizer;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
** Fluent setter for name.
|
||||
*******************************************************************************/
|
||||
public WebhookEventType withName(String name)
|
||||
{
|
||||
@@ -74,58 +66,40 @@ public class WebhookEventType implements Serializable
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for category
|
||||
** Fluent setter for label.
|
||||
*******************************************************************************/
|
||||
public WebhookEventCategory getCategory()
|
||||
public WebhookEventType withLabel(String label)
|
||||
{
|
||||
return (this.category);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for category
|
||||
*******************************************************************************/
|
||||
public void setCategory(WebhookEventCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for category
|
||||
*******************************************************************************/
|
||||
public WebhookEventType withCategory(WebhookEventCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName
|
||||
** Fluent setter for description (shown to people choosing a type).
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
public WebhookEventType withDescription(String description)
|
||||
{
|
||||
return (this.tableName);
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tableName
|
||||
** Fluent setter for kind.
|
||||
*******************************************************************************/
|
||||
public void setTableName(String tableName)
|
||||
public WebhookEventType withKind(WebhookEventKind kind)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
this.kind = kind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public WebhookEventType withTableName(String tableName)
|
||||
{
|
||||
@@ -136,27 +110,18 @@ public class WebhookEventType implements Serializable
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldName
|
||||
** Fluent setter for processName.
|
||||
*******************************************************************************/
|
||||
public String getFieldName()
|
||||
public WebhookEventType withProcessName(String processName)
|
||||
{
|
||||
return (this.fieldName);
|
||||
this.processName = processName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for fieldName
|
||||
*******************************************************************************/
|
||||
public void setFieldName(String fieldName)
|
||||
{
|
||||
this.fieldName = fieldName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fieldName
|
||||
** Fluent setter for fieldName.
|
||||
*******************************************************************************/
|
||||
public WebhookEventType withFieldName(String fieldName)
|
||||
{
|
||||
@@ -167,27 +132,7 @@ public class WebhookEventType implements Serializable
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for value
|
||||
*******************************************************************************/
|
||||
public Serializable getValue()
|
||||
{
|
||||
return (this.value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for value
|
||||
*******************************************************************************/
|
||||
public void setValue(Serializable value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for value
|
||||
** Fluent setter for value.
|
||||
*******************************************************************************/
|
||||
public WebhookEventType withValue(Serializable value)
|
||||
{
|
||||
@@ -198,27 +143,19 @@ public class WebhookEventType implements Serializable
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for customizer
|
||||
** Fluent setter for remote (the table is in another service; events come
|
||||
** only from the ESB).
|
||||
*******************************************************************************/
|
||||
public QCodeReference getCustomizer()
|
||||
public WebhookEventType withRemote(Boolean remote)
|
||||
{
|
||||
return (this.customizer);
|
||||
this.remote = remote;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for customizer
|
||||
*******************************************************************************/
|
||||
public void setCustomizer(QCodeReference customizer)
|
||||
{
|
||||
this.customizer = customizer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for customizer
|
||||
** Fluent setter for customizer.
|
||||
*******************************************************************************/
|
||||
public WebhookEventType withCustomizer(QCodeReference customizer)
|
||||
{
|
||||
@@ -227,18 +164,29 @@ public class WebhookEventType implements Serializable
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
** Getter for name.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
public String getName()
|
||||
{
|
||||
return (this.label);
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for label
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for label.
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
@@ -248,13 +196,80 @@ public class WebhookEventType implements Serializable
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label
|
||||
** Getter for description.
|
||||
*******************************************************************************/
|
||||
public WebhookEventType withLabel(String label)
|
||||
public String getDescription()
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
return (description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kind.
|
||||
*******************************************************************************/
|
||||
public WebhookEventKind getKind()
|
||||
{
|
||||
return (kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for processName.
|
||||
*******************************************************************************/
|
||||
public String getProcessName()
|
||||
{
|
||||
return (processName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldName.
|
||||
*******************************************************************************/
|
||||
public String getFieldName()
|
||||
{
|
||||
return (fieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for value.
|
||||
*******************************************************************************/
|
||||
public Serializable getValue()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for remote.
|
||||
*******************************************************************************/
|
||||
public Boolean getRemote()
|
||||
{
|
||||
return (remote);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for customizer.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getCustomizer()
|
||||
{
|
||||
return (customizer);
|
||||
}
|
||||
}
|
||||
+43
-51
@@ -5,18 +5,17 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.registry;
|
||||
@@ -29,8 +28,6 @@ import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvide
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
@@ -39,69 +36,64 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PVS for webhook event types - custom PVS implementation, that uses Webhook
|
||||
** Registry as backend.
|
||||
** The registered event types as a possible-value source (the "Event Type"
|
||||
** picker on subscriptions and deliveries). Labels read
|
||||
** {@code <label> (<name>)}, so people see both.
|
||||
*******************************************************************************/
|
||||
public class WebhookEventTypePossibleValueSource implements QCustomPossibleValueProvider<String>, MetaDataProducerInterface<QPossibleValueSource>
|
||||
public class WebhookEventTypePossibleValueSource implements QCustomPossibleValueProvider<String>
|
||||
{
|
||||
public static final String NAME = "WebhookEventType";
|
||||
public static final String NAME = "webhooks_event_type";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QPossibleValueSource produce(QInstance qInstance) throws QException
|
||||
/*******************************************************************************
|
||||
** The source definition.
|
||||
*******************************************************************************/
|
||||
public static QPossibleValueSource definition()
|
||||
{
|
||||
return new QPossibleValueSource()
|
||||
return (new QPossibleValueSource()
|
||||
.withName(NAME)
|
||||
.withLabel("Webhook Event Type")
|
||||
.withIdType(QFieldType.STRING)
|
||||
.withCustomCodeReference(new QCodeReference(getClass()));
|
||||
.withCustomCodeReference(new QCodeReference(WebhookEventTypePossibleValueSource.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
** One value by id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QPossibleValue<String> getPossibleValue(Serializable id)
|
||||
{
|
||||
WebhookEventType webhookEventType = WebhooksRegistry.ofOrWithNew(QContext.getQInstance()).getWebhookEventType(ValueUtils.getValueAsString(id));
|
||||
if(webhookEventType == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
return getPossibleValue(webhookEventType);
|
||||
WebhookEventType type = WebhooksRegistry.of(QContext.getQInstance()).get(ValueUtils.getValueAsString(id));
|
||||
return (type == null ? null : toValue(type));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
private static QPossibleValue<String> getPossibleValue(WebhookEventType webhookEventType)
|
||||
{
|
||||
return new QPossibleValue<>(webhookEventType.getName(), webhookEventType.getLabel());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
** Search.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
|
||||
{
|
||||
List<QPossibleValue<String>> allPossibleValues = new ArrayList<>();
|
||||
for(WebhookEventType webhookEventType : WebhooksRegistry.ofOrWithNew(QContext.getQInstance()).getAllWebhookEventTypes())
|
||||
List<QPossibleValue<String>> values = new ArrayList<>();
|
||||
for(WebhookEventType type : WebhooksRegistry.of(QContext.getQInstance()).getAll())
|
||||
{
|
||||
allPossibleValues.add(getPossibleValue(webhookEventType));
|
||||
values.add(toValue(type));
|
||||
}
|
||||
|
||||
return completeCustomPVSSearch(input, allPossibleValues);
|
||||
return (completeCustomPVSSearch(input, values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Value for a type.
|
||||
*******************************************************************************/
|
||||
private static QPossibleValue<String> toValue(WebhookEventType type)
|
||||
{
|
||||
String label = type.getLabel() == null ? type.getName() : type.getLabel() + " (" + type.getName() + ")";
|
||||
return (new QPossibleValue<>(type.getName(), label));
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.registry;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.events.WebhookEvent;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Code hook that shapes the {@code data} object of an event type's payload,
|
||||
** after the subscription's settings (detail level, include and exclude
|
||||
** fields) were applied. Set it with {@link WebhookEventType#withCustomizer}.
|
||||
**
|
||||
** <p>It runs when the event is queued (the payload is stored, so a retry sends
|
||||
** exactly the same bytes), once per subscription.</p>
|
||||
*******************************************************************************/
|
||||
public interface WebhookPayloadCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Change {@code data} in place (add, remove or rename keys).
|
||||
**
|
||||
** @param event the event being queued
|
||||
** @param subscription the subscription it is queued for (a
|
||||
** webhooks_subscription record)
|
||||
** @param data the payload's data object
|
||||
*******************************************************************************/
|
||||
void customizeData(WebhookEvent event, QRecord subscription, Map<String, Object> data) throws QException;
|
||||
}
|
||||
@@ -5,18 +5,17 @@
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.registry;
|
||||
@@ -26,34 +25,29 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webhooks.actions.WebhookEventTypeCustomizerInterface;
|
||||
import com.kingsrook.qbits.webhooks.model.WebhookEventCategory;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** class that manages webhook types (meta-data like)
|
||||
** The event types endpoints can subscribe to, stored on the QInstance (so two
|
||||
** instances in one JVM, as in tests, never share it).
|
||||
*******************************************************************************/
|
||||
public class WebhooksRegistry implements QSupplementalInstanceMetaData
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(WebhooksRegistry.class);
|
||||
|
||||
private static String NAME = "com.kingsrook.qbits.webhooks.registry.WebhooksRegistry";
|
||||
public static final String NAME = "com.kingsrook.qbits.webhooks.registry.WebhooksRegistry";
|
||||
|
||||
private final Map<String, WebhookEventType> eventTypes = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Singleton constructor
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private WebhooksRegistry()
|
||||
{
|
||||
@@ -62,145 +56,221 @@ public class WebhooksRegistry implements QSupplementalInstanceMetaData
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** The registry of an instance (created when missing).
|
||||
*******************************************************************************/
|
||||
public static WebhooksRegistry of(QInstance qInstance)
|
||||
{
|
||||
return QSupplementalInstanceMetaData.of(qInstance, NAME);
|
||||
return (QSupplementalInstanceMetaData.ofOrWithNew(qInstance, NAME, WebhooksRegistry::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Register the types implied by the config: table, ESB-table and process
|
||||
** event types.
|
||||
*******************************************************************************/
|
||||
public static WebhooksRegistry ofOrWithNew(QInstance qInstance)
|
||||
public void registerFromConfig(QInstance qInstance, WebhooksQBitConfig config)
|
||||
{
|
||||
return QSupplementalInstanceMetaData.ofOrWithNew(qInstance, NAME, WebhooksRegistry::new);
|
||||
for(String table : config.getTableEventSources())
|
||||
{
|
||||
registerTable(qInstance, table, false);
|
||||
}
|
||||
for(String table : config.getEsbTableEventSources())
|
||||
{
|
||||
registerTable(qInstance, table, true);
|
||||
}
|
||||
for(String process : config.getProcessEventSources())
|
||||
{
|
||||
register(new WebhookEventType().withName(processTypeName(process, WebhookEventKind.PROCESS_COMPLETED)).withKind(WebhookEventKind.PROCESS_COMPLETED)
|
||||
.withProcessName(process).withLabel(processLabel(qInstance, process) + " completed"));
|
||||
register(new WebhookEventType().withName(processTypeName(process, WebhookEventKind.PROCESS_FAILED)).withKind(WebhookEventKind.PROCESS_FAILED)
|
||||
.withProcessName(process).withLabel(processLabel(qInstance, process) + " failed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public void clear()
|
||||
/*******************************************************************************
|
||||
** Inserted, updated and deleted types for one table.
|
||||
*******************************************************************************/
|
||||
private void registerTable(QInstance qInstance, String table, boolean remote)
|
||||
{
|
||||
eventTypes.clear();
|
||||
QTableMetaData tableMetaData = qInstance.getTable(table);
|
||||
String label = tableMetaData != null && tableMetaData.getLabel() != null ? tableMetaData.getLabel() : QInstanceEnricher.nameToLabel(table);
|
||||
for(WebhookEventKind kind : List.of(WebhookEventKind.TABLE_INSERT, WebhookEventKind.TABLE_UPDATE, WebhookEventKind.TABLE_DELETE))
|
||||
{
|
||||
String name = tableTypeName(table, kind);
|
||||
if(!eventTypes.containsKey(name))
|
||||
{
|
||||
register(new WebhookEventType().withName(name).withKind(kind).withTableName(table).withRemote(remote).withLabel(label + " " + kind.getSuffix()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public WebhookEventType getWebhookEventType(String name)
|
||||
/*******************************************************************************
|
||||
** {@code <table>.inserted|updated|deleted|stored}.
|
||||
*******************************************************************************/
|
||||
public static String tableTypeName(String table, WebhookEventKind kind)
|
||||
{
|
||||
return (eventTypes.get(name));
|
||||
return (table + "." + kind.getSuffix());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public List<WebhookEventType> getAllWebhookEventTypes()
|
||||
/*******************************************************************************
|
||||
** {@code process.<name>.completed|failed}.
|
||||
*******************************************************************************/
|
||||
public static String processTypeName(String process, WebhookEventKind kind)
|
||||
{
|
||||
return ("process." + process + "." + kind.getSuffix());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Label of a process, or its name.
|
||||
*******************************************************************************/
|
||||
private static String processLabel(QInstance qInstance, String process)
|
||||
{
|
||||
return (qInstance.getProcess(process) != null && qInstance.getProcess(process).getLabel() != null
|
||||
? qInstance.getProcess(process).getLabel() : QInstanceEnricher.nameToLabel(process));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add (or replace) a type.
|
||||
*******************************************************************************/
|
||||
public WebhooksRegistry register(WebhookEventType eventType)
|
||||
{
|
||||
eventTypes.put(eventType.getName(), eventType);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A type by name, or null.
|
||||
*******************************************************************************/
|
||||
public WebhookEventType get(String name)
|
||||
{
|
||||
return (name == null ? null : eventTypes.get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All types, in registration order.
|
||||
*******************************************************************************/
|
||||
public List<WebhookEventType> getAll()
|
||||
{
|
||||
return (new ArrayList<>(eventTypes.values()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public void registerWebhookEventType(WebhookEventType eventType)
|
||||
/*******************************************************************************
|
||||
** The table types (of the given kinds) for a table.
|
||||
*******************************************************************************/
|
||||
public List<WebhookEventType> forTable(String tableName, List<WebhookEventKind> kinds)
|
||||
{
|
||||
if(eventTypes.containsKey(eventType.getName()))
|
||||
List<WebhookEventType> list = new ArrayList<>();
|
||||
for(WebhookEventType type : eventTypes.values())
|
||||
{
|
||||
LOG.info("Replacing existing webhook event type", logPair("name", eventType.getName()));
|
||||
if(type.getKind() != null && kinds.contains(type.getKind()) && tableName.equals(type.getTableName()))
|
||||
{
|
||||
list.add(type);
|
||||
}
|
||||
}
|
||||
|
||||
eventTypes.put(eventType.getName(), eventType);
|
||||
return (list);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, QInstanceValidator validator)
|
||||
/*******************************************************************************
|
||||
** The process types (of a kind) for a process.
|
||||
*******************************************************************************/
|
||||
public List<WebhookEventType> forProcess(String processName, WebhookEventKind kind)
|
||||
{
|
||||
for(WebhookEventType eventType : CollectionUtils.nonNullList(WebhooksRegistry.ofOrWithNew(qInstance).getAllWebhookEventTypes()))
|
||||
List<WebhookEventType> list = new ArrayList<>();
|
||||
for(WebhookEventType type : eventTypes.values())
|
||||
{
|
||||
String prefix = "Webhook Event Type: " + eventType.getName() + " ";
|
||||
|
||||
validator.assertCondition(StringUtils.hasContent(eventType.getName()), prefix + "is missing a name'");
|
||||
|
||||
WebhookEventCategory category = eventType.getCategory();
|
||||
if(validator.assertCondition(category != null, prefix + "is missing a category"))
|
||||
if(kind.equals(type.getKind()) && processName.equals(type.getProcessName()))
|
||||
{
|
||||
if(category.getRequiresTable())
|
||||
{
|
||||
validator.assertCondition(StringUtils.hasContent(eventType.getTableName()), prefix + "requires a tableName, but does not have one");
|
||||
}
|
||||
|
||||
if(category.getRequiresField())
|
||||
{
|
||||
validator.assertCondition(StringUtils.hasContent(eventType.getFieldName()), prefix + "requires a fieldName, but does not have one");
|
||||
}
|
||||
|
||||
if(category.getRequiresValue())
|
||||
{
|
||||
validator.assertCondition(eventType.getValue() != null, prefix + "requires a value, but does not have one");
|
||||
}
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(eventType.getTableName()))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(eventType.getTableName());
|
||||
validator.assertCondition(table != null, prefix + "references a non-existing tableName: " + eventType.getTableName());
|
||||
|
||||
if(table != null && StringUtils.hasContent(eventType.getFieldName()))
|
||||
{
|
||||
validator.assertCondition(table.getFields().containsKey(eventType.getFieldName()), prefix + "references a non-existing fieldName: " + eventType.getFieldName() + " for table " + eventType.getTableName());
|
||||
}
|
||||
}
|
||||
|
||||
if(eventType.getCustomizer() != null)
|
||||
{
|
||||
validator.validateSimpleCodeReference(prefix + "customizer codeReference: ", eventType.getCustomizer(), WebhookEventTypeCustomizerInterface.class);
|
||||
list.add(type);
|
||||
}
|
||||
}
|
||||
return (list);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
** Fill in labels.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance qInstance)
|
||||
{
|
||||
for(WebhookEventType webhookEventType : CollectionUtils.nonNullList(WebhooksRegistry.ofOrWithNew(qInstance).getAllWebhookEventTypes()))
|
||||
for(WebhookEventType type : eventTypes.values())
|
||||
{
|
||||
if(!StringUtils.hasContent(webhookEventType.getLabel()))
|
||||
if(!StringUtils.hasContent(type.getLabel()))
|
||||
{
|
||||
webhookEventType.setLabel(QInstanceEnricher.nameToLabel(webhookEventType.getName()));
|
||||
type.setLabel(QInstanceEnricher.nameToLabel(type.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
/*******************************************************************************
|
||||
** Validate every type.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, QInstanceValidator validator)
|
||||
{
|
||||
for(WebhookEventType type : eventTypes.values())
|
||||
{
|
||||
String prefix = "Webhook event type " + type.getName() + ": ";
|
||||
validator.assertCondition(StringUtils.hasContent(type.getName()) && type.getName().matches("[A-Za-z0-9_.:-]+"),
|
||||
prefix + "needs a name of letters, digits and . _ : -");
|
||||
if(!validator.assertCondition(type.getKind() != null, prefix + "needs a kind"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(type.getKind().isTable())
|
||||
{
|
||||
validator.assertCondition(StringUtils.hasContent(type.getTableName()), prefix + "a table type needs a tableName");
|
||||
if(StringUtils.hasContent(type.getTableName()) && !Boolean.TRUE.equals(type.getRemote()))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(type.getTableName());
|
||||
if(validator.assertCondition(table != null, prefix + "unknown table " + type.getTableName() + " (mark remote types withRemote(true))")
|
||||
&& StringUtils.hasContent(type.getFieldName()))
|
||||
{
|
||||
validator.assertCondition(table.getFields().containsKey(type.getFieldName()), prefix + "unknown field " + type.getFieldName());
|
||||
}
|
||||
}
|
||||
}
|
||||
if(type.getKind().isProcess())
|
||||
{
|
||||
validator.assertCondition(StringUtils.hasContent(type.getProcessName()), prefix + "a process type needs a processName");
|
||||
}
|
||||
if(type.getCustomizer() != null)
|
||||
{
|
||||
validator.validateSimpleCodeReference(prefix + "customizer: ", type.getCustomizer(), WebhookPayloadCustomizerInterface.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supplemental metadata name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return NAME;
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.registry;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.instances.enrichment.plugins.QInstanceEnricherPluginInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class WebhooksRegistryEnricherPlugin implements QInstanceEnricherPluginInterface<QInstance>
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance qInstance, QInstance qInstance2)
|
||||
{
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.registry;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.instances.validation.plugins.QInstanceValidatorPluginInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class WebhooksRegistryValidatorPlugin implements QInstanceValidatorPluginInterface<QInstance>
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, QInstance qInstance2, QInstanceValidator validator)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.secrets;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksQBitConfig;
|
||||
import com.kingsrook.qbits.webhooks.WebhooksRuntime;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.SecretSource;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.SecretStatus;
|
||||
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
|
||||
import com.kingsrook.qbits.webhooks.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signing secrets of endpoints ({@code webhooks_endpoint_secret}).
|
||||
**
|
||||
** <p>A secret is either GENERATED (random, {@code whsec_} form, stored
|
||||
** encrypted with {@link SecretCipher}) or a REFERENCE (the name of an
|
||||
** environment variable holding it). A secret value is never stored in plain
|
||||
** text, logged, audited or returned by a query; only its fingerprint is.</p>
|
||||
**
|
||||
** <p>Rotation: the ACTIVE secret becomes PREVIOUS (still signing until
|
||||
** {@code expiresDate}, the config's overlap), and a new ACTIVE secret is
|
||||
** created. Every delivery is signed with every usable secret, so a receiver
|
||||
** can switch secrets at any time within the overlap.</p>
|
||||
*******************************************************************************/
|
||||
public final class EndpointSecrets
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(EndpointSecrets.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A usable secret.
|
||||
*******************************************************************************/
|
||||
public record SigningKey(WebhookSecret secret, String fingerprint)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EndpointSecrets()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The secrets that sign deliveries to an endpoint now: ACTIVE ones and
|
||||
** PREVIOUS ones that have not expired. Secrets that cannot be read (key
|
||||
** missing, variable unset) are skipped with a warning.
|
||||
*******************************************************************************/
|
||||
public static List<SigningKey> signingKeys(Integer endpointId) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
Instant now = WebhooksRuntime.clock().instant();
|
||||
List<QRecord> rows = SystemWrites.query(WebhooksTables.SECRET, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("endpointId", QCriteriaOperator.EQUALS, endpointId))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, SecretStatus.ACTIVE.name(), SecretStatus.PREVIOUS.name()))
|
||||
.withOrderBy(new QFilterOrderBy("id", false)));
|
||||
|
||||
List<SigningKey> keys = new ArrayList<>();
|
||||
SecretCipher cipher = null;
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
Instant expires = ValueUtils.getValueAsInstant(row.getValue("expiresDate"));
|
||||
if(expires != null && !expires.isAfter(now))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
String text;
|
||||
if(SecretSource.REFERENCE.name().equals(row.getValueString("source")))
|
||||
{
|
||||
text = EnvironmentSecrets.lookup(row.getValueString("secretRef"));
|
||||
if(text == null)
|
||||
{
|
||||
LOG.warn("A webhook signing secret reference is not set in the environment", logPair("endpointId", endpointId), logPair("secretId", row.getValue("id")));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if(cipher == null)
|
||||
{
|
||||
cipher = SecretCipher.fromEnvironment(config.getEncryptionKeyEnvName());
|
||||
}
|
||||
if(cipher == null)
|
||||
{
|
||||
LOG.warn("A generated webhook signing secret cannot be used: the encryption key is not set", logPair("endpointId", endpointId),
|
||||
logPair("keyVariable", config.getEncryptionKeyEnvName()));
|
||||
continue;
|
||||
}
|
||||
text = cipher.decrypt(row.getValueString("encryptedSecret"), endpointId);
|
||||
}
|
||||
WebhookSecret secret = WebhookSecret.parse(text);
|
||||
keys.add(new SigningKey(secret, secret.fingerprint()));
|
||||
}
|
||||
catch(QException | IllegalArgumentException e)
|
||||
{
|
||||
LOG.warn("A webhook signing secret could not be read", logPair("endpointId", endpointId), logPair("secretId", row.getValue("id")), logPair("reason", e.getMessage()));
|
||||
}
|
||||
}
|
||||
return (keys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when generated secrets can be created (the encryption key is set).
|
||||
*******************************************************************************/
|
||||
public static boolean canGenerate() throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
return (config != null && SecretCipher.fromEnvironment(config.getEncryptionKeyEnvName()) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rotate: any ACTIVE secret becomes PREVIOUS (signing until the overlap
|
||||
** ends), and a new generated secret becomes ACTIVE.
|
||||
**
|
||||
** @return the new secret's text (whsec_...), to show once
|
||||
*******************************************************************************/
|
||||
public static String rotate(Integer endpointId, String note) throws QException
|
||||
{
|
||||
WebhooksQBitConfig config = WebhooksRuntime.config();
|
||||
SecretCipher cipher = SecretCipher.fromEnvironment(config.getEncryptionKeyEnvName());
|
||||
if(cipher == null)
|
||||
{
|
||||
throw (new QException("Signing secrets cannot be generated: set the " + config.getEncryptionKeyEnvName()
|
||||
+ " environment variable to base64 of 32 random bytes, or add a secret that references an environment variable."));
|
||||
}
|
||||
Instant now = WebhooksRuntime.clock().instant();
|
||||
Instant expires = now.plus(Duration.ofHours(config.getRotationOverlapHours()));
|
||||
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
List<QRecord> active = SystemWrites.query(WebhooksTables.SECRET, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("endpointId", QCriteriaOperator.EQUALS, endpointId))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, SecretStatus.ACTIVE.name())));
|
||||
List<QRecord> demote = new ArrayList<>();
|
||||
for(QRecord row : active)
|
||||
{
|
||||
demote.add(new QRecord().withValue("id", row.getValue("id")).withValue("status", SecretStatus.PREVIOUS.name()).withValue("expiresDate", expires));
|
||||
}
|
||||
SystemWrites.update(WebhooksTables.SECRET, demote, null);
|
||||
|
||||
WebhookSecret secret = WebhookSecret.generate();
|
||||
QRecord inserted = SystemWrites.insertOne(WebhooksTables.SECRET, new QRecord()
|
||||
.withValue("endpointId", endpointId)
|
||||
.withValue("status", SecretStatus.ACTIVE.name())
|
||||
.withValue("source", SecretSource.GENERATED.name())
|
||||
.withValue("encryptedSecret", cipher.encrypt(secret.reveal(), endpointId))
|
||||
.withValue("fingerprint", secret.fingerprint())
|
||||
.withValue("note", note));
|
||||
if(CollectionUtils.nullSafeHasContents(inserted.getErrors()))
|
||||
{
|
||||
throw (new QException("Could not store the new signing secret: " + inserted.getErrors().get(0).getMessage()));
|
||||
}
|
||||
return (secret.reveal());
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retire PREVIOUS secrets whose overlap has ended. Returns how many.
|
||||
*******************************************************************************/
|
||||
public static int retireExpired() throws QException
|
||||
{
|
||||
Instant now = WebhooksRuntime.clock().instant();
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
List<QRecord> expired = SystemWrites.query(WebhooksTables.SECRET, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, SecretStatus.PREVIOUS.name()))
|
||||
.withCriteria(new QFilterCriteria("expiresDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)));
|
||||
List<QRecord> updates = new ArrayList<>();
|
||||
for(QRecord row : expired)
|
||||
{
|
||||
updates.add(new QRecord().withValue("id", row.getValue("id")).withValue("status", SecretStatus.RETIRED.name()).withValue("retiredDate", now));
|
||||
}
|
||||
SystemWrites.update(WebhooksTables.SECRET, updates, null);
|
||||
return (updates.size());
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.secrets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Looks up a secret by name: a JVM system property, then an environment
|
||||
** variable, then QQQ's variable interpreter (which also reads a .env file).
|
||||
** Kubernetes secrets from External Secrets or Infisical arrive as environment
|
||||
** variables, so a reference is just the variable's name.
|
||||
*******************************************************************************/
|
||||
public final class EnvironmentSecrets
|
||||
{
|
||||
/** What a reference may look like: an environment variable name. */
|
||||
public static final String NAME_PATTERN = "[A-Za-z_][A-Za-z0-9_]{0,99}";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EnvironmentSecrets()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The value, or null.
|
||||
*******************************************************************************/
|
||||
public static String lookup(String name)
|
||||
{
|
||||
if(!StringUtils.hasContent(name) || !name.matches(NAME_PATTERN))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String value = System.getProperty(name);
|
||||
if(!StringUtils.hasContent(value))
|
||||
{
|
||||
value = System.getenv(name);
|
||||
}
|
||||
if(!StringUtils.hasContent(value))
|
||||
{
|
||||
String interpreted = new QMetaDataVariableInterpreter().interpret("${env." + name + "}");
|
||||
value = interpreted == null || interpreted.startsWith("${") ? null : interpreted;
|
||||
}
|
||||
return (StringUtils.hasContent(value) ? value : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.secrets;
|
||||
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encrypts generated signing secrets at rest with AES-256-GCM.
|
||||
**
|
||||
** <p>The key is 32 random bytes, base64, in the environment variable (or JVM
|
||||
** system property) the config names ({@code WEBHOOKS_SECRET_KEY} by default).
|
||||
** Stored text is {@code v1:<base64(12-byte nonce || ciphertext+tag)>}; the
|
||||
** endpoint id is bound in as associated data, so a ciphertext copied to
|
||||
** another endpoint does not decrypt. Without the key, generated secrets
|
||||
** cannot be created or used, and only environment-reference secrets work.</p>
|
||||
*******************************************************************************/
|
||||
public final class SecretCipher
|
||||
{
|
||||
private static final String PREFIX = "v1:";
|
||||
private static final int NONCE_LEN = 12;
|
||||
private static final int TAG_BITS = 128;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final byte[] key;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with a raw 32-byte key.
|
||||
*******************************************************************************/
|
||||
public SecretCipher(byte[] key)
|
||||
{
|
||||
if(key == null || key.length != 32)
|
||||
{
|
||||
throw (new IllegalArgumentException("The webhooks secret key must be 32 bytes (base64 of 32 random bytes)"));
|
||||
}
|
||||
this.key = key.clone();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The cipher from the named environment variable or system property, or
|
||||
** null when it is not set.
|
||||
**
|
||||
** @throws QException when it is set but is not base64 of 32 bytes
|
||||
*******************************************************************************/
|
||||
public static SecretCipher fromEnvironment(String name) throws QException
|
||||
{
|
||||
String value = EnvironmentSecrets.lookup(name);
|
||||
if(!StringUtils.hasContent(value))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new SecretCipher(Base64.getDecoder().decode(value.trim())));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QException("The webhooks secret key in " + name + " must be base64 of 32 random bytes"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encrypt.
|
||||
*******************************************************************************/
|
||||
public String encrypt(String plaintext, Integer endpointId) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] nonce = new byte[NONCE_LEN];
|
||||
RANDOM.nextBytes(nonce);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_BITS, nonce));
|
||||
cipher.updateAAD(aad(endpointId));
|
||||
byte[] sealed = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
return (PREFIX + Base64.getEncoder().encodeToString(ByteBuffer.allocate(nonce.length + sealed.length).put(nonce).put(sealed).array()));
|
||||
}
|
||||
catch(GeneralSecurityException e)
|
||||
{
|
||||
throw (new QException("Could not encrypt a webhook signing secret", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decrypt.
|
||||
*******************************************************************************/
|
||||
public String decrypt(String stored, Integer endpointId) throws QException
|
||||
{
|
||||
if(stored == null || !stored.startsWith(PREFIX))
|
||||
{
|
||||
throw (new QException("Unrecognized encrypted secret format"));
|
||||
}
|
||||
try
|
||||
{
|
||||
byte[] all = Base64.getDecoder().decode(stored.substring(PREFIX.length()));
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_BITS, all, 0, NONCE_LEN));
|
||||
cipher.updateAAD(aad(endpointId));
|
||||
return (new String(cipher.doFinal(all, NONCE_LEN, all.length - NONCE_LEN), StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(GeneralSecurityException | IllegalArgumentException e)
|
||||
{
|
||||
throw (new QException("Could not decrypt a webhook signing secret (wrong key, or the row was altered)"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Associated data: the endpoint id.
|
||||
*******************************************************************************/
|
||||
private static byte[] aad(Integer endpointId)
|
||||
{
|
||||
return (("webhooks_endpoint:" + endpointId).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webhooks.shared.http;
|
||||
|
||||
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Which network addresses an outbound request may reach.
|
||||
**
|
||||
** <p>Blocked by default: "this network" (0/8), private (10/8, 172.16/12,
|
||||
** 192.168/16), carrier-grade NAT (100.64/10), loopback (127/8, ::1),
|
||||
** link-local (169.254/16, fe80::/10, which covers cloud metadata), IETF
|
||||
** protocol and documentation ranges, benchmarking (198.18/15), multicast,
|
||||
** reserved and broadcast (224/4, 240/4), IPv6 unique-local (fc00::/7),
|
||||
** site-local (fec0::/10), discard (100::/64) and documentation (2001:db8::/32).
|
||||
** IPv4 addresses embedded in IPv6 (mapped, compatible, NAT64 64:ff9b::/96 and
|
||||
** 6to4 2002::/16) are checked as IPv4.</p>
|
||||
**
|
||||
** <p>An allowlist re-opens specific internal targets: CIDR blocks
|
||||
** ({@code 10.100.22.0/24}), single addresses, exact host names, or host
|
||||
** suffixes written with a leading dot ({@code .svc.cluster.local}). The cloud
|
||||
** metadata addresses (169.254.169.254, 100.100.100.200, fd00:ec2::254) and the
|
||||
** unspecified addresses stay blocked even when allowlisted.</p>
|
||||
**
|
||||
** <p>INTERNAL COPY: replaced by {@code com.kingsrook.qqq.backend.core.utils.http.AddressRules}
|
||||
** at QQQ 4.1.0-RC.5. Keep the two identical.</p>
|
||||
*******************************************************************************/
|
||||
public final class AddressRules
|
||||
{
|
||||
/** A dotted-quad IPv4 literal with octets 0-255. */
|
||||
private static final String IPV4 = "((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
|
||||
|
||||
private static final List<Cidr> BLOCKED = List.of(
|
||||
Cidr.parse("0.0.0.0/8"),
|
||||
Cidr.parse("10.0.0.0/8"),
|
||||
Cidr.parse("100.64.0.0/10"),
|
||||
Cidr.parse("127.0.0.0/8"),
|
||||
Cidr.parse("169.254.0.0/16"),
|
||||
Cidr.parse("172.16.0.0/12"),
|
||||
Cidr.parse("192.0.0.0/24"),
|
||||
Cidr.parse("192.0.2.0/24"),
|
||||
Cidr.parse("192.88.99.0/24"),
|
||||
Cidr.parse("192.168.0.0/16"),
|
||||
Cidr.parse("198.18.0.0/15"),
|
||||
Cidr.parse("198.51.100.0/24"),
|
||||
Cidr.parse("203.0.113.0/24"),
|
||||
Cidr.parse("224.0.0.0/4"),
|
||||
Cidr.parse("240.0.0.0/4"),
|
||||
Cidr.parse("::/128"),
|
||||
Cidr.parse("::1/128"),
|
||||
Cidr.parse("100::/64"),
|
||||
Cidr.parse("2001:db8::/32"),
|
||||
Cidr.parse("fc00::/7"),
|
||||
Cidr.parse("fe80::/10"),
|
||||
Cidr.parse("fec0::/10"),
|
||||
Cidr.parse("ff00::/8"));
|
||||
|
||||
private static final List<Cidr> ALWAYS_BLOCKED = List.of(
|
||||
Cidr.parse("0.0.0.0/32"),
|
||||
Cidr.parse("169.254.169.254/32"),
|
||||
Cidr.parse("100.100.100.200/32"),
|
||||
Cidr.parse("::/128"),
|
||||
Cidr.parse("fd00:ec2::254/128"));
|
||||
|
||||
private final List<Cidr> allowedCidrs = new ArrayList<>();
|
||||
private final List<String> allowedHosts = new ArrayList<>();
|
||||
private final List<String> allowedSuffixes = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rules with an allowlist (null or empty: nothing internal is allowed).
|
||||
**
|
||||
** @throws IllegalArgumentException for an entry that is not a CIDR, an
|
||||
** address, a host name or a .suffix
|
||||
*******************************************************************************/
|
||||
public AddressRules(List<String> allowlist)
|
||||
{
|
||||
if(allowlist == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(String raw : allowlist)
|
||||
{
|
||||
String entry = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
|
||||
if(entry.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(entry.contains("/") || looksLikeAddress(entry) || entry.matches("[0-9.]+"))
|
||||
{
|
||||
allowedCidrs.add(Cidr.parse(entry.contains("/") ? entry : entry + (entry.contains(":") ? "/128" : "/32")));
|
||||
}
|
||||
else if(entry.startsWith("."))
|
||||
{
|
||||
validateHostName(entry.substring(1), raw);
|
||||
allowedSuffixes.add(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
validateHostName(entry, raw);
|
||||
allowedHosts.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rules with no allowlist.
|
||||
*******************************************************************************/
|
||||
public static AddressRules defaults()
|
||||
{
|
||||
return (new AddressRules(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the address is in a blocked range (ignoring any allowlist).
|
||||
*******************************************************************************/
|
||||
public static boolean isInternal(InetAddress address)
|
||||
{
|
||||
InetAddress effective = embeddedIpv4(address);
|
||||
for(Cidr cidr : BLOCKED)
|
||||
{
|
||||
if(cidr.contains(effective) || cidr.contains(address))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null when the host may be reached at this address, else the reason.
|
||||
*******************************************************************************/
|
||||
public String check(String host, InetAddress address)
|
||||
{
|
||||
InetAddress effective = embeddedIpv4(address);
|
||||
for(Cidr cidr : ALWAYS_BLOCKED)
|
||||
{
|
||||
if(cidr.contains(effective) || cidr.contains(address))
|
||||
{
|
||||
return (address.getHostAddress() + " is a cloud metadata or unspecified address and is never allowed");
|
||||
}
|
||||
}
|
||||
if(!isInternal(address))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String lowerHost = host == null ? "" : host.toLowerCase(Locale.ROOT);
|
||||
if(allowedHosts.contains(lowerHost))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(String suffix : allowedSuffixes)
|
||||
{
|
||||
if(lowerHost.endsWith(suffix))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
for(Cidr cidr : allowedCidrs)
|
||||
{
|
||||
if(cidr.contains(effective) || cidr.contains(address))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
return (address.getHostAddress() + " is internal (private, loopback, link-local or reserved) and is not on this endpoint's allowlist");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when there is any allowlist entry.
|
||||
*******************************************************************************/
|
||||
public boolean hasAllowlist()
|
||||
{
|
||||
return (!allowedCidrs.isEmpty() || !allowedHosts.isEmpty() || !allowedSuffixes.isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The IPv4 address embedded in an IPv6 address (mapped, compatible, NAT64,
|
||||
** 6to4), or the address itself.
|
||||
*******************************************************************************/
|
||||
static InetAddress embeddedIpv4(InetAddress address)
|
||||
{
|
||||
if(!(address instanceof Inet6Address))
|
||||
{
|
||||
return (address);
|
||||
}
|
||||
byte[] b = address.getAddress();
|
||||
try
|
||||
{
|
||||
boolean firstTenZero = true;
|
||||
for(int i = 0; i < 10; i++)
|
||||
{
|
||||
firstTenZero &= b[i] == 0;
|
||||
}
|
||||
boolean mapped = firstTenZero && (b[10] & 0xff) == 0xff && (b[11] & 0xff) == 0xff;
|
||||
boolean compatible = firstTenZero && b[10] == 0 && b[11] == 0 && !(b[12] == 0 && b[13] == 0 && b[14] == 0 && (b[15] == 0 || b[15] == 1));
|
||||
if(mapped || compatible)
|
||||
{
|
||||
return (InetAddress.getByAddress(new byte[] {b[12], b[13], b[14], b[15]}));
|
||||
}
|
||||
boolean nat64 = (b[0] & 0xff) == 0x00 && (b[1] & 0xff) == 0x64 && (b[2] & 0xff) == 0xff && (b[3] & 0xff) == 0x9b;
|
||||
for(int i = 4; i < 12 && nat64; i++)
|
||||
{
|
||||
nat64 = b[i] == 0;
|
||||
}
|
||||
if(nat64)
|
||||
{
|
||||
return (InetAddress.getByAddress(new byte[] {b[12], b[13], b[14], b[15]}));
|
||||
}
|
||||
if((b[0] & 0xff) == 0x20 && (b[1] & 0xff) == 0x02)
|
||||
{
|
||||
return (InetAddress.getByAddress(new byte[] {b[2], b[3], b[4], b[5]}));
|
||||
}
|
||||
}
|
||||
catch(UnknownHostException e)
|
||||
{
|
||||
return (address);
|
||||
}
|
||||
return (address);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for a literal IPv4 address (four octets of 0-255) or IPv6 address,
|
||||
** so parsing it never triggers a DNS lookup.
|
||||
*******************************************************************************/
|
||||
static boolean looksLikeAddress(String text)
|
||||
{
|
||||
return (text.matches(IPV4) || (text.contains(":") && text.matches("[0-9a-f:.]+")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host name: letters, digits, hyphens and dots.
|
||||
*******************************************************************************/
|
||||
private static void validateHostName(String name, String raw)
|
||||
{
|
||||
boolean valid = !name.isEmpty() && name.length() <= 253;
|
||||
for(String label : name.split("\\.", -1))
|
||||
{
|
||||
valid &= isHostLabel(label);
|
||||
}
|
||||
if(!valid)
|
||||
{
|
||||
throw (new IllegalArgumentException("Not a CIDR block, address, host name or .suffix: " + raw));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One DNS label: 1-63 of a-z, 0-9 and '-', not starting or ending with '-'.
|
||||
** (A plain loop rather than a regex, so no input can make it backtrack.)
|
||||
*******************************************************************************/
|
||||
private static boolean isHostLabel(String label)
|
||||
{
|
||||
if(label.isEmpty() || label.length() > 63 || label.charAt(0) == '-' || label.charAt(label.length() - 1) == '-')
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
for(int i = 0; i < label.length(); i++)
|
||||
{
|
||||
char c = label.charAt(i);
|
||||
if(!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A CIDR block.
|
||||
*******************************************************************************/
|
||||
static final class Cidr
|
||||
{
|
||||
private final byte[] network;
|
||||
private final int prefix;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
private Cidr(byte[] network, int prefix)
|
||||
{
|
||||
this.network = network;
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse {@code address/prefix}.
|
||||
*******************************************************************************/
|
||||
static Cidr parse(String text)
|
||||
{
|
||||
int slash = text.indexOf('/');
|
||||
if(slash < 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("Not a CIDR block: " + text));
|
||||
}
|
||||
String addressText = text.substring(0, slash);
|
||||
if(!looksLikeAddress(addressText))
|
||||
{
|
||||
throw (new IllegalArgumentException("Not a CIDR block: " + text));
|
||||
}
|
||||
try
|
||||
{
|
||||
byte[] bytes = InetAddress.getByName(addressText).getAddress();
|
||||
int prefix = Integer.parseInt(text.substring(slash + 1));
|
||||
if(prefix < 0 || prefix > bytes.length * 8)
|
||||
{
|
||||
throw (new IllegalArgumentException("Bad prefix length in " + text));
|
||||
}
|
||||
return (new Cidr(bytes, prefix));
|
||||
}
|
||||
catch(UnknownHostException | NumberFormatException e)
|
||||
{
|
||||
throw (new IllegalArgumentException("Not a CIDR block: " + text, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the address is inside the block (same family only).
|
||||
*******************************************************************************/
|
||||
boolean contains(InetAddress address)
|
||||
{
|
||||
byte[] bytes = address.getAddress();
|
||||
if(bytes.length != network.length || (address instanceof Inet4Address) != (network.length == 4))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int fullBytes = prefix / 8;
|
||||
for(int i = 0; i < fullBytes; i++)
|
||||
{
|
||||
if(bytes[i] != network[i])
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
int remainder = prefix % 8;
|
||||
if(remainder == 0)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
int mask = (0xff << (8 - remainder)) & 0xff;
|
||||
return ((bytes[fullBytes] & mask) == (network[fullBytes] & mask));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user