From 21981898d28b48be36a01585f8dd757a886c7d4d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 7 Apr 2026 15:08:27 -0400 Subject: [PATCH] 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 --- .../webhooks/deliveries_controller.rb | 19 ++++ app/models/webhook/delivery.rb | 12 +++ .../deliveries/_delivery.json.jbuilder | 10 +++ .../webhooks/deliveries/_event.json.jbuilder | 15 ++++ .../webhooks/deliveries/index.json.jbuilder | 1 + config/routes.rb | 1 + docs/api/sections/webhooks.md | 44 +++++++++ .../webhooks/deliveries_controller_test.rb | 90 +++++++++++++++++++ test/models/webhook/delivery_test.rb | 39 ++++++++ 9 files changed, 231 insertions(+) create mode 100644 app/controllers/webhooks/deliveries_controller.rb create mode 100644 app/views/webhooks/deliveries/_delivery.json.jbuilder create mode 100644 app/views/webhooks/deliveries/_event.json.jbuilder create mode 100644 app/views/webhooks/deliveries/index.json.jbuilder create mode 100644 test/controllers/webhooks/deliveries_controller_test.rb diff --git a/app/controllers/webhooks/deliveries_controller.rb b/app/controllers/webhooks/deliveries_controller.rb new file mode 100644 index 000000000..551e5ae76 --- /dev/null +++ b/app/controllers/webhooks/deliveries_controller.rb @@ -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 diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 40dab42f0..952aab128 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -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 diff --git a/app/views/webhooks/deliveries/_delivery.json.jbuilder b/app/views/webhooks/deliveries/_delivery.json.jbuilder new file mode 100644 index 000000000..0fa8d92b3 --- /dev/null +++ b/app/views/webhooks/deliveries/_delivery.json.jbuilder @@ -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 diff --git a/app/views/webhooks/deliveries/_event.json.jbuilder b/app/views/webhooks/deliveries/_event.json.jbuilder new file mode 100644 index 000000000..311a73691 --- /dev/null +++ b/app/views/webhooks/deliveries/_event.json.jbuilder @@ -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 diff --git a/app/views/webhooks/deliveries/index.json.jbuilder b/app/views/webhooks/deliveries/index.json.jbuilder new file mode 100644 index 000000000..8df50813b --- /dev/null +++ b/app/views/webhooks/deliveries/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "webhooks/deliveries/delivery", as: :delivery diff --git a/config/routes.rb b/config/routes.rb index a21a0c457..d3eb300d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/docs/api/sections/webhooks.md b/docs/api/sections/webhooks.md index e06a02708..28eedfea7 100644 --- a/docs/api/sections/webhooks.md +++ b/docs/api/sections/webhooks.md @@ -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. diff --git a/test/controllers/webhooks/deliveries_controller_test.rb b/test/controllers/webhooks/deliveries_controller_test.rb new file mode 100644 index 000000000..49b167e6d --- /dev/null +++ b/test/controllers/webhooks/deliveries_controller_test.rb @@ -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 diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index 8d40c4417..5c63bc595 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -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)