Maciej Szlosarczyk
Maciej Szlosarczyk

Reputation: 809

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

Upvotes: 1

Views: 598

Answers (3)

AmitA
AmitA

Reputation: 3245

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 %>
...

Upvotes: 1

bennick
bennick

Reputation: 1917

In rails I prefer to create POROs (plan old ruby objects) to handle most of the core logic in my applications. In doing so we can keep our controllers dead simple and our models void of logic that does not pertain to saving data to the database. If you don't work at keeping unnecessary logic out of of our models they will become bloated and extremely hard to test.

The two PORO patterns I use the most are actions and services.

actions normally relate directly to and assist one controller action.

To take your example lets create one. We will create a WeatherCreator class. I like names that are insanely explicit. What does WeatherCreator do you ask? It creates a Weather record, of course!

# app/actions/weather_creator.rb
class WeatherCreator

 attr_reader :weather

 def initialize(args={})
   @date = args.fetch(:date)
   @weather = Weather.new
 end

 def create
   build_record
   @weather.save
 end

 private

 def build_record
   # All of your core logic goes here!
   # Plus you can delegate it out to various private methods in the class
   #
   # 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.
   #
   # Add necessary data to your model in @weather
 end

end

Then in our controller we can use the action class

# app/controllers/weather_controller.rb
class WeatherController < ApplicatonController

  def create
    creator = WeatherCreator.new(date: params[:date])
    if creator.create
      @weather = creator.weather
      render :new
    else
      flash[:success] = "Weather record created!"
      redirect_to some_path
    end
  end

end

Now your controller is stupid simple.

The great benefit of this is that your testing efforts can focus just on the action logic object and it's interface.

# spec/actions/weather_creator_spec.rb
require 'rails_helper'

RSpec.describe WeatherCreator do

  it "does cool things" do
    creator = WeatherCreator.new(date: Time.zone.now)
    creator.create
    expect(creator.weather).to # have cool things
  end

end

service objects on the other hand would live in app/services/. The difference is that these objects are used in many places in an app but the same isolation of logic and testing practices apply.

Depending on your app you can create different types of POROS for various purposes as a general service object category can also grow out of control.

To make things clear you can utilize different naming practices. So we could take the WeatherCreator class and instead call it WeatherCreatorAction or Action::WeatherCreator. Some goes with services SomeLogicService or Service::SomeLogic.

Use whatever suites your preferences and style best. Cheers!

Upvotes: 1

7urkm3n
7urkm3n

Reputation: 6321

I will give you little interesting way to implement easy and interesting way. You can make it like bookmark logic:

For example:

How's bookmark work ? User adds an URL to bookmarks, server saves the data of that bookmark, and when another user tries to add the same URL to bookmark, server not saves URL to bookmark because its duplicated Bookmark. Its just, server finds that bookmark and assigns to that user too. and again again again for all users who tries to add that the same url to bookmark.

Weather:

In your case, all you need is: If user request weather of that city and if you dnt have that data then fetch from api give it to user and save it to DB. and if another will request the same city, now just responding from DB not from 3rd party API. All you need is update the data, when it gets requested.

Upvotes: 0

Related Questions