How to properly use Sidekiq to process background tasks in Rails - ruby-on-rails

So, i've generated a rails app using https://github.com/Shopify/shopify_app - and for the most part have the app working as intended - it's goal is to get product quantities from an external stock management API, and then update the variant quantities in Shopify with the latest quantities from that stock management system.
My problem is that the initial POST request to the external API responds with a large number of products - this takes upwards of 15 seconds sometimes. In addition to this, another portion of my app then takes this response, and for every product in the response that also exists in Shopify, it will make a PUT request to Shopify to update the variant quantities. As with the initial request, this also takes upwards of 10-15 seconds.
My problem is that i'm hosting the app on Heroku, and as a result i've hit their 30 second request timeout limit. As a result I need to use a background worker to offset at least one of the requests above (perhaps both) to a worker queue. I've gone with the widely recommended Sidekiq gem - https://github.com/mperham/sidekiq - which is easy enough to set up.
My problem is that I don't know how to get the results from the finished Sidekiq worker job, and then use that again within the Controller - I also don't know if this is best practice (i'm a little new to Rails/App development).
I've included my controller (prior to breaking it down into workers) that currently runs the app below - I guess I just need some advice - am I doing this correctly - should some of this logic be inside a Model, and if so how would that model then communicate with the Controller, and then how would Sidekiq then fit into all of it.
Appreciate any advice or assistance, thanks.
class StockManagementController < ShopifyApp::AuthenticatedController
require 'uri'
require 'net/http'
require 'json'
require 'nokogiri'
require 'open-uri'
require 'rexml/document'
def new
#token = StockManagementController.new
end
def get_token
url = URI('https://external.api.endpoint/api/v1/AuthToken')
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
#HEROKU_ENV_USERNAME = ENV['HEROKU_ENV_USERNAME']
#HEROKU_ENV_PASSWORD = ENV['HEROKU_ENV_PASSWORD']
request = Net::HTTP::Post.new(url)
request['content-type'] = 'application/x-www-form-urlencoded'
request['cache-control'] = 'no-cache'
request.body = 'username=' + #HEROKU_ENV_USERNAME + '&password=' + #HEROKU_ENV_PASSWORD + '&grant_type=password'
response = http.request(request)
responseJSON = JSON.parse(response.read_body)
session[:accessToken] = responseJSON['access_token']
if session[:accessToken]
flash[:notice] = 'StockManagement token generation was successful.'
redirect_to '/StockManagement/product_quantity'
else
flash[:alert] = 'StockManagement token generation was unsuccessful.'
end
end
def product_quantity
REXML::Document.entity_expansion_text_limit = 1_000_000
#theToken = session[:accessToken]
if #theToken
url = URI('https://external.api.endpoint/api/v1/ProductQuantity')
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
request = Net::HTTP::Post.new(url)
request['authorization'] = 'bearer ' + #theToken + ''
request['content-type'] = 'application/xml'
request['cache-control'] = 'no-cache'
response = http.request(request)
responseBody = response.read_body
finalResponse = Hash.from_xml(responseBody).to_json
resultQuantity = JSON.parse finalResponse
#connectionType = resultQuantity['AutomatorResponse']['Type']
#successResponse = resultQuantity['AutomatorResponse']['Success']
#errorResponse = resultQuantity['AutomatorResponse']['ErrorMsg']
productQuantityResponse = resultQuantity['AutomatorResponse']['ResponseString']
xmlResponse = Hash.from_xml(productQuantityResponse).to_json
jsonResponse = JSON.parse xmlResponse
#fullResponse = jsonResponse['StockManagement']['Company']['InventoryQuantitiesByLocation']['InventoryQuantity']
# This hash is used to store the final list of items that we need in order to display the item's we've synced, and to show the number of items we've sycned successfully.
#finalList = Hash.new
# This array is used to contain the available products - this is used later on as a way of only rendering
#availableProducts = Array.new
# Here we get all of the variant data from Shopify.
#variants = ShopifyAPI::Variant.find(:all, params: {})
# For each peace of variant data, we push all of the available SKUs in the store to the #availableProducts Array for use later
#variants.each do |variant|
#availableProducts << variant.sku
end
#Our final list of products which will contain details from both the Stock Management company and Shopify - we will use this list to run api calls against each item
#finalProductList = Array.new
puts "Final product list has #{#fullResponse.length} items."
puts #fullResponse.inspect
# We look through every item in the response from Company
#fullResponse.each_with_index do |p, index|
# We get the Quantity and Product Code
#productQTY = p["QtyOnHand"].to_f.round
#productCode = p["Code"].upcase
# If the product code is found in the list of available products in the Shopify store...
if #availableProducts.include? #productCode
#variants.each do |variant|
if #productCode === variant.sku
if #productQTY != 0
#finalProductList << {
"sku" => variant.sku,
"inventory_quantity" => variant.inventory_quantity,
"old_inventory_quantity" => variant.old_inventory_quantity,
"id" => variant.id,
"company_sku" => #productCode,
"company_qty" => #productQTY
}
end
end
end
end
end
# If we get a successful response from StockManagement, proceed...
if #finalProductList
flash[:notice] = 'StockManagement product quantity check was successful.'
puts "Final product list has #{#finalProductList.length} items."
puts #finalProductList
#finalProductList.each do |item|
#productSKU = item["sku"]
#productInventoryQuantity = item["inventory_quantity"]
#productOldInventoryQuantity = item["old_inventory_quantity"]
#productID = item["id"]
#companySKU = item["company_sku"]
#companyQTY = item["company_qty"]
url = URI("https://example.myshopify.com/admin/variants/#{#productID}.json")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
request = Net::HTTP::Put.new(url)
request["content-type"] = 'application/json'
request["authorization"] = 'Basic KJSHDFKJHSDFKJHSDFKJHSDFKJHSDFKJHSDFKJHSDFKJHSDFKJHSDFKJHSDF'
request["cache-control"] = 'no-cache'
request.body = "{\n\t\"variant\": {\n\t\t\"id\": #{#productID},\n\t\t\"inventory_quantity\": #{#companyQTY},\n\t\t\"old_inventory_quantity\": #{#productOldInventoryQuantity}\n\t}\n}"
# This is the line that actually runs the put request to update the quantity.
response = http.request(request)
# Finally, we populate the finalList has with response information.
#finalList[#companySKU] = ["","You had #{#productOldInventoryQuantity} in stock, now you have #{#companyQTY} in stock."]
end
else
# If the overall sync failed, we flash an alert.
flash[:alert] = 'Quantity synchronisation was unsuccessful.'
end
# Lastly we get the final number of items that were synchronised.
#synchronisedItems = #finalList.length
# We flash this notification, letting the user known how many products were successfully synchronised.
flash[:notice] = "#{#synchronisedItems} product quantities were synchronised successfully."
# We then pretty print this to the console for debugging purposes.
pp #finalList
else
flash[:alert] = #errorResponse
end
end
end

