sosek sosek - 7 months ago 25
Ruby Question

Where to put given logic in rails?

I'm writing an application where user enters a date, and then the system fetches the historical weather data for that week (I assume that Wednesday is representative for the whole week) from an external API. For certain reasons, I don't want to do live calls for each date - I want to fetch it once and persist on-site.

In Spring, I'd put most of it into a service layer. Since I am new to Rails, I am not sure where to put certain logic, but here's my proposal:

WeatherController

def create
transform date entered by user to Wednesday of the same week.
Check if there is a already record for that date, if not, fetch the JSON from external API.
Parse JSON to Ruby object, save.
Return the weather data.


WeatherModel

validate if the date is indeed Wednesday
validate if entered date is unique

Answer

Generally, I wouldn't put the logic in a create action. Even though you're creating something, the user of your site is really asking you to show the weather. The user should be oblivious to where you're bringing the info from and how you're caching it.

Option 1 - Use Rails Caching

One option is to use Rails caching in the show action. Right in that action you will do a blocking call to the API, and then Rails will store the return value in the cache store (e.g. Redis).

def show
  date = Date.parse params[:date]
  @info_to_show = Rails.cache.fetch(cache_key_for date) do
    WeatherAPIFetcher.fetch(date)
  end
end

private

def cache_key_for(date)
  "weather-cache-#{date + (3 - date.wday)}"
end

Option 2: Non-blocking calls with ActiveJobs

Option 1 above will make accessing the data you already accumulated somewhat awkward (e.g. for statistics, graphs, etc). In addition, it blocks the server while you are waiting for a response from the API endpoint. If these are non-issues, you should consider option 1, as it's very simple. If you need more than that, below is a suggestion for storing the data you fetch in the DB.

I suggest a model to store the data and an async job that retrieves the data. Note you'll need to have ActiveJob set up for the WeatherFetcherJob.

# migration file
create_table :weather_logs do |t|
  t.datetime :date

  # You may want to use an enumerized string field `status` instead of a boolean so that you can record 'not_fetched', 'success', 'error'.
  t.boolean :fetch_completed, default: false
  t.text :error_message
  t.text :error_backtrace

  # Whatever info you're saving

  t.timestamps
end
add_index :weather_logs, :date

# app/models/weather_log.rb

class WeatherLog
  # Return a log record immediately (non-blocking).
  def self.find_for_week(date_str)
    date = Date.parse(date_str)
    wednesday_representative = date + (3 - date.wday)
    record = find_or_create_by(date: wednesday_representative)
    WeatherFetcherJob.perform_later(record) unless record.fetch_completed
    record
  end
end

# app/jobs/weather_fetcher_job.rb

class WeatherFetcherJob < ActiveJob::Base
  def perform(weather_log_record)
    # Fetch from API
    # Update the weather_log_record with the information
    # Update the weather_log_record's fetch_completed to true
    # If there is an error - store it in the error fields.
  end
end

Then, in the controller you can rely on whether the API completed to decide what to display to the user. These are broad strokes, you'll have to adapt to your use case.

# app/controllers/weather_controller
def show
  @weather_log = WeatherLog.find_for_week(params[:date])
  @show_spinner = true unless @weather_log.fetch_completed
end

def poll
  @weather_log = WeatherLog.find(params[:id])
  render json: @weather_log.fetch_completed
end

# app/javascripts/poll.js.coffee

$(document).ready ->
  poll = -> 
    $.get($('#spinner-element').data('poll-url'), (fetch_in_progress) ->
      if fetch_in_progress
        setTimeout(poll, 2000)
      else
        window.location = $('#spinner-element').data('redirect-to')
    )
  $('#spinner-element').each -> poll()

# app/views/weather_controller.rb
...
<% if @show_spinner %>
    <%= content_tag :div, 'Loading...', id: 'spinner-element', data: { poll_url: poll_weather_path(@weather_log), redirect_to: weather_path(@weather_log) } %>
<% end %>
...