From bfdf636dcfbc5c1ab15629a34df530c135047293 Mon Sep 17 00:00:00 2001 From: Anna Buianova Date: Thu, 29 Aug 2019 17:23:41 +0300 Subject: [PATCH] Implement webhook & API endpoints #3715 (#3783) --- app/controllers/api/v0/webhooks_controller.rb | 29 +++++++ app/models/user.rb | 71 +++++++++--------- app/models/webhook/endpoint.rb | 21 ++++++ app/models/webhook/event.rb | 18 +++++ app/views/api/v0/webhooks/show.json.jbuilder | 6 ++ config/routes.rb | 1 + ...20190819104106_create_webhook_endpoints.rb | 12 +++ db/schema.rb | 12 +++ spec/factories/webhook_endpoints.rb | 8 ++ spec/models/webhook/endpoint_spec.rb | 39 ++++++++++ spec/requests/api/v0/webhooks_spec.rb | 75 +++++++++++++++++++ 11 files changed, 257 insertions(+), 35 deletions(-) create mode 100644 app/controllers/api/v0/webhooks_controller.rb create mode 100644 app/models/webhook/endpoint.rb create mode 100644 app/models/webhook/event.rb create mode 100644 app/views/api/v0/webhooks/show.json.jbuilder create mode 100644 db/migrate/20190819104106_create_webhook_endpoints.rb create mode 100644 spec/factories/webhook_endpoints.rb create mode 100644 spec/models/webhook/endpoint_spec.rb create mode 100644 spec/requests/api/v0/webhooks_spec.rb diff --git a/app/controllers/api/v0/webhooks_controller.rb b/app/controllers/api/v0/webhooks_controller.rb new file mode 100644 index 000000000..3ea44fa22 --- /dev/null +++ b/app/controllers/api/v0/webhooks_controller.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 77687ef8a..7d2b9d02c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/webhook/endpoint.rb b/app/models/webhook/endpoint.rb new file mode 100644 index 000000000..792f2d57b --- /dev/null +++ b/app/models/webhook/endpoint.rb @@ -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 diff --git a/app/models/webhook/event.rb b/app/models/webhook/event.rb new file mode 100644 index 000000000..21eae2a5f --- /dev/null +++ b/app/models/webhook/event.rb @@ -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 diff --git a/app/views/api/v0/webhooks/show.json.jbuilder b/app/views/api/v0/webhooks/show.json.jbuilder new file mode 100644 index 000000000..589c9851d --- /dev/null +++ b/app/views/api/v0/webhooks/show.json.jbuilder @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 479497590..92c5753b3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/db/migrate/20190819104106_create_webhook_endpoints.rb b/db/migrate/20190819104106_create_webhook_endpoints.rb new file mode 100644 index 000000000..58bd370a7 --- /dev/null +++ b/db/migrate/20190819104106_create_webhook_endpoints.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index e6ea88ba5..f8e95b634 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/spec/factories/webhook_endpoints.rb b/spec/factories/webhook_endpoints.rb new file mode 100644 index 000000000..385bd91d5 --- /dev/null +++ b/spec/factories/webhook_endpoints.rb @@ -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 diff --git a/spec/models/webhook/endpoint_spec.rb b/spec/models/webhook/endpoint_spec.rb new file mode 100644 index 000000000..c9898728d --- /dev/null +++ b/spec/models/webhook/endpoint_spec.rb @@ -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 diff --git a/spec/requests/api/v0/webhooks_spec.rb b/spec/requests/api/v0/webhooks_spec.rb new file mode 100644 index 000000000..26b282035 --- /dev/null +++ b/spec/requests/api/v0/webhooks_spec.rb @@ -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