First of all, your product_quantity method is way too long. You should break it into smaller parts. 2nd, http.verify_mode = OpenSSL::SSL::VERIFY_NONE should not be done in production. The example you've provide along with your question are too complex and are therefore difficult to answer. It sounds like you need a basic understanding of design patterns and this is not a specific ruby question.
If your app needs to make realtime API calls inside of a controller this is a poor design. You don't want to keep requests of any kind waiting for more than a couple of seconds at most. You should consider WHY you need to make these requests in the first place. If it's data you need quick access to, you should write background jobs to scrape the data on a schedule and store it in your own database.
If a user of your app makes a request which needs to wait for the API's response, you could write a worker to handle fetching the API data and eventually send a response to the user's browser probably using actioncable.
For your constant definitions you probably should do this in an initializer wihich you would keep in my_app_root/config/initializers/constants.rb which get loaded into your app at runtime. You could just call them where need using te ENV[] syntax but if you prefer simpler constants drop the # since that naming convention in ruby is for instance objects.
#app_root/config/initializers/constants.rb
HEROKU_ENV_USERNAME = ENV['HEROKU_ENV_USERNAME']
HEROKU_ENV_PASSWORD = ENV['HEROKU_ENV_PASSWORD']

Related

Why are records not being stored to the db when testing with VCR?

I have an interactor foo with method perform on it which makes an API call, and records the response through ActiveRecord. It works fine. I have a spec for it that triggers the method with FactoryBot data, then checks the db for the expected records. This also works fine. However, when I wrap the call in a VCR.use_cassette, the call is made (and resulting cassette created), but the db records don't seem to be written. What am I missing?
spec looks like this:
it 'should do a thing' do
bar = FactoryGirl.create(:bar)
VCR.use_cassette('foo/cassette') do
MyInteractor.perform(bar)
end
record = BarRecord.find_by(bar_id: bar.id)
expect(record.status.to_sym).to be(:success)
end
perform method looks roughly like this:
def perform(bar)
uri = URI.parse("<url>")
req = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
req.basic_auth Settings.username, Settings.password
req['Accept'] = 'Application/json'
req.body = post_params.to_json
https = Net::HTTP.new(uri.host, uri.port)
https.use_ssl = true
res = https.request(req)
record = BarRecord.new(bar_id: bar.id)
record.status = JSON.parse(res.body)["status"]
record.save!
record
end
BarRecord should be created by the call to perform. Without VCR it is.
For the curious, there was in fact nothing wrong with VCR. I was storing items with a fixed timestamp, and then reading them with a dynamic one that didn't overlap. Full pebkac.

