├── test ├── config │ ├── web.rb │ ├── puma.rb │ └── server.rb ├── test_helper.rb ├── activehook_test.rb ├── server │ └── config_test.rb └── hook_test.rb ├── Procfile ├── lib ├── activehook │ ├── version.rb │ ├── app │ │ ├── config.ru │ │ ├── config.rb │ │ ├── middleware.rb │ │ └── launcher.rb │ ├── client.rb │ ├── errors.rb │ ├── redis.rb │ ├── client │ │ ├── config.rb │ │ └── recieve.rb │ ├── app.rb │ ├── server.rb │ ├── log.rb │ ├── validate.rb │ ├── server │ │ ├── config.rb │ │ ├── retry.rb │ │ ├── launcher.rb │ │ ├── queue.rb │ │ ├── send.rb │ │ ├── manager.rb │ │ └── worker.rb │ ├── config.rb │ └── hook.rb └── activehook.rb ├── Gemfile ├── .travis.yml ├── .gitignore ├── bin ├── activehook-app ├── setup ├── activehook-server └── console ├── Rakefile ├── LICENSE.txt ├── activehook.gemspec ├── CODE_OF_CONDUCT.md └── README.md /test/config/web.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec activehook-app -p test/config/puma.rb 2 | worker: bundle exec activehook-server 3 | -------------------------------------------------------------------------------- /lib/activehook/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | VERSION = '0.1.9' 3 | CODENAME = 'Fat Sparrow' 4 | end 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in activehook.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.3.0 5 | before_install: gem install bundler -v 1.12.5 6 | -------------------------------------------------------------------------------- /lib/activehook.rb: -------------------------------------------------------------------------------- 1 | require 'activehook/version' 2 | 3 | module ActiveHook 4 | class << self 5 | attr_accessor :mode 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /.byebug_history 4 | /Gemfile.lock 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | -------------------------------------------------------------------------------- /bin/activehook-app: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'activehook' 4 | require 'activehook/app/base' 5 | 6 | app = ActiveHook::App::Launcher.new(ARGV) 7 | app.start 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /bin/activehook-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'activehook' 4 | require 'activehook/server' 5 | 6 | server = ActiveHook::Server::Launcher.new(ARGV) 7 | server.start 8 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'activehook' 3 | require 'fakeredis' 4 | require 'byebug' 5 | 6 | require 'minitest/autorun' 7 | -------------------------------------------------------------------------------- /test/activehook_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ActiveHookTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::ActiveHook::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/config/puma.rb: -------------------------------------------------------------------------------- 1 | #Puma config 2 | workers Integer(5) 3 | threads_count = Integer(5) 4 | threads threads_count, threads_count 5 | 6 | preload_app! 7 | 8 | rackup DefaultRackup 9 | port ENV['PORT'] || 3000 10 | -------------------------------------------------------------------------------- /lib/activehook/app/config.ru: -------------------------------------------------------------------------------- 1 | require 'activehook' 2 | require 'activehook/app/base' 3 | require 'byebug' 4 | 5 | use ActiveHook::App::Middleware 6 | run -> (env) { [200, {"Content-Type" => "text/html"}, ["Hello World!"]] } 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList['test/**/*_test.rb'] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /lib/activehook/client.rb: -------------------------------------------------------------------------------- 1 | ActiveHook.mode = :client 2 | 3 | require 'uri' 4 | require 'json' 5 | require 'net/http' 6 | require 'openssl' 7 | require 'rack' 8 | require 'activehook/config' 9 | require 'activehook/client/config' 10 | require 'activehook/client/recieve' 11 | -------------------------------------------------------------------------------- /lib/activehook/errors.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module Errors 3 | class Config < StandardError; end 4 | class Hook < StandardError; end 5 | class HTTP < StandardError; end 6 | class Send < StandardError; end 7 | class Server < StandardError; end 8 | class Worker < StandardError; end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "activehook" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /lib/activehook/redis.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | class << self 3 | attr_reader :connection_pool 4 | 5 | def redis 6 | @connection_pool ||= ConnectionPool.create 7 | end 8 | end 9 | 10 | class ConnectionPool 11 | def self.create 12 | ::ConnectionPool.new(size: ActiveHook.config.redis_pool) do 13 | Redis.new(url: ActiveHook.config.redis_url) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/activehook/client/config.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module Client 3 | class Config < ActiveHook::BaseConfig 4 | OTHER_DEFAULTS = { 5 | validation_uri: '', 6 | validation_token: '' 7 | }.freeze 8 | 9 | attr_accessor :validation_uri, :validation_token 10 | 11 | def initialize 12 | super 13 | OTHER_DEFAULTS.each { |key, value| send("#{key}=", value) } 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/activehook/app/config.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module App 3 | class Config < ActiveHook::BaseConfig 4 | OTHER_DEFAULTS = { 5 | validation_path: '/hooks/validate', 6 | creation_path: '/hooks' 7 | }.freeze 8 | 9 | attr_accessor :validation_path, :creation_path 10 | 11 | def initialize 12 | super 13 | OTHER_DEFAULTS.each { |key, value| send("#{key}=", value) } 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/config/server.rb: -------------------------------------------------------------------------------- 1 | ActiveHook.configure do |config| 2 | #Your redis server url 3 | config.redis_url = ENV['REDIS_URL'] 4 | #The number of redis connections to provide 5 | config.redis_pool = 5 6 | #The number of forked workers to create for the server 7 | config.workers = 1 8 | #The number of queue threads to provide for each worker 9 | config.queue_threads = 3 10 | #The number of retry threads to provide for each worker 11 | config.retry_threads = 1 12 | end 13 | -------------------------------------------------------------------------------- /lib/activehook/app.rb: -------------------------------------------------------------------------------- 1 | ActiveHook.mode = :app 2 | 3 | require 'puma' 4 | require 'rack' 5 | require 'redis' 6 | require 'json' 7 | require 'openssl' 8 | require 'securerandom' 9 | require 'connection_pool' 10 | require 'activehook/config' 11 | require 'activehook/log' 12 | require 'activehook/redis' 13 | require 'activehook/errors' 14 | require 'activehook/hook' 15 | require 'activehook/validate' 16 | require 'activehook/version' 17 | require 'activehook/app/config' 18 | require 'activehook/app/launcher' 19 | require 'activehook/app/middleware' 20 | -------------------------------------------------------------------------------- /lib/activehook/server.rb: -------------------------------------------------------------------------------- 1 | ActiveHook.mode = :server 2 | 3 | require 'redis' 4 | require 'json' 5 | require 'uri' 6 | require 'net/http' 7 | require 'openssl' 8 | require 'connection_pool' 9 | require 'activehook/config' 10 | require 'activehook/redis' 11 | require 'activehook/errors' 12 | require 'activehook/hook' 13 | require 'activehook/log' 14 | require 'activehook/server/config' 15 | require 'activehook/server/launcher' 16 | require 'activehook/server/manager' 17 | require 'activehook/server/queue' 18 | require 'activehook/server/retry' 19 | require 'activehook/server/send' 20 | require 'activehook/server/worker' 21 | -------------------------------------------------------------------------------- /lib/activehook/log.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module ActiveHook 4 | class << self 5 | STDOUT.sync = true 6 | 7 | def log 8 | @log ||= Log.new 9 | end 10 | end 11 | 12 | class Log 13 | def initialize 14 | @log = ::Logger.new(STDOUT) 15 | @log.formatter = proc do |_severity, datetime, _progname, msg| 16 | "#{msg}\n" 17 | end 18 | end 19 | 20 | def info(msg) 21 | @log.info("[ \e[32mOK\e[0m ] #{msg}") 22 | end 23 | 24 | def err(msg, action: :no_exit) 25 | @log.info("[ \e[31mER\e[0m ] #{msg}") 26 | exit 1 if action == :exit 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/server/config_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'activehook/server/base' 3 | 4 | class ConfigTest < Minitest::Test 5 | 6 | def test_that_redis_configure_works 7 | ActiveHook.configure do |c| 8 | c.redis_url = ENV['REDIS_URL'] 9 | c.redis_pool = 20 10 | end 11 | assert ActiveHook.config.redis_url == ENV['REDIS_URL'] 12 | assert ActiveHook.config.redis_pool == 20 13 | end 14 | 15 | def test_that_redis_setup_works 16 | ActiveHook.configure do |c| 17 | c.redis_url = ENV['REDIS_URL'] 18 | c.redis_pool = 10 19 | end 20 | assert ActiveHook.redis.with(&:ping) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/activehook/validate.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | class Validate 3 | attr_accessor :id, :key 4 | 5 | def initialize(options = {}) 6 | options.each { |key, value| send("#{key}=", value) } 7 | end 8 | 9 | def perform 10 | validate! 11 | @key == find_key 12 | rescue 13 | false 14 | end 15 | 16 | private 17 | 18 | def find_key 19 | ActiveHook.redis.with do |conn| 20 | conn.zrangebyscore('ah:validation', @id.to_i, @id.to_i).first 21 | end 22 | end 23 | 24 | def validate! 25 | raise Errors::Validation, 'ID must be an integer.' unless @id.is_a?(Integer) 26 | raise Errors::Validation, 'Key must be a a string.' unless @key.is_a?(String) && @key.length > 6 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/activehook/server/config.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module Server 3 | class Config < ActiveHook::BaseConfig 4 | OTHER_DEFAULTS = { 5 | workers: 2, 6 | queue_threads: 2, 7 | retry_threads: 1 8 | }.freeze 9 | 10 | attr_accessor :workers, :queue_threads, :retry_threads 11 | 12 | def initialize 13 | super 14 | OTHER_DEFAULTS.each { |key, value| send("#{key}=", value) } 15 | end 16 | 17 | def worker_options 18 | { 19 | queue_threads: queue_threads, 20 | retry_threads: retry_threads 21 | } 22 | end 23 | 24 | def manager_options 25 | { 26 | workers: workers, 27 | options: worker_options 28 | } 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/activehook/server/retry.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module Server 3 | class Retry 4 | def initialize 5 | @done = false 6 | end 7 | 8 | def start 9 | until @done 10 | ActiveHook.redis.with do |conn| 11 | conn.watch('ah:retry') do 12 | retries = retrieve_retries(conn) 13 | update_retries(conn, retries) 14 | end 15 | end 16 | sleep 2 17 | end 18 | end 19 | 20 | def shutdown 21 | @done = true 22 | end 23 | 24 | private 25 | 26 | def retrieve_retries(conn) 27 | conn.zrangebyscore('ah:retry', 0, Time.now.to_i) 28 | end 29 | 30 | def update_retries(conn, retries) 31 | if retries.any? 32 | conn.multi do |multi| 33 | multi.incrby('ah:total_retries', retries.count) 34 | multi.zrem('ah:retry', retries) 35 | multi.lpush('ah:queue', retries) 36 | end 37 | else 38 | conn.unwatch 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/activehook/config.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | class << self 3 | def configure 4 | reset 5 | yield(config) 6 | end 7 | 8 | def config 9 | @config ||= build_config 10 | end 11 | 12 | def build_config 13 | klass = 14 | case ActiveHook.mode 15 | when :server then ActiveHook::Server::Config 16 | when :client then ActiveHook::Client::Config 17 | else ActiveHook::App::Config 18 | end 19 | klass.new 20 | end 21 | 22 | def reset 23 | @config = nil 24 | @connection_pool = nil 25 | end 26 | end 27 | 28 | class BaseConfig 29 | BASE_DEFAULTS = { 30 | redis_url: ENV['REDIS_URL'], 31 | redis_pool: 5, 32 | signature_header: 'X-Webhook-Signature' 33 | }.freeze 34 | 35 | attr_accessor :redis_url, :redis_pool, :signature_header 36 | 37 | def initialize 38 | BASE_DEFAULTS.each { |key, value| send("#{key}=", value) } 39 | end 40 | 41 | def redis 42 | { 43 | size: redis_pool, 44 | url: redis_url 45 | } 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Nicholas Sweeting 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /activehook.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'activehook/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "activehook" 8 | spec.version = ActiveHook::VERSION 9 | spec.authors = ["Nicholas Sweeting"] 10 | spec.email = ["nsweeting@gmail.com"] 11 | spec.summary = "Fast and simple webhook delivery microservice for Ruby." 12 | spec.description = "Fast and simple webhook delivery microservice for Ruby." 13 | spec.homepage = "https://github.com/nsweeting/activehook" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.executables = %w( activehook-server activehook-app ) 18 | spec.require_paths = %w( lib ) 19 | 20 | spec.add_runtime_dependency "redis", "~> 3.3" 21 | spec.add_runtime_dependency "connection_pool", "~> 2.2" 22 | spec.add_runtime_dependency "puma", "~> 3.4" 23 | spec.add_runtime_dependency "rack" 24 | spec.add_development_dependency "bundler", "~> 1.12" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | spec.add_development_dependency "minitest", "~> 5.0" 27 | spec.add_development_dependency "byebug", "~> 5.0" 28 | spec.add_development_dependency "fakeredis", "~> 0.5" 29 | end 30 | -------------------------------------------------------------------------------- /lib/activehook/server/launcher.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | 3 | module ActiveHook 4 | module Server 5 | # Handles the start of the ActiveHook server via command line 6 | # 7 | class Launcher 8 | def initialize(argv) 9 | @argv = argv 10 | end 11 | 12 | # Parses commmand line options and starts the Manager object 13 | # 14 | def start 15 | start_message 16 | setup_options 17 | boot_manager 18 | end 19 | 20 | private 21 | 22 | def start_message 23 | ActiveHook.log.info('ActiveHook Server starting!') 24 | ActiveHook.log.info("* Version #{VERSION}, codename: #{CODENAME}") 25 | end 26 | 27 | # Parses the arguments passed through the command line. 28 | # 29 | def setup_options 30 | parser = OptionParser.new do |o| 31 | o.banner = 'Usage: bundle exec bin/activehook [options]' 32 | 33 | o.on('-c', '--config PATH', 'Load PATH for config file') do |arg| 34 | load(arg) 35 | ActiveHook.log.info("* Server config: #{arg}") 36 | end 37 | 38 | o.on('-h', '--help', 'Prints this help') { puts o && exit } 39 | end 40 | parser.parse!(@argv) 41 | end 42 | 43 | def boot_manager 44 | manager = ActiveHook::Server::Manager.new(ActiveHook.config.manager_options) 45 | manager.start 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/activehook/app/middleware.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module App 3 | class Middleware 4 | def initialize(app) 5 | @app = app 6 | end 7 | 8 | def call(env) 9 | @env = env 10 | @req = Rack::Request.new(env) 11 | 12 | if validation_request? then response(Validation) 13 | #Not enabling webhook creation yet. 14 | #elsif creation_request? then response(Creation) 15 | else @app.call(@env) 16 | end 17 | end 18 | 19 | def validation_request? 20 | @req.path == ActiveHook.config.validation_path && @req.get? 21 | end 22 | 23 | def creation_request? 24 | @req.path == ActiveHook.config.creation_path && @req.post? 25 | end 26 | 27 | def response(klass) 28 | response = 29 | if klass.new(@req).start then { code: 200, status: true } 30 | else { code: 400, status: false } 31 | end 32 | [response[:code], { "Content-Type" => "application/json" }, [{ status: response[:status] }.to_json]] 33 | end 34 | end 35 | 36 | Validation = Struct.new(:req) do 37 | def start 38 | hook = { id: req.params['id'].to_i, key: req.params['key'] } 39 | ActiveHook::Validate.new(hook).perform 40 | rescue 41 | false 42 | end 43 | end 44 | 45 | Creation = Struct.new(:req) do 46 | def start 47 | hook = JSON.parse(req.body.read) 48 | ActiveHook::Hook.new(hook).perform 49 | rescue 50 | false 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/activehook/app/launcher.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | 3 | module ActiveHook 4 | module App 5 | # Handles the start of the ActiveHook web via command line 6 | # 7 | class Launcher 8 | def initialize(argv) 9 | @argv = argv 10 | @puma_config = nil 11 | end 12 | 13 | def start 14 | start_message 15 | setup_options 16 | boot_puma 17 | end 18 | 19 | private 20 | 21 | def start_message 22 | ActiveHook.log.info('ActiveHook App starting!') 23 | ActiveHook.log.info("* Version #{VERSION}, codename: #{CODENAME}") 24 | end 25 | 26 | # Parses the arguments passed through the command line. 27 | # 28 | def setup_options 29 | parser = OptionParser.new do |o| 30 | o.banner = 'Usage: bundle exec bin/activehook [options]' 31 | 32 | o.on('-c', '--config PATH', 'Load PATH for config file') do |arg| 33 | load(arg) 34 | ActiveHook.log.info("* App config: #{arg}") 35 | end 36 | 37 | o.on('-p', '--puma config PATH', 'Load PATH for puma config file') do |arg| 38 | @puma_config = arg 39 | ActiveHook.log.info("* Puma config: #{arg}") 40 | end 41 | 42 | o.on('-h', '--help', 'Prints this help') { puts o && exit } 43 | end 44 | parser.parse!(@argv) 45 | end 46 | 47 | def boot_puma 48 | ActiveHook.log.info('* Booting Puma...') 49 | exec("bundle exec puma -C #{@puma_config} --dir lib/activehook/app/") 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/activehook/server/queue.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module Server 3 | # The Queue object processes any hooks that are queued into our Redis server. 4 | # It will perform a 'blocking pop' on our hook list until one is added. 5 | # 6 | class Queue 7 | def initialize 8 | @done = false 9 | end 10 | 11 | # Starts our queue process. This will run until instructed to stop. 12 | # 13 | def start 14 | until @done 15 | json = retrieve_hook 16 | HookRunner.new(json) if json 17 | end 18 | end 19 | 20 | # Shutsdown our queue process. 21 | # 22 | def shutdown 23 | @done = true 24 | end 25 | 26 | private 27 | 28 | # Performs a 'blocking pop' on our redis queue list. 29 | # 30 | def retrieve_hook 31 | json = ActiveHook.redis.with { |c| c.brpop('ah:queue') } 32 | json.last if json 33 | end 34 | end 35 | 36 | class HookRunner 37 | def initialize(json) 38 | @hook = Hook.new(JSON.parse(json)) 39 | @post = Send.new(hook: @hook) 40 | start 41 | end 42 | 43 | def start 44 | @post.start 45 | ActiveHook.redis.with do |conn| 46 | @post.success? ? hook_success(conn) : hook_failed(conn) 47 | end 48 | end 49 | 50 | private 51 | 52 | def hook_success(conn) 53 | conn.incr('ah:total_success') 54 | end 55 | 56 | def hook_failed(conn) 57 | conn.zadd('ah:retry', @hook.retry_at, @hook.to_json) if @hook.retry? 58 | conn.incr('ah:total_failed') 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/activehook/server/send.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | REQUEST_HEADERS = { 3 | "Content-Type" => "application/json", 4 | "Accept" => "application/json", 5 | "User-Agent" => "ActiveHook/#{ActiveHook::VERSION}" 6 | }.freeze 7 | 8 | module Server 9 | class Send 10 | attr_accessor :hook 11 | attr_reader :response_time, :status, :response 12 | 13 | def initialize(options = {}) 14 | options.each { |key, value| send("#{key}=", value) } 15 | end 16 | 17 | def start 18 | @status = post_hook 19 | log_status 20 | end 21 | 22 | def uri 23 | @uri ||= URI.parse(@hook.uri) 24 | end 25 | 26 | def success? 27 | @status == :success 28 | end 29 | 30 | private 31 | 32 | def post_hook 33 | http = Net::HTTP.new(uri.host, uri.port) 34 | measure_response_time do 35 | @response = http.post(uri.path, @hook.final_payload, final_headers) 36 | end 37 | response_status(@response) 38 | rescue 39 | :error 40 | end 41 | 42 | def measure_response_time 43 | start = Time.now 44 | yield 45 | finish = Time.now 46 | @response_time = "| #{((finish - start) * 1000.0).round(3)} ms" 47 | end 48 | 49 | def response_status(response) 50 | case response.code.to_i 51 | when (200..204) 52 | :success 53 | when (400..499) 54 | :bad_request 55 | when (500..599) 56 | :server_problems 57 | end 58 | end 59 | 60 | def log_status 61 | msg = "POST | #{uri} | #{status.upcase} #{response_time}" 62 | if status == :success 63 | ActiveHook.log.info(msg) 64 | else 65 | ActiveHook.log.err(msg) 66 | end 67 | end 68 | 69 | def final_headers 70 | { "X-Hook-Signature" => @hook.signature }.merge(REQUEST_HEADERS) 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/activehook/server/manager.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module Server 3 | # The Manager controls our Worker processes. We use it to instruct each 4 | # of them to start and shutdown. 5 | # 6 | class Manager 7 | attr_accessor :workers, :options 8 | attr_reader :forks 9 | 10 | def initialize(options = {}) 11 | options.each { |key, value| send("#{key}=", value) } 12 | @master = Process.pid 13 | at_exit { shutdown } 14 | end 15 | 16 | # Instantiates new Worker objects, setting them with our options. We 17 | # follow up by booting each of our Workers. Our Manager is then put to 18 | # sleep so that our Workers can do their thing. 19 | # 20 | def start 21 | validate! 22 | start_messages 23 | create_workers 24 | Process.wait 25 | end 26 | 27 | # Shutsdown our Worker processes. 28 | # 29 | def shutdown 30 | @forks.each { |w| Process.kill('SIGINT', w[:pid].to_i) } 31 | Process.kill('SIGINT', @master) 32 | end 33 | 34 | private 35 | 36 | # Create the specified number of workers and starts them 37 | # 38 | def create_workers 39 | @forks = [] 40 | @workers.times do |id| 41 | pid = fork { Worker.new(@options.merge(id: id)).start } 42 | @forks << { id: id, pid: pid } 43 | end 44 | end 45 | 46 | # Information about the start process 47 | # 48 | def start_messages 49 | ActiveHook.log.info("* Workers: #{@workers}") 50 | ActiveHook.log.info("* Threads: #{@options[:queue_threads]} queue, #{@options[:retry_threads]} retry") 51 | end 52 | 53 | # Validates our data before starting our Workers. Also instantiates our 54 | # connection pool by pinging Redis. 55 | # 56 | def validate! 57 | raise Errors::Server, 'Cound not connect to Redis.' unless ActiveHook.redis.with { |c| c.ping && c.quit } 58 | raise Errors::Server, 'Workers must be an Integer.' unless @workers.is_a?(Integer) 59 | raise Errors::Server, 'Options must be a Hash.' unless @options.is_a?(Hash) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/activehook/client/recieve.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module Client 3 | module Recieve 4 | 5 | REQUEST_HEADERS = { 6 | "Content-Type" => "application/json", 7 | "Accept" => "application/json", 8 | "User-Agent" => "ActiveHook/#{ActiveHook::VERSION}" 9 | }.freeze 10 | 11 | attr_accessor :request 12 | 13 | def initialize(options = {}) 14 | options.each { |key, value| send("#{key}=", value) } 15 | end 16 | 17 | def signature_valid? 18 | @signature_valid ||= validate_signature 19 | end 20 | 21 | def server_valid? 22 | @server_valid ||= validate_server 23 | end 24 | 25 | def payload 26 | parsed_body['payload'] 27 | rescue 28 | nil 29 | end 30 | 31 | def validated_payload 32 | raise StandardError, 'Webhook is invalid.' unless signature_valid? 33 | @payload 34 | end 35 | 36 | private 37 | 38 | def parsed_body 39 | @parsed_body ||= JSON.parse(request.body.read) 40 | rescue 41 | {} 42 | end 43 | 44 | def hook_id 45 | parsed_body['hook_id'] 46 | end 47 | 48 | def hook_key 49 | parsed_body['hook_key'] 50 | end 51 | 52 | def hook_uri 53 | @hook_uri ||= URI.parse(self.class::VALIDATION_URI) 54 | end 55 | 56 | def hook_signature 57 | @request.env['HTTP_X-Webhook-Signature'] 58 | end 59 | 60 | def validate_server 61 | http = Net::HTTP.new(hook_uri.host, hook_uri.port) 62 | response = http.post(hook_uri.path, hook_json, REQUEST_HEADERS) 63 | response.code.to_i == 200 ? true : false 64 | rescue 65 | false 66 | end 67 | 68 | def validate_signature 69 | signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), self.class::VALIDATION_TOKEN, payload) 70 | Rack::Utils.secure_compare(signature, hook_signature) 71 | rescue 72 | false 73 | end 74 | 75 | def hook_json 76 | { id: hook_id, 77 | key: hook_key }.to_json 78 | end 79 | end 80 | end 81 | 82 | class Recieve 83 | include ActiveHook::Client::Recieve 84 | 85 | VALIDATION_URI = (ActiveHook.config.validation_uri).freeze 86 | VALIDATION_TOKEN = (ActiveHook.config.validation_token).freeze 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at nsweeting@gmail.com. All 39 | complaints will be reviewed and investigated and will result in a response that 40 | is deemed necessary and appropriate to the circumstances. Maintainers are 41 | obligated to maintain confidentiality with regard to the reporter of an 42 | incident. 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 45 | version 1.3.0, available at 46 | [http://contributor-covenant.org/version/1/3/0/][version] 47 | 48 | [homepage]: http://contributor-covenant.org 49 | [version]: http://contributor-covenant.org/version/1/3/0/ -------------------------------------------------------------------------------- /lib/activehook/server/worker.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | module Server 3 | # The Worker manages our two main processes - Queue and Retry. Each of these 4 | # processes is alloted a number of threads. These threads are then forked. 5 | # Each worker object maintains control of these threads through the aptly 6 | # named start and shutdown methods. 7 | # 8 | class Worker 9 | attr_accessor :queue_threads, :retry_threads, :id 10 | 11 | def initialize(options = {}) 12 | options.each { |key, value| send("#{key}=", value) } 13 | @pid = Process.pid 14 | @threads = [] 15 | @_threads_real = [] 16 | at_exit { shutdown } 17 | end 18 | 19 | # Starts our new worker. 20 | # 21 | def start 22 | validate! 23 | start_message 24 | build_threads 25 | start_threads 26 | end 27 | 28 | # Shutsdown our worker as well as its threads. 29 | # 30 | def shutdown 31 | shutdown_message 32 | @threads.each(&:shutdown) 33 | @_threads_real.each(&:exit) 34 | end 35 | 36 | private 37 | 38 | # Forks the worker and creates the actual threads (@_threads_real) for 39 | # our Queue and Retry objects. We then start them and join them to the 40 | # main process. 41 | # 42 | def start_threads 43 | @threads.each do |thread| 44 | @_threads_real << Thread.new { thread.start } 45 | end 46 | @_threads_real.map(&:join) 47 | end 48 | 49 | # Instantiates our Queue and Retry objects based on the number of threads 50 | # specified for each process type. We store these objects as an array in 51 | # @threads. 52 | # 53 | def build_threads 54 | @queue_threads.times { @threads << Queue.new } 55 | @retry_threads.times { @threads << Retry.new } 56 | end 57 | 58 | # Information about the start process 59 | # 60 | def start_message 61 | ActiveHook.log.info("* Worker #{@id} started, pid: #{@pid}") 62 | end 63 | 64 | # Information about the shutdown process 65 | # 66 | def shutdown_message 67 | ActiveHook.log.info("* Worker #{@id} shutdown, pid: #{@pid}") 68 | end 69 | 70 | # Validates our data before starting the worker. 71 | # 72 | def validate! 73 | raise Errors::Worker, 'Queue threads must be an Integer.' unless @queue_threads.is_a?(Integer) 74 | raise Errors::Worker, 'Retry threads must be an Integer.' unless @retry_threads.is_a?(Integer) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/activehook/hook.rb: -------------------------------------------------------------------------------- 1 | module ActiveHook 2 | class Hook 3 | attr_accessor :token, :uri, :id, :key, :retry_max, :retry_time, :created_at 4 | attr_reader :errors, :payload 5 | 6 | def initialize(options = {}) 7 | options = defaults.merge(options) 8 | options.each { |key, value| send("#{key}=", value) } 9 | @errors = {} 10 | end 11 | 12 | def save 13 | return false unless valid? 14 | save_hook 15 | end 16 | 17 | def save! 18 | raise Errors::Hook, 'Hook is invalid' unless valid? 19 | save_hook 20 | end 21 | 22 | def payload=(payload) 23 | if payload.is_a?(String) 24 | @payload = JSON.parse(payload) 25 | else 26 | @payload = payload 27 | end 28 | rescue JSON::ParserError 29 | @payload = nil 30 | end 31 | 32 | def retry? 33 | fail_at > Time.now.to_i 34 | end 35 | 36 | def retry_at 37 | Time.now.to_i + @retry_time.to_i 38 | end 39 | 40 | def fail_at 41 | @created_at.to_i + retry_max_time 42 | end 43 | 44 | def retry_max_time 45 | @retry_time.to_i * @retry_max.to_i 46 | end 47 | 48 | def to_json 49 | { id: @id, 50 | key: @key, 51 | token: @token, 52 | created_at: @created_at, 53 | retry_time: @retry_time, 54 | retry_max: @retry_max, 55 | uri: @uri, 56 | payload: @payload }.to_json 57 | end 58 | 59 | def as_json(_options) 60 | to_json 61 | end 62 | 63 | def final_payload 64 | { hook_id: @id, 65 | hook_key: @key, 66 | hook_time: @created_at, 67 | hook_signature: ActiveHook.config.signature_header, 68 | payload: @payload }.to_json 69 | end 70 | 71 | def signature 72 | OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), @token, final_payload) 73 | end 74 | 75 | def valid? 76 | validate! 77 | @errors.empty? 78 | end 79 | 80 | private 81 | 82 | def save_hook 83 | ActiveHook.redis.with do |conn| 84 | @id = conn.incr('ah:total_queued') 85 | conn.lpush('ah:queue', to_json) 86 | conn.zadd('ah:validation', @id, @key) 87 | end 88 | end 89 | 90 | def defaults 91 | { key: SecureRandom.uuid, 92 | created_at: Time.now.to_i, 93 | retry_time: 3600, 94 | retry_max: 3 } 95 | end 96 | 97 | def validate! 98 | @errors.merge!(token: ['must be a string.']) unless @token.is_a?(String) 99 | @errors.merge!(payload: ['must be a Hash']) unless @payload.is_a?(Hash) 100 | @errors.merge!(uri: ['is not a valid format.']) unless @uri =~ /\A#{URI::regexp}\z/ 101 | @errors.merge!(created_at: ['must be an Integer.']) unless @created_at.is_a?(Integer) 102 | @errors.merge!(retry_time: ['must be an Integer.']) unless @retry_time.is_a?(Integer) 103 | @errors.merge!(retry_max: ['must be an Integer.']) unless @retry_max.is_a?(Integer) 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/hook_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'activehook/app/base' 3 | 4 | class HookTest < Minitest::Test 5 | def setup 6 | @redis = Redis.new 7 | end 8 | 9 | def test_that_bad_uri_raises_exception 10 | hook = ActiveHook::Hook.new(uri: '5665', payload: { test: 1 }) 11 | assert_raises(ActiveHook::Errors::Hook) { hook.perform } 12 | end 13 | 14 | def test_that_non_hash_payload_raises_exception 15 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: []) 16 | assert_raises(ActiveHook::Errors::Hook) { hook.perform } 17 | end 18 | 19 | def test_that_non_integer_created_at_raises_exception 20 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 21 | hook.created_at = 'Test' 22 | assert_raises(ActiveHook::Errors::Hook) { hook.perform } 23 | end 24 | 25 | def test_that_non_integer_retry_time_raises_exception 26 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }, retry_time: 'Test') 27 | assert_raises(ActiveHook::Errors::Hook) { hook.perform } 28 | end 29 | 30 | def test_that_non_integer_retry_max_raises_exception 31 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }, retry_max: 'Test') 32 | assert_raises(ActiveHook::Errors::Hook) { hook.perform } 33 | end 34 | 35 | def test_that_new_hook_sets_key 36 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 37 | assert hook.key 38 | end 39 | 40 | def test_that_new_hook_sets_created_at 41 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 42 | assert hook.created_at 43 | end 44 | 45 | def test_that_to_json_returns_json 46 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 47 | json = hook.to_json 48 | new_hook = JSON.parse(json) 49 | assert new_hook['uri'] == hook.uri 50 | end 51 | 52 | def test_that_secure_payload_returns_json 53 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 54 | json = hook.secure_payload 55 | new_hook = JSON.parse(json) 56 | assert new_hook['hook_key'] == hook.key 57 | end 58 | 59 | def test_that_retry_max_time_returns_correct_number 60 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }, retry_time: 30, retry_max: 2) 61 | assert hook.retry_max_time == 60 62 | end 63 | 64 | def test_that_non_retry_question_works 65 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }, retry_time: 0, retry_max: 0) 66 | assert !hook.retry? 67 | end 68 | 69 | def test_that_retry_question_works 70 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }, retry_time: 3600, retry_max: 3) 71 | assert hook.retry? 72 | end 73 | 74 | def test_that_retry_at_returns_time_integer 75 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 76 | assert Time.now.to_i < hook.retry_at 77 | end 78 | 79 | def test_that_fail_at_returns_time_integer 80 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 81 | assert Time.now.to_i < hook.fail_at 82 | end 83 | 84 | def test_that_perform_adds_to_redis_queue 85 | llen = @redis.llen('ah:queue') 86 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 87 | hook.perform 88 | sleep 1 89 | assert @redis.llen('ah:queue') == llen + 1 90 | end 91 | 92 | def test_that_perform_adds_to_redis_validation 93 | zcount = @redis.zcount('ah:validation', 0, 10000000000) 94 | hook = ActiveHook::Hook.new(uri: 'http://test.com/', payload: { test: 1 }) 95 | hook.perform 96 | sleep 1 97 | assert @redis.zcount('ah:validation', 0, 10000000000) == zcount + 1 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveHook 2 | [![Code Climate](https://codeclimate.com/github/nsweeting/activehook/badges/gpa.svg)](https://codeclimate.com/github/nsweeting/activehook) [![Gem Version](https://badge.fury.io/rb/activehook.svg)](https://badge.fury.io/rb/activehook) 3 | 4 | Fast and simple webhook delivery microservice for Ruby. **Please consider it under development at the moment.** 5 | 6 | ActiveHook provides a scalable solution to your applications webhook sending needs. Its Redis-backed, with support for forking and threading - letting it send an enormous amount of webhooks in short order. Basically a much more focused version of a job processor such as Sidekiq, DelayedJob, Resque, etc. It includes the following: 7 | 8 | - A server for the purpose of sending webhooks. With support for retry attempts. 9 | - A client-side mixin module for the purpose of recieving and validating webhooks. 10 | - A piece of Rack middleware for providing server-side validation. 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'activehook' 18 | ``` 19 | 20 | And then execute: 21 | 22 | $ bundle 23 | 24 | Or install it yourself as: 25 | 26 | $ gem install activehook 27 | 28 | ## Getting Started 29 | 30 | Before starting, ensure you have a functioning Redis server available. 31 | 32 | ActiveHook can be operated in a few different ways. 33 | 34 | #### The Server 35 | 36 | In order to send webhooks, we run the ActiveHook server. This is a seperate service beyond your web application (Rails, Sinatra, etc). To start the server simply type the following in your console. 37 | 38 | $ bundle exec activehook-server -c config/activehook.rb 39 | 40 | By providing a path to a configuration file, we can setup ActiveHook with plain old ruby. In a rails application, this should be placed in your config folder. Below is a list of currently available server options: 41 | 42 | ```ruby 43 | # ActiveHook server configuration 44 | ActiveHook.configure do |config| 45 | # Your redis server url 46 | config.redis_url = ENV['REDIS_URL'] 47 | # The number of redis connections to provide 48 | config.redis_pool = 10 49 | # The number of forked workers to create for the server 50 | config.workers = 2 51 | # The number of queue threads to provide for each worker 52 | config.queue_threads = 2 53 | # The number of retry threads to provide for each worker 54 | config.retry_threads = 1 55 | end 56 | ``` 57 | 58 | #### Your Application 59 | 60 | Before we can create webhooks within our application, we will need to do some setup. With Rails, we should place this configuration with your initializers. Below is a list of currently available application options: 61 | 62 | ```ruby 63 | #IMPORTANT! 64 | require 'activehook/app' 65 | 66 | # ActiveHook app configuration 67 | ActiveHook.configure do |config| 68 | #Your redis server url 69 | config.redis_url = ENV['REDIS_URL'] 70 | #The number of redis connections to provide 71 | config.redis_pool = 5 72 | #The route to the webhook validator if you want to enable server-side validation 73 | config.validation_path = '/hooks/validate' 74 | end 75 | ``` 76 | 77 | To provide webhooks to your users, you will also need to allow them to specify a URI and token. In Rails, we can do this by creating a migration like below: 78 | 79 | ```ruby 80 | add_column :users, :webhook_uri, :string 81 | add_column :users, :webhook_token, :string 82 | ``` 83 | 84 | With our app setup, we can create webhooks for processing. From within our application, all we have to do is: 85 | 86 | ```ruby 87 | hook = ActiveHook::Hook.new(token: webhook_token, uri: webhook_uri, payload: { msg: 'My first webhook!' }) 88 | if hook.save # We can also do save!, which would raise an exception upon failure. 89 | # Success. 90 | else 91 | # Failed - access errors at hook.errors 92 | end 93 | 94 | ``` 95 | 96 | That's it! We provide a valid string token and URI, as well hash payload. ActiveHooks server will then attempt to send the webhook. If the webhook fails to be delivered, it will be sent to the retry queue. Delivery will be reattempted at the specified intervals, and eventually dropped if all attempts fail. 97 | 98 | The default setting for failed webhooks is 3 more attempts at an interval of 3600 seconds (1 hour). You can change these values by including them in your hook initialization. 99 | 100 | ```ruby 101 | ActiveHook::Hook.new(token: webhook_token, uri: webhook_uri, payload: { msg: 'My first webhook!' }, retry_max: 3, retry_time: 3600) 102 | ``` 103 | 104 | #### Recieving 105 | 106 | ActiveHook provides a class as well as mixin module for the purposes of recieving webhooks and performing validation on them. The class should be used for personal projects and testing, while the mixin module can be integrated with other application gems. 107 | 108 | Using the class or mixin, we are able to perform both client-side and server-side validation. 109 | 110 | Using the class is easy. We should first add the following config: 111 | 112 | ```ruby 113 | #IMPORTANT! 114 | require 'activehook/client' 115 | 116 | # ActiveHook client configuration 117 | ActiveHook.configure do |config| 118 | # Your validation uri for server-side validation 119 | config.validation_uri = 'http://localhost:3000/hooks/validate' 120 | # Your validation token for client-side validation 121 | config.validation_token = ENV['WEBHOOK_TOKEN'] 122 | end 123 | ``` 124 | 125 | If we were using Rails we could then do the following: 126 | 127 | ```ruby 128 | class WebhooksController < ApplicationController 129 | 130 | def create 131 | @webhook = ActiveHook::Recieve.new(request: request) 132 | if @webhook.signature_valid? 133 | #We can now do stuff with the Hash @webhook.payload 134 | end 135 | end 136 | end 137 | ``` 138 | 139 | The signature_valid? method will perform client-side validation. We can also perform server-side validation by doing the following: 140 | 141 | ```ruby 142 | @webhook.server_valid? 143 | ``` 144 | 145 | Using the mixin module for our own classes would go like this: 146 | 147 | ```ruby 148 | require 'activehook/client' 149 | 150 | module MyApp 151 | class Webhook 152 | include ActiveHook::Client::Recieve 153 | 154 | VALIDATION_TOKEN = ENV['WEBHOOK_TOKEN'] 155 | #IMPORTANT! We will go over running the validation server next. 156 | VALIDATION_URI = 'http://myapp.com/hooks/validate' 157 | end 158 | end 159 | ``` 160 | 161 | This would allow us to perform the same validation actions as in our Rails example, except we could use: 162 | 163 | ```ruby 164 | @webhook = MyApp::Webhook.new(request: request) 165 | if @webhook.signature_valid? 166 | #We can now do stuff with the Hash @webhook.payload 167 | end 168 | ``` 169 | 170 | #### Server Validation 171 | 172 | Along with client-side validation, ActiveHook also allows you to setup server-side validation. This utilizes a piece of Rack middleware. 173 | 174 | When a client attempts to validate a webhook, they are sending a message back to your server. The message includes the hooks ID as well as key. These are are then cross-referenced with the server records. If they match, we provide the AOK. 175 | 176 | We set the address that the middleware uses from our config file (application config described above): 177 | 178 | ```ruby 179 | config.validation_path = '/hooks/validate' 180 | ``` 181 | 182 | In Rails, we would add the middleware like this: 183 | 184 | ```ruby 185 | # In config/application.rb 186 | config.middleware.use('ActiveHook::App::Middleware') 187 | ``` 188 | 189 | Or with Rackup files: 190 | 191 | ```ruby 192 | # In config.ru 193 | use ActiveHook::App::Middleware 194 | ``` 195 | 196 | ActiveHook also provides a straight lightweight validation microservice. This simply runs the middleware with Puma on its own. 197 | 198 | $ bundle exec activehook-app -p config/puma.rb -c config/activehook.rb 199 | 200 | We must provide a path to our Puma config file as well as our ActiveHook app config file. Please read more about Puma if you need help with this. 201 | 202 | ## Development 203 | 204 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 205 | 206 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 207 | 208 | ## Contributing 209 | 210 | Bug reports and pull requests are welcome on GitHub at https://github.com/nsweeting/activehook. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 211 | 212 | 213 | ## License 214 | 215 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 216 | --------------------------------------------------------------------------------