Add JSON API for webhook deliveries (#2785)
* Add JSON API for webhook deliveries * Extract sanitized_request method on Webhook::Delivery * Extract response_summary method on Webhook::Delivery
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
class Webhooks::DeliveriesController < ApplicationController
|
||||
include BoardScoped
|
||||
|
||||
before_action :ensure_admin
|
||||
before_action :set_webhook
|
||||
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
set_page_and_extract_portion_from @webhook.deliveries.ordered.includes(event: [ :creator, { eventable: :card } ])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_webhook
|
||||
@webhook = @board.webhooks.find(params[:webhook_id])
|
||||
end
|
||||
end
|
||||
@@ -44,6 +44,18 @@ class Webhook::Delivery < ApplicationRecord
|
||||
raise
|
||||
end
|
||||
|
||||
def sanitized_request
|
||||
if headers = request&.dig("headers")&.except("X-Webhook-Signature")
|
||||
{ headers: headers }
|
||||
end
|
||||
end
|
||||
|
||||
def response_summary
|
||||
if response.present?
|
||||
{ code: response[:code], error: response[:error] }
|
||||
end
|
||||
end
|
||||
|
||||
def failed?
|
||||
(errored? || completed?) && !succeeded?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
json.cache! delivery do
|
||||
json.(delivery, :id, :state)
|
||||
json.created_at delivery.created_at.utc
|
||||
json.updated_at delivery.updated_at.utc
|
||||
|
||||
json.request delivery.sanitized_request
|
||||
json.response delivery.response_summary
|
||||
|
||||
json.event delivery.event, partial: "webhooks/deliveries/event", as: :event
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
json.cache! [ event, event.creator, event.eventable ] do
|
||||
json.(event, :id, :action)
|
||||
json.created_at event.created_at.utc
|
||||
|
||||
json.creator do
|
||||
json.id event.creator_id
|
||||
json.name event.creator.name
|
||||
end
|
||||
|
||||
json.eventable do
|
||||
json.type event.eventable_type
|
||||
json.id event.eventable_id
|
||||
json.url polymorphic_url(event.eventable)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @page.records, partial: "webhooks/deliveries/delivery", as: :delivery
|
||||
@@ -46,6 +46,7 @@ Rails.application.routes.draw do
|
||||
resources :webhooks do
|
||||
scope module: :webhooks do
|
||||
resource :activation, only: :create
|
||||
resources :deliveries, only: :index, defaults: { format: :json }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,3 +115,47 @@ HTTP/1.1 201 Created
|
||||
```
|
||||
|
||||
Returns the reactivated webhook in the response body.
|
||||
|
||||
## `GET /:account_slug/boards/:board_id/webhooks/:webhook_id/deliveries`
|
||||
|
||||
Returns a paginated list of deliveries for a webhook. Only account admins can access delivery history.
|
||||
|
||||
__Response:__
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "03f5v9zkft4hj9qq0lsn9ohdn",
|
||||
"state": "completed",
|
||||
"created_at": "2025-12-05T19:36:35.534Z",
|
||||
"updated_at": "2025-12-05T19:36:36.102Z",
|
||||
"request": {
|
||||
"headers": {
|
||||
"User-Agent": "fizzy/1.0.0 Webhook",
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Timestamp": "2025-12-05T19:36:35.401Z"
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"code": 200,
|
||||
"error": null
|
||||
},
|
||||
"event": {
|
||||
"id": "03f5v9zkft4hj9qq0lsn9ohde",
|
||||
"action": "card_closed",
|
||||
"created_at": "2025-12-05T19:36:35.401Z",
|
||||
"creator": {
|
||||
"id": "03f5v9zjw7pz8717a4no1h8a7",
|
||||
"name": "David Heinemeier Hansson"
|
||||
},
|
||||
"eventable": {
|
||||
"type": "Card",
|
||||
"id": "03f5v9zkft4hj9qq0lsn9ohdb",
|
||||
"url": "http://fizzy.localhost:3006/897362094/cards/1"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`request` and `response` can be `null` for deliveries that have not started yet or are still in progress. The delivery request headers omit the `X-Webhook-Signature` header.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
require "test_helper"
|
||||
|
||||
class Webhooks::DeliveriesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :kevin
|
||||
end
|
||||
|
||||
test "index as JSON" do
|
||||
webhook = webhooks(:active)
|
||||
delivery = webhook_deliveries(:successfully_completed)
|
||||
webhook_timestamp = delivery.event.created_at.utc.iso8601
|
||||
delivery.update!(
|
||||
request: {
|
||||
headers: {
|
||||
"User-Agent" => "fizzy/1.0.0 Webhook",
|
||||
"Content-Type" => "application/json",
|
||||
"X-Webhook-Signature" => "super-secret-signature",
|
||||
"X-Webhook-Timestamp" => webhook_timestamp
|
||||
}
|
||||
},
|
||||
response: { code: 200 }
|
||||
)
|
||||
|
||||
get board_webhook_deliveries_path(webhook.board, webhook), as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_kind_of Array, @response.parsed_body
|
||||
assert_equal webhook.deliveries.count, @response.parsed_body.count
|
||||
assert_equal webhook.deliveries.ordered.pluck(:id), @response.parsed_body.pluck("id")
|
||||
|
||||
completed_delivery = @response.parsed_body.find { |record| record["id"] == delivery.id }
|
||||
|
||||
assert_equal "completed", completed_delivery["state"]
|
||||
assert_equal delivery.created_at.utc.iso8601(3), completed_delivery["created_at"]
|
||||
assert_equal delivery.updated_at.utc.iso8601(3), completed_delivery["updated_at"]
|
||||
assert_equal "fizzy/1.0.0 Webhook", completed_delivery.dig("request", "headers", "User-Agent")
|
||||
assert_equal "application/json", completed_delivery.dig("request", "headers", "Content-Type")
|
||||
assert_equal webhook_timestamp, completed_delivery.dig("request", "headers", "X-Webhook-Timestamp")
|
||||
assert_not completed_delivery.dig("request", "headers").key?("X-Webhook-Signature")
|
||||
assert_equal 200, completed_delivery.dig("response", "code")
|
||||
assert_nil completed_delivery.dig("response", "error")
|
||||
assert_equal delivery.event.id, completed_delivery.dig("event", "id")
|
||||
assert_equal delivery.event.action, completed_delivery.dig("event", "action")
|
||||
assert_equal delivery.event.created_at.utc.iso8601(3), completed_delivery.dig("event", "created_at")
|
||||
assert_equal delivery.event.creator_id, completed_delivery.dig("event", "creator", "id")
|
||||
assert_equal delivery.event.creator.name, completed_delivery.dig("event", "creator", "name")
|
||||
assert_equal delivery.event.eventable_type, completed_delivery.dig("event", "eventable", "type")
|
||||
assert_equal delivery.event.eventable_id, completed_delivery.dig("event", "eventable", "id")
|
||||
assert_equal polymorphic_url(delivery.event.eventable), completed_delivery.dig("event", "eventable", "url")
|
||||
|
||||
pending_delivery = @response.parsed_body.find { |record| record["id"] == webhook_deliveries(:pending).id }
|
||||
assert_nil pending_delivery["request"]
|
||||
assert_nil pending_delivery["response"]
|
||||
end
|
||||
|
||||
test "index defaults to JSON" do
|
||||
webhook = webhooks(:active)
|
||||
|
||||
get board_webhook_deliveries_path(webhook.board, webhook)
|
||||
|
||||
assert_response :success
|
||||
assert_equal "application/json; charset=utf-8", @response.headers["Content-Type"]
|
||||
end
|
||||
|
||||
test "index rejects HTML" do
|
||||
webhook = webhooks(:active)
|
||||
|
||||
get board_webhook_deliveries_path(webhook.board, webhook, format: :html)
|
||||
|
||||
assert_response :not_acceptable
|
||||
end
|
||||
|
||||
test "non-admin cannot access deliveries as JSON" do
|
||||
logout_and_sign_in_as :jz
|
||||
webhook = webhooks(:active)
|
||||
|
||||
get board_webhook_deliveries_path(webhook.board, webhook), as: :json
|
||||
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "cannot access deliveries on board without access as JSON" do
|
||||
logout_and_sign_in_as :jason
|
||||
webhook = webhooks(:inactive)
|
||||
|
||||
get board_webhook_deliveries_path(webhook.board, webhook), as: :json
|
||||
|
||||
assert_response :not_found
|
||||
end
|
||||
end
|
||||
@@ -45,6 +45,45 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
|
||||
assert_not delivery.succeeded?, "the response can't have an error"
|
||||
end
|
||||
|
||||
test "sanitized_request strips signature header" do
|
||||
delivery = webhook_deliveries(:successfully_completed)
|
||||
delivery.update!(request: {
|
||||
headers: {
|
||||
"User-Agent" => "fizzy/1.0.0 Webhook",
|
||||
"Content-Type" => "application/json",
|
||||
"X-Webhook-Signature" => "super-secret-signature",
|
||||
"X-Webhook-Timestamp" => "2025-12-05T19:36:35.401Z"
|
||||
}
|
||||
})
|
||||
|
||||
result = delivery.sanitized_request
|
||||
assert_equal %w[ User-Agent Content-Type X-Webhook-Timestamp ], result[:headers].keys
|
||||
assert_not result[:headers].key?("X-Webhook-Signature")
|
||||
end
|
||||
|
||||
test "sanitized_request returns nil when request is blank" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
delivery.update_columns(request: nil)
|
||||
|
||||
assert_nil delivery.sanitized_request
|
||||
end
|
||||
|
||||
test "response_summary returns code and error" do
|
||||
delivery = webhook_deliveries(:successfully_completed)
|
||||
delivery.update!(response: { code: 200, error: nil })
|
||||
|
||||
result = delivery.response_summary
|
||||
assert_equal 200, result[:code]
|
||||
assert_nil result[:error]
|
||||
end
|
||||
|
||||
test "response_summary returns nil when response is blank" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
delivery.update_columns(response: nil)
|
||||
|
||||
assert_nil delivery.response_summary
|
||||
end
|
||||
|
||||
test "deliver_later" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user