diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb index 82166043e..2991be51a 100644 --- a/app/controllers/webhooks/activations_controller.rb +++ b/app/controllers/webhooks/activations_controller.rb @@ -4,9 +4,12 @@ class Webhooks::ActivationsController < ApplicationController before_action :ensure_admin def create - webhook = @board.webhooks.find(params[:webhook_id]) - webhook.activate + @webhook = @board.webhooks.find(params[:webhook_id]) + @webhook.activate - redirect_to webhook + respond_to do |format| + format.html { redirect_to @webhook } + format.json { render "webhooks/show", status: :created } + end end end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 7d79c4587..584c2dc7a 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -16,21 +16,45 @@ class WebhooksController < ApplicationController end def create - webhook = @board.webhooks.create!(webhook_params) - redirect_to webhook + @webhook = @board.webhooks.new(webhook_params) + + if @webhook.save + respond_to do |format| + format.html { redirect_to @webhook } + format.json { render :show, status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) } + end + else + respond_to do |format| + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @webhook.errors, status: :unprocessable_entity } + end + end end def edit end def update - @webhook.update!(webhook_params.except(:url)) - redirect_to @webhook + if @webhook.update(webhook_params.except(:url)) + respond_to do |format| + format.html { redirect_to @webhook } + format.json { render :show } + end + else + respond_to do |format| + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @webhook.errors, status: :unprocessable_entity } + end + end end def destroy @webhook.destroy! - redirect_to board_webhooks_path + + respond_to do |format| + format.html { redirect_to board_webhooks_path } + format.json { head :no_content } + end end private diff --git a/app/views/webhooks/_webhook.json.jbuilder b/app/views/webhooks/_webhook.json.jbuilder new file mode 100644 index 000000000..a55257984 --- /dev/null +++ b/app/views/webhooks/_webhook.json.jbuilder @@ -0,0 +1,8 @@ +json.cache! [ webhook, webhook.board ] do + json.(webhook, :id, :name, :active, :signing_secret, :subscribed_actions) + json.payload_url webhook.url + json.created_at webhook.created_at.utc + json.url board_webhook_url(webhook.board, webhook) + + json.board webhook.board, partial: "boards/board", as: :board +end diff --git a/app/views/webhooks/index.json.jbuilder b/app/views/webhooks/index.json.jbuilder new file mode 100644 index 000000000..c63df9ea0 --- /dev/null +++ b/app/views/webhooks/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "webhooks/webhook", as: :webhook diff --git a/app/views/webhooks/show.json.jbuilder b/app/views/webhooks/show.json.jbuilder new file mode 100644 index 000000000..78a9dcdc7 --- /dev/null +++ b/app/views/webhooks/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "webhooks/webhook", webhook: @webhook diff --git a/docs/API.md b/docs/API.md index fae64650a..cec58f36e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -591,6 +591,124 @@ __Response:__ Returns `204 No Content` on success. +## Webhooks + +Webhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks. + +### `GET /:account_slug/boards/:board_id/webhooks` + +Returns a paginated list of webhooks for a board. + +__Response:__ + +```json +[ + { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Production API", + "payload_url": "https://api.example.com/webhooks", + "active": true, + "signing_secret": "p94Bx2HjempCdYB4DTyZkY1b", + "subscribed_actions": ["card_published", "card_assigned", "card_closed"], + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm", + "board": { + "id": "03f5v9zkft4hj9qq0lsn9ohcy", + "name": "Fizzy", + "all_access": true, + "created_at": "2025-12-05T19:36:35.534Z", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy", + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "role": "owner", + "active": true, + "email_address": "david@example.com", + "created_at": "2025-12-05T19:36:35.401Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" + } + } + } +] +``` + +### `GET /:account_slug/boards/:board_id/webhooks/:id` + +Returns a single webhook. + +__Response:__ + +Returns the same webhook shape shown above. + +### `POST /:account_slug/boards/:board_id/webhooks` + +Creates a webhook. + +__Request:__ + +```json +{ + "webhook": { + "name": "Production API", + "url": "https://api.example.com/webhooks", + "subscribed_actions": ["card_published", "card_assigned", "card_closed"] + } +} +``` + +`subscribed_actions` accepts any of: +`card_assigned`, `card_closed`, `card_postponed`, `card_auto_postponed`, `card_board_changed`, `card_published`, `card_reopened`, `card_sent_back_to_triage`, `card_triaged`, `card_unassigned`, `comment_created` + +__Response:__ + +``` +HTTP/1.1 201 Created +Location: http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcy/webhooks/03f5v9zkft4hj9qq0lsn9ohcm.json +``` + +Returns the created webhook in the response body. + +### `PATCH /:account_slug/boards/:board_id/webhooks/:id` + +Updates a webhook. + +__Request:__ + +```json +{ + "webhook": { + "name": "Production API", + "subscribed_actions": ["card_closed"] + } +} +``` + +The `url` is immutable after creation and is ignored on update. + +__Response:__ + +Returns the updated webhook. + +### `DELETE /:account_slug/boards/:board_id/webhooks/:id` + +Deletes a webhook. + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/boards/:board_id/webhooks/:id/activation` + +Reactivates a deactivated webhook. + +__Response:__ + +``` +HTTP/1.1 201 Created +``` + +Returns the reactivated webhook in the response body. + ## Cards Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments. diff --git a/test/controllers/webhooks/activations_controller_test.rb b/test/controllers/webhooks/activations_controller_test.rb index b28fa3924..79a2390aa 100644 --- a/test/controllers/webhooks/activations_controller_test.rb +++ b/test/controllers/webhooks/activations_controller_test.rb @@ -32,4 +32,36 @@ class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest post board_webhook_activation_path(webhook.board, webhook) assert_response :forbidden end + + test "create as JSON" do + webhook = webhooks(:inactive) + + assert_not webhook.active? + + assert_changes -> { webhook.reload.active? }, from: false, to: true do + post board_webhook_activation_path(webhook.board, webhook), as: :json + end + + assert_response :created + assert_equal webhook.id, @response.parsed_body["id"] + assert_equal true, @response.parsed_body["active"] + end + + test "cannot activate webhook on board without access as JSON" do + logout_and_sign_in_as :jason + webhook = webhooks(:inactive) + + post board_webhook_activation_path(webhook.board, webhook), as: :json + + assert_response :not_found + end + + test "non-admin cannot activate webhook as JSON" do + logout_and_sign_in_as :jz + webhook = webhooks(:active) + + post board_webhook_activation_path(webhook.board, webhook), as: :json + + assert_response :forbidden + end end diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb index de336bc88..674be64c1 100644 --- a/test/controllers/webhooks_controller_test.rb +++ b/test/controllers/webhooks_controller_test.rb @@ -130,4 +130,143 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest get board_webhooks_path(webhook.board) assert_response :not_found end + + test "index as JSON" do + board = boards(:writebook) + + get board_webhooks_path(board), as: :json + + assert_response :success + assert_kind_of Array, @response.parsed_body + assert_equal board.webhooks.count, @response.parsed_body.count + assert_equal webhooks(:active).id, @response.parsed_body.first["id"] + end + + test "show as JSON" do + webhook = webhooks(:active) + + get board_webhook_path(webhook.board, webhook), as: :json + + assert_response :success + assert_equal webhook.id, @response.parsed_body["id"] + assert_equal webhook.name, @response.parsed_body["name"] + assert_equal webhook.url, @response.parsed_body["payload_url"] + assert_equal webhook.active?, @response.parsed_body["active"] + assert_equal webhook.signing_secret, @response.parsed_body["signing_secret"] + assert_equal webhook.subscribed_actions, @response.parsed_body["subscribed_actions"] + assert_equal webhook.board.id, @response.parsed_body.dig("board", "id") + end + + test "create as JSON" do + board = boards(:writebook) + + assert_difference "Webhook.count", 1 do + post board_webhooks_path(board), params: { + webhook: { + name: "Test Webhook", + url: "https://example.com/webhook", + subscribed_actions: [ "", "card_published", "card_closed" ] + } + }, as: :json + end + + webhook = Webhook.last + + assert_response :created + assert_equal board_webhook_url(board, webhook, format: :json), @response.headers["Location"] + assert_equal webhook.id, @response.parsed_body["id"] + assert_equal "https://example.com/webhook", @response.parsed_body["payload_url"] + assert_equal webhook.signing_secret, @response.parsed_body["signing_secret"] + end + + test "create with invalid params as JSON" do + board = boards(:writebook) + + assert_no_difference "Webhook.count" do + post board_webhooks_path(board), params: { + webhook: { + name: "", + url: "invalid-url" + } + }, as: :json + end + + assert_response :unprocessable_entity + assert @response.parsed_body["name"].present? + assert @response.parsed_body["url"].present? + end + + test "update as JSON" do + webhook = webhooks(:active) + + patch board_webhook_path(webhook.board, webhook), params: { + webhook: { + name: "Updated Webhook", + subscribed_actions: [ "card_published" ] + } + }, as: :json + + webhook.reload + + assert_response :success + assert_equal "Updated Webhook", webhook.name + assert_equal [ "card_published" ], webhook.subscribed_actions + assert_equal "Updated Webhook", @response.parsed_body["name"] + assert_equal [ "card_published" ], @response.parsed_body["subscribed_actions"] + end + + test "update with invalid params as JSON" do + webhook = webhooks(:active) + + patch board_webhook_path(webhook.board, webhook), params: { + webhook: { + name: "" + } + }, as: :json + + assert_response :unprocessable_entity + assert @response.parsed_body["name"].present? + end + + test "update does not change url as JSON" do + webhook = webhooks(:active) + + assert_no_changes -> { webhook.reload.url } do + patch board_webhook_path(webhook.board, webhook), params: { + webhook: { + name: "Updated Webhook", + url: "https://different.com/webhook" + } + }, as: :json + end + + assert_response :success + assert_equal webhook.reload.url, @response.parsed_body["payload_url"] + end + + test "destroy as JSON" do + webhook = webhooks(:active) + + assert_difference "Webhook.count", -1 do + delete board_webhook_path(webhook.board, webhook), as: :json + end + + assert_response :no_content + end + + test "non-admin cannot access webhook endpoints as JSON" do + logout_and_sign_in_as :jz + + get board_webhooks_path(boards(:writebook)), as: :json + + assert_response :forbidden + end + + test "cannot access webhooks on board without access as JSON" do + logout_and_sign_in_as :jason + + get board_webhooks_path(boards(:private)), as: :json + + assert_response :not_found + end end