mirror of https://github.com/docusealco/docuseal
parent
1b60b42428
commit
988a5361a6
@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookEventsController < ApplicationController
|
||||
load_and_authorize_resource :webhook_url, parent: false, only: %i[show resend], id_param: :webhook_id
|
||||
|
||||
def show
|
||||
@webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id])
|
||||
@webhook_attempts = @webhook_event.webhook_attempts.order(created_at: :desc)
|
||||
|
||||
return unless current_ability.can?(:read, @webhook_event.record)
|
||||
|
||||
@data =
|
||||
case @webhook_event.event_type
|
||||
when 'form.started', 'form.completed', 'form.declined', 'form.viewed'
|
||||
Submitters::SerializeForWebhook.call(@webhook_event.record)
|
||||
when 'submission.created', 'submission.completed', 'submission.expired'
|
||||
Submissions::SerializeForApi.call(@webhook_event.record)
|
||||
when 'template.created', 'template.updated'
|
||||
Templates::SerializeForApi.call(@webhook_event.record)
|
||||
when 'submission.archived'
|
||||
@webhook_event.record.as_json(only: %i[id archived_at])
|
||||
end
|
||||
end
|
||||
|
||||
def resend
|
||||
@webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id])
|
||||
|
||||
id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first)
|
||||
|
||||
WebhookUrls::EVENT_TYPE_TO_JOB_CLASS[@webhook_event.event_type].perform_async(
|
||||
id_key => @webhook_event.record_id,
|
||||
'webhook_url_id' => @webhook_event.webhook_url_id,
|
||||
'event_uuid' => @webhook_event.uuid,
|
||||
'attempt' => SendWebhookRequest::MANUAL_ATTEMPT,
|
||||
'last_status' => 0
|
||||
)
|
||||
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendTestWebhookRequestJob
|
||||
include Sidekiq::Job
|
||||
|
||||
sidekiq_options retry: 0
|
||||
|
||||
USER_AGENT = 'DocuSeal.com Webhook'
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url && submitter
|
||||
|
||||
Faraday.post(webhook_url.url,
|
||||
{
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current.iso8601,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT,
|
||||
**webhook_url.secret.to_h)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: webhook_attempts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# attempt :integer not null
|
||||
# response_body :text
|
||||
# response_status_code :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# webhook_event_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_webhook_attempts_on_webhook_event_id (webhook_event_id)
|
||||
#
|
||||
class WebhookAttempt < ApplicationRecord
|
||||
belongs_to :webhook_event
|
||||
|
||||
def success?
|
||||
response_status_code.to_i / 100 == 2
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: webhook_events
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# event_type :string not null
|
||||
# record_type :string not null
|
||||
# status :string not null
|
||||
# uuid :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# record_id :bigint not null
|
||||
# webhook_url_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_webhook_events_error (webhook_url_id,id) WHERE ((status)::text = 'error'::text)
|
||||
# index_webhook_events_on_uuid_and_webhook_url_id (uuid,webhook_url_id) UNIQUE
|
||||
# index_webhook_events_on_webhook_url_id_and_id (webhook_url_id,id)
|
||||
#
|
||||
class WebhookEvent < ApplicationRecord
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||
|
||||
belongs_to :webhook_url, optional: true
|
||||
belongs_to :account, optional: true
|
||||
belongs_to :record, polymorphic: true, optional: true
|
||||
|
||||
has_many :webhook_attempts, dependent: nil
|
||||
end
|
||||
@ -0,0 +1,65 @@
|
||||
<%= render 'shared/turbo_drawer', title: @webhook_event.event_type, close_after_submit: false do %>
|
||||
<div class="relative px-4 py-4">
|
||||
<ol class="relative border-s border-base-300 space-y-6 ml-3">
|
||||
<% if @webhook_event.status == 'error' %>
|
||||
<% last_attempt = @webhook_attempts.select { |e| SendWebhookRequest::AUTOMATED_RETRY_RANGE.cover?(e.attempt) }.max_by(&:attempt) %>
|
||||
<% if SendWebhookRequest::AUTOMATED_RETRY_RANGE.cover?(last_attempt&.attempt) %>
|
||||
<li class="ml-7">
|
||||
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center border-base-content-/60 text-base-content/60 bg-base-100" style="left: -12px;">
|
||||
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
<p class="leading-none text-base-content/90 pt-1">
|
||||
<%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, last_attempt.created_at + (2**last_attempt.attempt).minutes)) %>
|
||||
</p>
|
||||
</li>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @webhook_attempts.present? %>
|
||||
<% @webhook_attempts.each do |webhook_attempt| %>
|
||||
<li class="ml-7">
|
||||
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center <%= webhook_attempt.success? ? 'btn-success bg-lime-50' : 'btn-error bg-red-50' %>" style="left: -12px;">
|
||||
<%= svg_icon(webhook_attempt.success? ? 'check' : 'x', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
<p class="leading-none text-sm text-base-content/60 pt-1">
|
||||
<%= l(webhook_attempt.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm font-bold text-base-content/80">
|
||||
<span><%= Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %></span>
|
||||
<% if webhook_attempt.response_status_code.positive? %>
|
||||
<span>(<%= webhook_attempt.response_status_code %>)</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% unless webhook_attempt.success? %>
|
||||
<p class="text-sm text-base-content/80 mt-1">
|
||||
<%= webhook_attempt.response_body.presence || Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<li class="ml-7">
|
||||
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center btn-info bg-blue-50" style="left: -12px;">
|
||||
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
<p class="leading-none text-base-content/60 pt-1">
|
||||
<%= l(@webhook_event.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
|
||||
</p>
|
||||
</li>
|
||||
<% end %>
|
||||
</ol>
|
||||
<% unless @webhook_event.status == 'pending' %>
|
||||
<%= button_to button_title(title: t('resend'), disabled_with: 'sending', icon: svg_icon('rotate', class: 'w-4 h-4'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), resend_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid), class: 'absolute right-4 top-3 btn btn-neutral btn-sm text-white', method: :post %>
|
||||
<% end %>
|
||||
<% if @data %>
|
||||
<div class="mockup-code overflow-hidden relative pb-0 mt-6">
|
||||
<% response = JSON.pretty_generate({ event_type: @webhook_event.event_type, timestamp: @webhook_event.created_at.as_json, data: @data }) %>
|
||||
<span class="top-0 right-0 absolute">
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text: response, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
|
||||
</span>
|
||||
<pre class="before:!m-0 pl-4 pb-4"><code class="overflow-hidden text-sm w-full"><%== HighlightCode.call(response, 'JSON', theme: 'base16.dark') %></code></pre>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Rouge
|
||||
autoload :InheritableHash, 'rouge/util'
|
||||
autoload :Token, 'rouge/token'
|
||||
autoload :Lexer, 'rouge/lexer'
|
||||
autoload :RegexLexer, 'rouge/regex_lexer'
|
||||
|
||||
module Lexers
|
||||
autoload :JSON, 'rouge/lexers/json'
|
||||
end
|
||||
|
||||
autoload :Formatter, 'rouge/formatter'
|
||||
|
||||
module Formatters
|
||||
autoload :HTML, 'rouge/formatters/html'
|
||||
autoload :HTMLInline, 'rouge/formatters/html_inline'
|
||||
end
|
||||
|
||||
autoload :Theme, 'rouge/theme'
|
||||
end
|
||||
@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateWebhookEventsAndAttempts < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :webhook_events do |t|
|
||||
t.string :uuid, null: false
|
||||
t.bigint :webhook_url_id, null: false
|
||||
t.bigint :account_id, null: false
|
||||
t.bigint :record_id, null: false
|
||||
t.string :record_type, null: false
|
||||
t.string :event_type, null: false
|
||||
t.string :status, null: false
|
||||
|
||||
t.index %i[uuid webhook_url_id], unique: true
|
||||
t.index %i[webhook_url_id id]
|
||||
t.index %i[webhook_url_id id], where: "status = 'error'", name: 'index_webhook_events_error'
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
create_table :webhook_attempts do |t|
|
||||
t.bigint :webhook_event_id, null: false, index: true
|
||||
t.text :response_body
|
||||
t.integer :response_status_code, null: false
|
||||
t.integer :attempt, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module HighlightCode
|
||||
module_function
|
||||
|
||||
def call(code, lexer, theme: 'base16.light')
|
||||
require 'rouge/themes/base16' unless Rouge::Theme.registry[theme]
|
||||
|
||||
formatter = Rouge::Formatters::HTMLInline.new(theme)
|
||||
lexer = Rouge::Lexers.const_get(lexer.to_sym).new
|
||||
formatted_code = formatter.format(lexer.lex(code))
|
||||
formatted_code = formatted_code.gsub('background-color: #181818', '') if theme == 'base16.dark'
|
||||
formatted_code
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,97 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe SendSubmissionExpiredWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user) }
|
||||
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user, expire_at: 1.day.ago) }
|
||||
let(:webhook_url) { create(:webhook_url, account:, events: ['submission.expired']) }
|
||||
|
||||
before do
|
||||
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
|
||||
value: GenerateCertificate.call.transform_values(&:to_pem))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 200)
|
||||
end
|
||||
|
||||
it 'sends a webhook request' do
|
||||
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'submission.expired',
|
||||
'timestamp' => /.*/,
|
||||
'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json)
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it 'sends a webhook request with the secret' do
|
||||
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
|
||||
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'submission.expired',
|
||||
'timestamp' => /.*/,
|
||||
'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json)
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook',
|
||||
'X-Secret-Header' => 'secret_value'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it "doesn't send a webhook request if the event is not in the webhook's events" do
|
||||
webhook_url.update!(events: ['submission.archived'])
|
||||
|
||||
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid)
|
||||
|
||||
expect(WebMock).not_to have_requested(:post, webhook_url.url)
|
||||
end
|
||||
|
||||
it 'sends again if the response status is 400 or higher' do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 401)
|
||||
|
||||
event_uuid = SecureRandom.uuid
|
||||
|
||||
expect do
|
||||
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => event_uuid)
|
||||
end.to change(described_class.jobs, :size).by(1)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
|
||||
args = described_class.jobs.last['args'].first
|
||||
|
||||
expect(args['attempt']).to eq(1)
|
||||
expect(args['last_status']).to eq(401)
|
||||
expect(args['event_uuid']).to eq(event_uuid)
|
||||
expect(args['webhook_url_id']).to eq(webhook_url.id)
|
||||
expect(args['submission_id']).to eq(submission.id)
|
||||
end
|
||||
|
||||
it "doesn't send again if the max attempts is reached" do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 401)
|
||||
|
||||
expect do
|
||||
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
|
||||
'event_uuid' => SecureRandom.uuid, 'attempt' => 21)
|
||||
end.not_to change(described_class.jobs, :size)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue