Implement webhook & API endpoints #3715 (#3783)

This commit is contained in:
Anna Buianova 2019-08-29 17:23:41 +03:00 committed by Mac Siri
parent 917bc14eae
commit bfdf636dcf
11 changed files with 257 additions and 35 deletions

View file

@ -0,0 +1,29 @@
module Api
module V0
class WebhooksController < ApiController
respond_to :json
before_action :authenticate!
def create
@webhook = current_user.webhook_endpoints.create!(webhook_params)
render "show", status: :created
end
def show
@webhook = Webhook::Endpoint.includes(:user).find(params[:id])
end
def destroy
webhook = current_user.webhook_endpoints.find(params[:id])
webhook.destroy
render json: { success: webhook.destroyed? }
end
private
def webhook_params
params.require(:webhook_endpoint).permit(:target_url, :source, events: [])
end
end
end
end

View file

@ -13,41 +13,42 @@ class User < ApplicationRecord
acts_as_followable
acts_as_follower
has_many :organization_memberships, dependent: :destroy
has_many :organizations, through: :organization_memberships
has_many :api_secrets, dependent: :destroy
has_many :articles, dependent: :destroy
has_many :badge_achievements, dependent: :destroy
has_many :badges, through: :badge_achievements
has_many :collections, dependent: :destroy
has_many :comments, dependent: :destroy
has_many :email_messages, class_name: "Ahoy::Message"
has_many :github_repos, dependent: :destroy
has_many :identities, dependent: :destroy
has_many :mentions, dependent: :destroy
has_many :messages, dependent: :destroy
has_many :notes, as: :noteable, inverse_of: :noteable
has_many :profile_pins, as: :profile, inverse_of: :profile
has_many :authored_notes, as: :author, inverse_of: :author, class_name: "Note"
has_many :notifications, dependent: :destroy
has_many :reactions, dependent: :destroy
has_many :tweets, dependent: :destroy
has_many :chat_channel_memberships, dependent: :destroy
has_many :chat_channels, through: :chat_channel_memberships
has_many :notification_subscriptions, dependent: :destroy
has_many :push_notification_subscriptions, dependent: :destroy
has_many :feedback_messages
has_many :rating_votes
has_many :html_variants, dependent: :destroy
has_many :page_views
has_many :credits
has_many :classified_listings
has_many :poll_votes
has_many :poll_skips
has_many :backup_data, foreign_key: "instance_user_id", inverse_of: :instance_user, class_name: "BackupData"
has_many :display_ad_events
has_many :access_grants, class_name: "Doorkeeper::AccessGrant", foreign_key: :resource_owner_id, inverse_of: :resource_owner, dependent: :delete_all
has_many :access_tokens, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id, inverse_of: :resource_owner, dependent: :delete_all
has_many :organization_memberships, dependent: :destroy
has_many :organizations, through: :organization_memberships
has_many :api_secrets, dependent: :destroy
has_many :articles, dependent: :destroy
has_many :badge_achievements, dependent: :destroy
has_many :badges, through: :badge_achievements
has_many :collections, dependent: :destroy
has_many :comments, dependent: :destroy
has_many :email_messages, class_name: "Ahoy::Message"
has_many :github_repos, dependent: :destroy
has_many :identities, dependent: :destroy
has_many :mentions, dependent: :destroy
has_many :messages, dependent: :destroy
has_many :notes, as: :noteable, inverse_of: :noteable
has_many :profile_pins, as: :profile, inverse_of: :profile
has_many :authored_notes, as: :author, inverse_of: :author, class_name: "Note"
has_many :notifications, dependent: :destroy
has_many :reactions, dependent: :destroy
has_many :tweets, dependent: :destroy
has_many :chat_channel_memberships, dependent: :destroy
has_many :chat_channels, through: :chat_channel_memberships
has_many :notification_subscriptions, dependent: :destroy
has_many :push_notification_subscriptions, dependent: :destroy
has_many :feedback_messages
has_many :rating_votes
has_many :html_variants, dependent: :destroy
has_many :page_views
has_many :credits
has_many :classified_listings
has_many :poll_votes
has_many :poll_skips
has_many :backup_data, foreign_key: "instance_user_id", inverse_of: :instance_user, class_name: "BackupData"
has_many :display_ad_events
has_many :access_grants, class_name: "Doorkeeper::AccessGrant", foreign_key: :resource_owner_id, inverse_of: :resource_owner, dependent: :delete_all
has_many :access_tokens, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id, inverse_of: :resource_owner, dependent: :delete_all
has_many :webhook_endpoints, class_name: "Webhook::Endpoint", foreign_key: :user_id, inverse_of: :user, dependent: :delete_all
mount_uploader :profile_image, ProfileImageUploader

View file

@ -0,0 +1,21 @@
module Webhook
class Endpoint < ApplicationRecord
belongs_to :user, inverse_of: :webhook_endpoints
validates :target_url, uniqueness: true, url: { schemes: %w[https] }
validates :events, presence: true
attribute :events, :string, array: true, default: []
scope :for_events, ->(events) { where("events @> ARRAY[?]::varchar[]", Array(events)) }
def self.table_name_prefix
"webhook_"
end
def events=(events)
events = Array(events).map { |event| event.to_s.underscore }
super(Webhook::Event::EVENT_TYPES & events)
end
end
end

View file

@ -0,0 +1,18 @@
module Webhook
class Event
EVENT_TYPES = %w[
article_created
article_updated
article_destroyed
].freeze
def initialize(event_name, payload = {})
@event_name = event_name
@payload = payload
end
private
attr_reader :event_name, :payload
end
end

View file

@ -0,0 +1,6 @@
json.type_of "webhook_endpoint"
json.events @webhook.events
json.target_url @webhook.target_url
json.source @webhook.source
json.partial! "api/v0/articles/user", user: @webhook.user

View file

@ -107,6 +107,7 @@ Rails.application.routes.draw do
post "/update_or_create", to: "github_repos#update_or_create"
end
end
resources :webhooks, only: %i[create show destroy]
get "/analytics/totals", to: "analytics#totals"
get "/analytics/historical", to: "analytics#historical"

View file

@ -0,0 +1,12 @@
class CreateWebhookEndpoints < ActiveRecord::Migration[5.2]
def change
create_table :webhook_endpoints do |t|
t.string :target_url, null: false
t.string :events, null: false, array: true
t.references :user, foreign_key: true, null: false
t.string :source
t.timestamps
t.index :events
end
end
end

View file

@ -1149,6 +1149,17 @@ ActiveRecord::Schema.define(version: 2019_08_27_163358) do
t.index ["user_id", "role_id"], name: "index_users_roles_on_user_id_and_role_id"
end
create_table "webhook_endpoints", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "events", null: false, array: true
t.string "source"
t.string "target_url", null: false
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["events"], name: "index_webhook_endpoints_on_events"
t.index ["user_id"], name: "index_webhook_endpoints_on_user_id"
end
add_foreign_key "badge_achievements", "badges"
add_foreign_key "badge_achievements", "users"
add_foreign_key "chat_channel_memberships", "chat_channels"
@ -1162,4 +1173,5 @@ ActiveRecord::Schema.define(version: 2019_08_27_163358) do
add_foreign_key "push_notification_subscriptions", "users"
add_foreign_key "sponsorships", "organizations"
add_foreign_key "sponsorships", "users"
add_foreign_key "webhook_endpoints", "users"
end

View file

@ -0,0 +1,8 @@
FactoryBot.define do
factory :webhook_endpoint, class: Webhook::Endpoint do
target_url { Faker::Internet.url(scheme: "https") }
events { %w[article_created article_updated article_destroyed] }
user
source { "stackbit" }
end
end

View file

@ -0,0 +1,39 @@
require "rails_helper"
RSpec.describe Webhook::Endpoint, type: :model do
let!(:endpoint) { create(:webhook_endpoint, user: user, events: %w[article_created article_updated article_destroyed]) }
let(:user) { create(:user) }
it "is valid" do
expect(endpoint).to be_valid
end
it "sets events according to the list" do
webhook = create(:webhook_endpoint, events: %w[article_updated other_updated cool article_created])
webhook.reload
expect(webhook.events.sort).to eq(%w[article_created article_updated])
end
context "when endpoints exist" do
let!(:epoint2) { create(:webhook_endpoint, events: %w[article_destroyed], user: user) }
let!(:epoint3) { create(:webhook_endpoint, events: %w[article_updated article_destroyed]) }
before do
create(:webhook_endpoint, events: %w[article_created])
end
it "finds for events" do
d_points = described_class.for_events("article_destroyed")
expect(d_points.pluck(:id).sort).to eq([endpoint, epoint2, epoint3].map(&:id).sort)
end
it "finds for_events array" do
endpoints = described_class.for_events(%w[article_created article_destroyed])
expect(endpoints.pluck(:id)).to eq([endpoint.id])
end
it "belongs to user" do
expect(user.webhook_endpoints.pluck(:id).sort).to eq([endpoint, epoint2].map(&:id).sort)
end
end
end

View file

@ -0,0 +1,75 @@
require "rails_helper"
RSpec.describe "Api::V0::Webhooks", type: :request do
let(:user) { create(:user) }
let!(:webhook) { create(:webhook_endpoint, user: user) }
before do
sign_in user
end
describe "GET /api/v0/webhooks/:id" do
it "returns 200 on success" do
get "/api/webhooks/#{webhook.id}"
expect(response).to have_http_status(:ok)
end
it "returns json on success" do
get "/api/webhooks/#{webhook.id}"
json = JSON.parse(response.body)
expect(json["target_url"]).to eq(webhook.target_url)
expect(json["user"]["username"]).to eq(user.username)
end
end
describe "POST /api/v0/webhooks" do
let(:webhook_params) do
{ source: "stackbit", target_url: Faker::Internet.url(scheme: "https"), events: %w[article_created article_updated article_destroyed] }
end
it "creates a webhook" do
expect do
post "/api/webhooks", params: { webhook_endpoint: webhook_params }
end.to change(Webhook::Endpoint, :count).by(1)
end
it "creates a webhook with events and data" do
post "/api/webhooks", params: { webhook_endpoint: webhook_params }
created_webhook = user.webhook_endpoints.last
expect(created_webhook.events).to eq(%w[article_created article_updated article_destroyed])
expect(created_webhook.target_url).to eq(webhook_params[:target_url])
expect(created_webhook.source).to eq(webhook_params[:source])
end
it "returns :created and json response on success" do
post "/api/webhooks", params: { webhook_endpoint: webhook_params }
expect(response).to have_http_status(:created)
expect(response.content_type).to eq("application/json")
json = JSON.parse(response.body)
expect(json["target_url"]).to eq(webhook_params[:target_url])
end
end
describe "DELETE /api/v0/webhooks/:id" do
it "deletes the webhook" do
expect do
delete "/api/webhooks/#{webhook.id}"
end.to change(Webhook::Endpoint, :count).by(-1)
end
it "returns 200 on success" do
delete "/api/webhooks/#{webhook.id}"
expect(response).to have_http_status(:ok)
expect(response.content_type).to eq("application/json")
json = JSON.parse(response.body)
expect(json["success"]).to be true
end
it "doesn't allow to destroy other user webhook" do
other_webhook = create(:webhook_endpoint, user: create(:user))
expect do
delete "/api/webhooks/#{other_webhook.id}"
end.not_to change(Webhook::Endpoint, :count)
end
end
end