Rails.cache.fetch with expires_in only expire if new value is not nil

I want to do a simple Rails.cache.fetch and expire it after about 10 minutes. The cache gets filled with json data from an external API. However the external API is not reachable sometimes. So when the cache expires and tries to fetch new json data, the cache content gets invalid.
How can I make Rails.cache.fetch ONLY EXPIRE the cache if the fetch_json returns valid data? However the cache should expire after 10 minutes if it receives new valid data.
This is how I tried to do it, but it does not work. Better Code highlighting in this Gist: https://gist.github.com/i42n/6094528
Any tips how I can get this work?
module ExternalApiHelper
require 'timeout'
require 'net/http'
def self.fetch_json(url)
begin
result = Timeout::timeout(2) do # 2 seconds
# operation that may cause a timeout
uri = URI.parse(url)
http = Net::HTTP.new(uri.host, uri.port)
request = Net::HTTP::Get.new(uri.request_uri)
response = http.request(request)
return JSON.parse(response.body)
end
return result
rescue
# if any error happens in the block above, return empty string
# this will result in fetch_json_with_cache using the last value in cache
# as long as no new data arrives
return ""
end
end
def self.fetch_json_with_cache(url, expire_time)
cache_backup = Rails.cache.read(url)
api_data = Rails.cache.fetch(url, :expires_in => expire_time) do
new_data = fetch_json(url)
if new_data.blank?
# if API fetch did not return valid data, return the old cache state
cache_backup
else
new_data
end
end
return api_data
end
end
Solved it like this. Maybe not the most elegant way to do it, but works:
https://github.com/ReliveRadio/reliveradio-website/blob/4874cf4158361c73a693e65643d9e7f11333d9d6/app/helpers/external_api_helper.rb

How to make a PUT request from a Rails model to controller?

