diff --git a/Gemfile b/Gemfile index 8e06226..61b458f 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,7 @@ gem 'puma', '~> 4.1' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder gem 'jbuilder', '~> 2.7' # Use Redis adapter to run Action Cable in production -# gem 'redis', '~> 4.0' +gem 'redis', '~> 4.0' # Use Active Model has_secure_password gem 'bcrypt', '~> 3.1.7' gem 'jwt' diff --git a/Gemfile.lock b/Gemfile.lock index 9461f08..6d6dce7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -217,6 +217,7 @@ DEPENDENCIES puma (~> 4.1) rack-cors rails (~> 6.0.3, >= 6.0.3.4) + redis (~> 4.0) rspec-rails (~> 4.0.1) rubocop sidekiq diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index e1ba1d2..cef125b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,68 +1,86 @@ # frozen_string_literal: true class EventsController < ApplicationController - before_action :authorize_request - before_action :set_user - # Needs to find all events based on bridge_id or event_id - # Needs to return id for each event as well - # def index - # events = @current_user.bridges - # events.map! do |event| - # updated_at = String(event.updated_at) - # date = date_format(updated_at.split(' ')[1]) - # time = updated_at.split(' ')[0] - # { id: 1, - # time: time, - # date: date, - # status_code: event.status_code } - # end - # render json: { events: events }, status: 200 # OK - # end + # before_action :authorize_request + before_action :set_events, only: :index + before_action :set_event, only: %i[show destroy] - def show; end + def index + render json: @events, status: 200 + rescue ActiveRecord::RecordInvalid + render json: { error: 'neither event_id nor bridge_id were valid' }, status: 400 # Bad Request + rescue ActiveRecord::RecordNotFound + render json: { error: 'events matching that id were not found' }, status: 400 + rescue ActiveRecord::NotNullViolation + render json: { error: 'neither event_id nor bridge_id were not submitted' }, status: 400 # Bad Request + end + + def show + render json: @event, status: 200 + rescue ActiveRecord::RecordNotFound + render json: { error: 'an event by that id was not found' }, status: 400 + end + + def destroy + @event.destroy + end - # receive bridge id + data def create - bridge = Bridge.find(params[:id]) - data = { inbound: request.body, outbounds: [] } - event = Event.new(data: data, bridge_id: bridge.id) - if event.save - # EventWorker.perform(id of event just created) - status 201 # Created - else - status 400 # Bad Request - end + event = create_event_object(create_data_object, find_bridge) + event.save! + EventWorker.perform_async(event.id) + render json: {}, status: 202 # Accepted (asynchronous processing) + rescue ActiveRecord::RecordNotFound + render json: { error: 'a bridge by that id was not found' }, status: 400 + rescue ActiveRecord::RecordInvalid + render json: { error: 'payload, bridge_id, or urls were invalid' }, status: 400 # Bad Request + rescue ActiveRecord::NotNullViolation + render json: { error: 'payload, bridge_id, or urls fields were not submitted' }, status: 400 # Bad Request end private - def date_format(_date) - year = time.split('-')[0] - month = time.split('-')[1] - day = time.split('-')[2] - "#{year}-#{month}-#{day}" + def event_params + params.permit(:id, :bridge_id, :event_id, :test) end - def set_user - # bridge_id or #event_id - # => User + def create_data_object + { 'inbound' => { + 'payload' => JSON.parse(request.body.read), + 'dateTime' => DateTime.now.utc, + 'ip' => request.ip, + 'contentLength' => request.content_length + }, + 'outbound' => [] } end -end -# Step 1 -# Convert binary to jsonb + def create_event_object(data, bridge) + Event.new( + data: data.to_json, + bridge_id: bridge.id, + test: event_params[:test] || false + ) + end -# Step 2 -# payload: -{ - test: 'user entered string from editor', - production: 'user entered string from editor' -} + def set_events + @events = if event_params[:bridge_id] + Event.where(bridge_id: event_params[:bridge_id]).order(completed_at: :desc).limit(100) + elsif event_params[:event_id] + Event.where(bridge_id: find_event.bridge_id).order(completed_at: :desc).limit(100) + else + raise ActiveRecord::NotNullViolation + end + end + + def set_event + @event = find_event + end -# Step 3 -# + def find_event + Event.find(event_params[:event_id]) + end -# NB: Need to run `bundle exec sidekiq` in separate terminal -# localhost:3000/sidekiq to monitor sidekiq while running -# after turning it on -# => mount Sidekiq::Web => '/sidekiq + def find_bridge + Bridge.find(event_params[:bridge_id]) + end +end diff --git a/app/lib/exceptions/large_status_code.rb b/app/lib/exceptions/large_status_code.rb new file mode 100644 index 0000000..9f552dd --- /dev/null +++ b/app/lib/exceptions/large_status_code.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Sidekiq + class LargeStatusCode < StandardError + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 42759bc..c1bd581 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,7 +1,40 @@ # frozen_string_literal: true +BOOLEAN = [true, false].freeze + class Event < ApplicationRecord - validates :outbound_url, presence: true + before_validation :set_urls + validates :test, inclusion: [true, false] + validates :completed, inclusion: [true, false] + validates :status_code, numericality: { greater_than_or_equal_to: 100, less_than_or_equal_to: 599 }, allow_nil: true + validate :completed_at_format + validate :data_json_object belongs_to :bridge + + private + + def set_urls + self.inbound_url = bridge.inbound_url + self.outbound_url = bridge.outbound_url + end + + def data_json_object + data = JSON.parse(self.data) + %w[inbound outbound].all? { |key| data.include?(key) } && + %w[payload dateTime ip contentLength].all? { |key| data['inbound'].include?(key) } || + errors.add( + :data, + 'must include the keys: "inbound", "outbound", + while "inbound" must include the keys "payload", "dateTime", "ip", "contentLength"' + ) + rescue JSON::ParserError, TypeError + errors.add(:data, 'object must be a valid json object') + end + + def completed_at_format + return if completed_at.nil? || completed_at.instance_of?(ActiveSupport::TimeWithZone) + + errors.add(:completed_at, '"completed_at" must be a Time instance if event is completed') + end end diff --git a/app/workers/event_worker.rb b/app/workers/event_worker.rb index 76f0ad6..ffe3340 100644 --- a/app/workers/event_worker.rb +++ b/app/workers/event_worker.rb @@ -1,15 +1,127 @@ # frozen_string_literal: true +require 'net/http' +require_relative '../lib/exceptions/large_status_code' + class EventWorker include Sidekiq::Worker + # attr_accessor :retry_count + + # sidekiq_retry_in { 5 } # TODO: SET RETRY_IN DYNAMICALLY + + SCHEME = 'http://' + + def perform(event_id, retries = 0) + event = Event.find(event_id) + bridge = Bridge.find(event.bridge_id) + execute_request_response_cycle(event, bridge) + complete_event(event) + rescue StandardError => e + binding.pry + save_http_error(event, e) unless e.instance_of?(Sidekiq::LargeStatusCode) + complete_event(event) && return if retries >= bridge.retries + + EventWorker.perform_in(bridge.delay.minutes, event_id, retries + 1) + end + + private + + def set_headers(req, bridge) + bridge.headers.each { |header| req[header['key']] = header['value'] } + end - # takes event id argument - # find event => - # find bridge => - # git payload & outbound url - # => send it + def complete_event(event) + event.completed = true + event.completed_at = Time.now.utc + event.save! + end + + def create_error_response(error) + { + message: error.message + } + end + + def save_http_error(event, error) + response = create_error_response(error) + event_data = JSON.parse(event.data) + + event_data['outbound'].last['response'] = response + event.data = event_data.to_json + event.save + end + + def create_response_object(payload, response) + { + dateTime: DateTime.now.utc, + statusCode: response.code, + message: response.message, + size: response.size, + payload: payload + } + end + + def save_response(event, resp) + resp_code = resp.code.to_i + payload = (resp_code >= 300 ? {} : JSON.parse(resp.body)) + response = create_response_object(payload, resp) + event_data = JSON.parse(event.data) + + event_data['outbound'].last['response'] = response + event.data = event_data.to_json + event.save + raise Sidekiq::LargeStatusCode if resp_code >= 300 + end + + def create_request_object(payload, length) + { + payload: payload, + dateTime: DateTime.now.utc, + contentLength: length + } + end + + def save_request(event, length, payload) + request = create_request_object(payload, length) + event_data = JSON.parse(event.data) + + event_data['outbound'].push({ 'request' => request, 'response' => {} }) + event.data = event_data.to_json + event.save + end + + def prepend_scheme(uri) + return uri if uri.starts_with?('https') + + minus_scheme = uri.split('//').last + "#{SCHEME}#{minus_scheme}" + end + + def generate_http_request(bridge, payload) + method = bridge.method.capitalize + uri = URI(prepend_scheme(bridge.outbound_url)) + http = Net::HTTP.new(uri.host, uri.port) + req = "Net::HTTP::#{method}".constantize.new(uri, 'Content-Type' => 'application/json') + + http.use_ssl = (uri.scheme == 'https') + set_headers(req, bridge) + req.body = payload.to_json + [http, req] + end + + def extract_payload(bridge, event) + if event.test + JSON.parse(bridge.data['test_payload']) + else + JSON.parse(bridge.data['payload']) + end + end - def perform(event_id) - # do something + def execute_request_response_cycle(event, bridge) + payload = extract_payload(bridge, event) + http, req = generate_http_request(bridge, payload) + save_request(event, req.length, payload) + response = http.request(req) + save_response(event, response) end end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 0000000..eb1f6b4 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,12 @@ +class SidekiqMiddleware + def call(worker, job, _queue) + worker.retry_count = job['retry_count'] if worker.respond_to?(:retry_count) + yield + end +end + +Sidekiq.configure_server do |config| + config.server_middleware do |chain| + chain.add SidekiqMiddleware + end +end diff --git a/config/routes.rb b/config/routes.rb index 3982662..26b49bf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,16 @@ # frozen_string_literal: true +require 'sidekiq/web' + Rails.application.routes.draw do resource :user, except: %i[new edit] resources :bridges resources :headers, :environment_variables, only: :destroy post 'login', to: 'sessions#create' + post 'events', to: 'events#create' get 'events', to: 'events#index' + get 'events/:event_id', to: 'events#show' + delete 'events/:event_id', to: 'events#destroy' + mount Sidekiq::Web => '/sidekiq' end diff --git a/db/dump.rdb b/db/dump.rdb new file mode 100644 index 0000000..5d95a9c Binary files /dev/null and b/db/dump.rdb differ diff --git a/db/migrate/20201111175121_change_binary_columns_to_json_b.rb b/db/migrate/20201111170705_change_binary_columns_to_json_b.rb similarity index 68% rename from db/migrate/20201111175121_change_binary_columns_to_json_b.rb rename to db/migrate/20201111170705_change_binary_columns_to_json_b.rb index 78254ec..0e9cb74 100644 --- a/db/migrate/20201111175121_change_binary_columns_to_json_b.rb +++ b/db/migrate/20201111170705_change_binary_columns_to_json_b.rb @@ -1,6 +1,11 @@ class ChangeBinaryColumnsToJsonB < ActiveRecord::Migration[6.0] - def change + def up change_column :bridges, :payload, :jsonb, using: 'payload::text::jsonb', null: false change_column :events, :data, :jsonb, using: 'data::text::jsonb', null: false end + + def down + change_column :bridges, :payload, :text + change_column :events, :data, :text + end end diff --git a/db/migrate/20201111174614_remove_default_attribute.rb b/db/migrate/20201111174614_remove_default_attribute.rb new file mode 100644 index 0000000..5975a97 --- /dev/null +++ b/db/migrate/20201111174614_remove_default_attribute.rb @@ -0,0 +1,5 @@ +class RemoveDefaultAttribute < ActiveRecord::Migration[6.0] + def change + change_column_default :bridges, :payload, nil + end +end diff --git a/db/migrate/20201111195349_remove_not_null_status_code_in_events.rb b/db/migrate/20201111195349_remove_not_null_status_code_in_events.rb new file mode 100644 index 0000000..790e3db --- /dev/null +++ b/db/migrate/20201111195349_remove_not_null_status_code_in_events.rb @@ -0,0 +1,5 @@ +class RemoveNotNullStatusCodeInEvents < ActiveRecord::Migration[6.0] + def change + change_column_null :events, :status_code, true + end +end diff --git a/db/migrate/20201113001336_add_test_to_events.rb b/db/migrate/20201113001336_add_test_to_events.rb new file mode 100644 index 0000000..37e8280 --- /dev/null +++ b/db/migrate/20201113001336_add_test_to_events.rb @@ -0,0 +1,5 @@ +class AddTestToEvents < ActiveRecord::Migration[6.0] + def change + add_column :events, :test, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 7ee69a3..540210e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_11_12_094842) do +ActiveRecord::Schema.define(version: 2020_11_13_001336) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -44,11 +44,12 @@ t.jsonb "data", null: false t.string "inbound_url", null: false t.string "outbound_url", null: false - t.integer "status_code", null: false + t.integer "status_code" t.datetime "completed_at" t.bigint "bridge_id", null: false t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.boolean "test", default: false, null: false t.index ["bridge_id"], name: "index_events_on_bridge_id" end diff --git a/db/seeds.rb b/db/seeds.rb index bc30a3e..f2a9189 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -6,13 +6,14 @@ def test_url user2 = User.create(email: 'tester@bridge.io', password: 'password', notifications: false) bridge = Bridge.create( - user: user, - title: 'My First Bridge', - outbound_url: test_url, - method: 'POST', - retries: 5, + user_id: user.id, + title: 'My First Bridge', + inbound_url: 'bridgeapi.com/249634', + outbound_url: 'ip.jsontest.com', + method: 'POST', + retries: 5, delay: 15, - data: { payload: {}, test_payload: {} } + data: { payload: "{\"FirstName\":\"Lee\",\"LastName\":\"Oswald\",\"UserName\":\"GrassyKnoll\",\"Password\":{\"nested\":\"magic bullet\"},\"Email\":\"kgb63@yandex.ru\"}", test_payload: "{\"test_key_one\":\"testerstring\",\"test_key_two\":[\"stringinarray\"]}" } ) bridge.environment_variables << EnvironmentVariable.create(key: 'database', value: 'a102345ij2') @@ -21,13 +22,14 @@ def test_url bridge.headers << Header.create(key: 'Authentication', value: 'Bearer &&&&&&&&&&&&&&&&') bridge2 = Bridge.create( - user: user2, - title: 'My Second Bridge', - outbound_url: test_url, - method: 'PATCH', - retries: 0, + user_id: user2.id, + title: 'My Second Bridge', + inbound_url: 'bridgeapi.com/746353', + outbound_url: test_url, + method: 'PATCH', + retries: 3, delay: 0, - data: { payload: {}, test_payload: {} } + data: { payload: "{\"FirstName\":\"Booths\",\"LastName\":\"John\",\"UserName\":\"FordTheatre\",\"Password\":{\"nested\":\"sic temper tyrannis\"},\"Email\":\"mail@mail.com\"}", test_payload: "{\"test_key_one\":{\"nested\":11},\"test_key_two\":888}" } ) bridge2.environment_variables << EnvironmentVariable.create(key: 'database', value: 'z9992374623') @@ -35,7 +37,7 @@ def test_url bridge2.headers << Header.create(key: 'X_API_KEY', value: 'returntheslab') bridge2.headers << Header.create(key: 'Authentication', value: 'Bearer *************') -5.times do - bridge.events << Event.create(completed: false, outbound_url: bridge.outbound_url, inbound_url: test_url, data: '', status_code: 300) - bridge2.events << Event.create(completed: false, outbound_url: bridge2.outbound_url, inbound_url: test_url, data: '', status_code: 302) -end +# 5.times do +# bridge.events << Event.create(completed: false, outbound_url: bridge.outbound_url, inbound_url: 'bridgeapi.com/249634', data: '', status_code: 300) +# bridge2.events << Event.create(completed: false, outbound_url: bridge2.outbound_url, inbound_url: 'bridgeapi.com/746353', data: '', status_code: 302) +# end diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000..7f3083b Binary files /dev/null and b/dump.rdb differ diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 86f63dd..83c7c57 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -3,33 +3,73 @@ require 'rails_helper' RSpec.describe Event, type: :model do - before do + before(:context) do create_user end subject do - create_bridge + create_event end - it 'belongs to bridge' do - event1 = Event.create( - completed: false, - outbound_url: subject.outbound_url, - inbound_url: subject.inbound_url, - data: '', - status_code: 300 - ) - event2 = Event.create( - completed: false, - outbound_url: subject.outbound_url, - inbound_url: subject.inbound_url, - data: '', - status_code: 300 + it 'is valid as long as long as it has a valid bridge_id and data object' do + event = Event.create({ bridge_id: subject.bridge_id, data: subject.data }) + expect(event).to be_valid + end + + it 'is invalid without a data object' do + subject.data = nil + expect(subject).to_not be_valid + end + + it 'can be converted to side_bar format' do + expect(subject.sidebar_format).to match( + id: subject.id, + time: a_string_matching(/\d{1,2}:\d{1,2}:\d{1,2}/), + date: a_string_matching(/\d{4}-\d{1,2}-\d{1,2}/), + status_code: an_instance_of(Integer) ) + end + + it 'belongs to a bridge' do + expect(subject.bridge.class).to eql Bridge + end + + it 'has its urls set to the url of the bridge it belongs to' do + bridge = subject.bridge + expect(bridge.inbound_url).to eql subject.inbound_url + expect(bridge.outbound_url).to eql subject.outbound_url + end + + it 'has a data attribute referencing a json object' do + expect(JSON.parse(subject.data)).to be_an_instance_of(Hash) + end + + it 'raises an error if data attribute does not reference a JSON object' do + subject.data = 'not a json object' + expect { subject.save! }.to raise_error('Validation failed: Data object must be a valid json object') + end + + it 'does not accept removing inbound or outbound key from json data object' do + data = JSON.parse(subject.data) + data.delete('outbound') + subject.data = data.to_json + expect { subject.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'does not accept removing payload, date, time, ip or content length from inbound key in json data object' do + data = JSON.parse(subject.data) + data['inbound'].delete('payload') + subject.data = data.to_json + expect { subject.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'does not accept status codes above 599' do + subject.status_code = 5555 + expect { subject.save! }.to raise_error('Validation failed: Status code must be less than or equal to 599') + end - subject.events << event1 - subject.events << event2 - expect(event1.bridge).to eq subject - expect(event2.bridge).to eq subject + it 'does not accept status codes below 100' do + subject.status_code = 10 + expect { subject.save! }.to raise_error('Validation failed: Status code must be greater than or equal to 100') end end diff --git a/spec/requests/events_request.spec.rb b/spec/requests/events_request.spec.rb new file mode 100644 index 0000000..38f5cd6 --- /dev/null +++ b/spec/requests/events_request.spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.describe 'Events', type: :request do + before(:context) do + create_event + end + + after(:context) do + destroy_event + end +end diff --git a/spec/support/main_helper.rb b/spec/support/main_helper.rb index 98a45ce..5efb8d2 100644 --- a/spec/support/main_helper.rb +++ b/spec/support/main_helper.rb @@ -1,8 +1,15 @@ # frozen_string_literal: true +def random_email + local = [] + 8.times { |_| local.push(('a'..'z').to_a.sample) } + domain = ['aol.com', 'nsa.gov', 'jubii.dk', 'kreml.ru', 'hotmail.com', 'netscape.com'][rand(6)] + "#{local.join('')}@#{domain}" +end + module MainHelper def create_user - @current_user = User.create(email: 'admin@bridge.io', password: 'password', notifications: false) + @current_user = User.create(email: random_email, password: 'password', notifications: false) @token = JsonWebToken.encode(user_id: @current_user.id) end @@ -31,4 +38,60 @@ def create_bridge **bridge_hash ) end + + def event_data + { + 'inbound' => { + 'payload' => { + 'FirstName' => 'Lee', + 'LastName' => 'Oswald', + 'UserName' => 'GrassyKnoll', + 'Password' => { 'nested' => 'magic bullet' }, + 'Email' => 'kgb63@yandex.ru' + }, + 'date' => '2020-11-17', + 'time' => '03:23:35', + 'ip' => '::1', + 'content_length' => 152 + }, + 'outbound' => [ + { 'request' => { + 'payload' => { + 'FirstName' => 'Lee', + 'LastName' => 'Oswald', + 'UserName' => 'GrassyKnoll', + 'Password' => { 'nested' => 'magic bullet' }, + 'Email' => 'kgb63@yandex.ru' + }, + 'date' => '2020-11-17', + 'time' => '03:23:35', + 'content_length' => 7 + }, + 'response' => { + 'date' => '2020-11-17', + 'time' => '03:23:35', + 'status_code' => '200', + 'message' => 'OK', + 'size' => 7, + 'payload' => { 'ip' => '153.33.111.24' } + } } + ] + }.to_json + end + + def create_event + @bridge = create_bridge + @bridge.save + @event = Event.create({ + bridge_id: @bridge.id, + data: event_data, + status_code: 200, + completed: true, + completed_at: Time.now.utc + 30 + }) + end + + def destroy_event + @event.destroy! + end end