I want to update model via PUT request in Rails app. What would be the best way to do that?
Basically I have:
def method
...
#relation = Relation.find(34)
#relation.name = "new_name"
#relation.save
end
This gives me errors in SQLite ("cannot start a transaction within a transaction").
Switching to put/post should I guess save the problem.. What would be the right way to do it?
So after some time, I actually found the solution. Here is the code for the Resque worker, that updates the Relation model via PUT. Using this method I don't get SQLite busy exception errors.
class VideoCollector
def self.perform(rel_id)
#relation = Relation.find_by_id(rel_id)
#url = Rails.application.routes.url_helpers.relation_url(#relation)
#uri = URI(#url)
#body ={"collected" => "true"}.to_json
request = Net::HTTP::Put.new(#uri.path, initheader = {'Content-Type' =>'application/json'})
request.body = #body
response = Net::HTTP.new(#uri.host, #uri.port).start {|http| http.request(request) }
end
end
Maybe that will be useful to someone.

Problems with MailChimp API in Ruby Error Code: -90

I am using the following code in my MailChimp Controller to submit simple newsletter data. When It is sent I receive the following error as a "Method is not exported by this server -90" I have attached my controller code below. I am using this controller for a simple newsletter signup form. (Name, Email)
class MailchimpController < ApplicationController
require "net/http"
require "uri"
def subscribe
if request.post?
mailchimp = {}
mailchimp['apikey'] = 'f72328d1de9cc76092casdfsd425e467b6641-us2'
mailchimp['id'] = '8037342dd1874'
mailchimp['email_address'] = "email#gmail.com"
mailchimp['merge_vars[FNAME]'] = "FirstName"
mailchimp['output'] = 'json'
uri = URI.parse("http://us2.api.mailchimp.com/1.3/?method=listSubscribe")
response = Net::HTTP.post_form(uri, mailchimp)
mailchimp = ActiveSupport::JSON.decode(response.body)
if mailchimp['error']
render :text => mailchimp['error'] + "code:" + mailchimp['code'].to_s
elsif mailchimp == 'true'
render :text => 'ok'
else
render :text => 'error'
end
end
end
end
I highly recommend the Hominid gem: https://github.com/tatemae-consultancy/hominid
The problem is that Net::HTTP.post_form is not passing the "method" GET parameter. Not being a big ruby user, I'm not certain what the actual proper way to do that with Net::HTTP is, but this works:
require "net/http"
data="apikey=blahblahblah"
response = nil
Net::HTTP.start('us2.api.mailchimp.com', 80) {|http|
response = http.post('/1.3/?method=lists', data)
}
p response.body
That's the lists() method (for simplicity) and you'd have to build up (and urlencode your values!) your the full POST params rather than simply providing the hash.
Did you take a look at the many gems already available for ruby?
http://apidocs.mailchimp.com/downloads/#ruby
The bigger problem, and main reason I'm replying to this, is that your API Key is not obfuscated nearly well enough. Granted I'm used to working with them, but I was able to guess it very quickly. I would suggest immediately going and disabling that key in your account and then editing the post to actually have completely bogus data rather than anything close to the correct key. The list id on the other hand, doesn't matter at all.
You'll be able to use your hash if you convert it to json before passing it to Net::HTTP. The combined code would look something like:
mailchimp = {}
mailchimp['apikey'] = 'APIKEYAPIKEYAPIKEYAPIKEY'
mailchimp['id'] = '8037342dd1874'
mailchimp['email_address'] = "email#gmail.com"
mailchimp['merge_vars[FNAME]'] = "FirstName"
mailchimp['output'] = 'json'
response = nil
Net::HTTP.start('us2.api.mailchimp.com', 80) {|http|
response = http.post('/1.3/?method=listSubscribe', mailchimp.to_json)
}

Using OpenUri, how can I get the contents of a redirecting page?

I want to get data from this page:
http://www.canadapost.ca/cpotools/apps/track/personal/findByTrackNumber?trackingNumber=0656887000494793
But that page forwards to:
http://www.canadapost.ca/cpotools/apps/track/personal/findByTrackNumber?execution=eXs1
So, when I use open, from OpenUri, to try and fetch the data, it throws a RuntimeError error saying HTTP redirection loop:
I'm not really sure how to get that data after it redirects and throws that error.
You need a tool like Mechanize. From it's description:
The Mechanize library is used for
automating interaction with websites.
Mechanize automatically stores and
sends cookies, follows redirects, can
follow links, and submit forms. Form
fields can be populated and submitted.
Mechanize also keeps track of the
sites that you have visited as a
history.
which is exactly what you need. So,
sudo gem install mechanize
then
require 'mechanize'
agent = WWW::Mechanize.new
page = agent.get "http://www.canadapost.ca/cpotools/apps/track/personal/findByTrackNumber trackingNumber=0656887000494793"
page.content # Get the resulting page as a string
page.body # Get the body content of the resulting page as a string
page.search(".somecss") # Search for specific elements by XPath/CSS using nokogiri
and you're ready to rock 'n' roll.
The site seems to be doing some of the redirection logic with sessions. If you don't send back the session cookies they are sending on the first request you will end up in a redirect loop. IMHO it's a crappy implementation on their part.
However, I tried to pass the cookies back to them, but I didn't get it to work, so I can't be completely sure that that is all that's going on here.
While mechanize is a wonderful tool I prefer to "cook" my own thing.
If you are serious about parsing you can take a look at this code. It serves to crawl thousands of site on an international level everyday and as far as I have researched and tweaked there isn't a more stable approach to this that also allows you to highly customize later on your needs.
require "open-uri"
require "zlib"
require "nokogiri"
require "sanitize"
require "htmlentities"
require "readability"
def crawl(url_address)
self.errors = Array.new
begin
begin
url_address = URI.parse(url_address)
rescue URI::InvalidURIError
url_address = URI.decode(url_address)
url_address = URI.encode(url_address)
url_address = URI.parse(url_address)
end
url_address.normalize!
stream = ""
timeout(8) { stream = url_address.open(SHINSO_HEADERS) }
if stream.size > 0
url_crawled = URI.parse(stream.base_uri.to_s)
else
self.errors << "Server said status 200 OK but document file is zero bytes."
return
end
rescue Exception => exception
self.errors << exception
return
end
# extract information before html parsing
self.url_posted = url_address.to_s
self.url_parsed = url_crawled.to_s
self.url_host = url_crawled.host
self.status = stream.status
self.content_type = stream.content_type
self.content_encoding = stream.content_encoding
self.charset = stream.charset
if stream.content_encoding.include?('gzip')
document = Zlib::GzipReader.new(stream).read
elsif stream.content_encoding.include?('deflate')
document = Zlib::Deflate.new().deflate(stream).read
#elsif stream.content_encoding.include?('x-gzip') or
#elsif stream.content_encoding.include?('compress')
else
document = stream.read
end
self.charset_guess = CharGuess.guess(document)
if not self.charset_guess.blank? and (not self.charset_guess.downcase == 'utf-8' or not self.charset_guess.downcase == 'utf8')
document = Iconv.iconv("UTF-8", self.charset_guess, document).to_s
end
document = Nokogiri::HTML.parse(document,nil,"utf8")
document.xpath('//script').remove
document.xpath('//SCRIPT').remove
for item in document.xpath('//*[translate(#src, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")]')
item.set_attribute('src',make_absolute_address(item['src']))
end
document = document.to_s.gsub(/<!--(.|\s)*?-->/,'')
self.content = Nokogiri::HTML.parse(document,nil,"utf8")
end

Resources