My First Rails Application - AllergyFree Meals

Posted by agdavid on May 19, 2016

My first Rails application is AllergyFree Meals (AFM), a web application that helps you find delicious and healthy recipes contributed by a community of allergy-conscious users. All code is publicly available at GitHub. Checkout a video walkthrough of AFM above, and overview of the development process below. Enjoy!

Inspiration for AllergyFree Meals Cooking is one of the daily experiences I really enjoy - it is family-centric, relaxing, but also builds up with excitment to the point of eating! However, with a sibling managing celiac disease and a nephew severely allergic to five of the “big eight” allergens, I am also trying to make sure that dining experiences as a family are always inclusive and allergy conscious. AFM aims to help us eat smart, eat delicious, but also eat allergy-free.

Key Database Relationships AFM began as a simple application, but as the features I wanted to create grew, it blossomed into a database with nine (9) tables, including four (4) join tables. The key join-relationships are outlined below:

Items - Ingredients - Recipes This is the heart of the application, managing the feature that most users might look for - the ability to create a recipe. A Recipe and Item are connected through the join-table of Ingredients, and the join-table contains the extra attribute of “quantity”. As explained further in “Highlights of Coding Challenges” (below), this relationship led to a deeply-nested form and required a custom writer in the Recipe model to handle Ingredients and Items when instantiating a new Recipe. The model relationships were as follows:

class Recipe < ActiveRecord::Base
  has_many :ingredients
  has_many :items, through: :ingredients
  accepts_nested_attributes_for :ingredients, allow_destroy: true
  accepts_nested_attributes:for :items
end
class Ingredient < ActiveRecord::Base
  belongs_to :recipe
  belongs_to :item 
  accepts_nested_attributes_for :item
end
class Item < ActiveRecord::Base
  has_many :ingredients
  has_many :recipes, through: :ingredients
end

Users - FavoriteRecipes - Recipes This relationship creates a higher-fidelity experience by allowing a user to save recipes to a collection, creating a virtual “recipe-box” to track their favorite meals. The ActiveRecord relationships use terminology that evokes the relationship between a user and recipe. For example, to get a list of a user’s favorite recipes call @user.favorites and to get a list of users that added a recipe to their favorites call @recipe.favorited_by. The model relationships were as follows:

class User < ActiveRecord::Base
  has_many :recipes
  has_many :favorite_recipes
  has_many :favorites, through: :favorite_recipes, source: :recipe
end
class FavoriteRecipe < ActiveRecord::Base
  belongs_to :recipe 
  belongs_to :user
end
class Recipe < ActiveRecord::Base
  belongs_to :user
  has_many :favorite_recipes
  has_many :favorited_by, through: :favorite_recipes, source: :user 
end

Allergens - RecipeAllergens - Recipes This relationship powers the key search feature which allows a user to filter recipes by allergen - allowing you to declare which allergens to avoid in a recipe. The model relationships were as follows:

class Allergen < ActiveRecord::Base
  has_many :recipe_allergens
  has_many :recipes, through: :recipe_allergens
end
class RecipeAllergen < ActiveRecord::Base
  belongs_to :recipe 
  belongs_to :allergen 
end
class Recipe < ActiveRecord::Base
  has_many :recipe_allergens
  has_many :allergens, through: :recipe_allergens
end

Highlights of Coding Challenges

1.Overlaying Omniauth on Top of Devise and Adding Custom Attributes to Devise User. The Devise gem provides incredible user authentication (i.e., sign-up, login, logout) out-of-the-box, but requires some coding acrobatics when overlaying Omniauth gem authentication and adding custom attributes to a the Devise User table. Providing Omniauth authentication means addressing additional issues of avoiding: (a) the required Devise password when authenticating using Omniauth; and (b) the Devise password confirmation when updating a User profile. On the Omniauth issue, the RailsCast beautifully addresses these Omniauth issues. On the custom attributes issue, the code snippets below show how I “sanitized” custom attributes by adding them to the Devise User strong parameters:

class ApplicationController < ActionController::Base
  before_filter :configure_permitted_parameters, if: :devise_controller?

  protected

    def configure_permitted_parameters
      devise_parameter_sanitizer.permit(:sign_up) { |u| u.permit(:email, :password, :password_confirmation, :remember_me, :name, :provider, :uid) }
      devise_parameter_sanitizer.permit(:account_update) { |u| u.permit(:email, :password, :password_confirmation, :current_password, :name, { allergen_ids: [] }, :provider, :uid, :image, :motto, :admin) }
    end
end

2.Finding or Creating a User Instance with Omniauth. In using Omniauth, I wanted the alternative Facebook-authentication to be in addition to the Devise authentication, in case a user had previously signed-up with the default Devise process. This required a class method in the User model that could correctly locate an existing user instance by a previously existing attribute (in this case, email) and add the Omniauth attributes (provider and uid), rather than creating a new user instance. The class method was as follows:

class User < ActiveRecord::Base
  def self.find_or_create_from_omniauth(auth_hash)
    where(email: auth_hash[:info][:email]).first_or_create do |user|
      #set the remaining attributes
      user.name = auth_hash[:info][:name] 
      user.provider = auth_hash[:provider] 
      user.uid = auth_hash[:uid] 
    end
  end
end

3.Search Recipes by Allergen. One of the key features of AFM is the ability to search all recipes and filter by allergens. This was a fun use of the array “set intersection” method by which you return a new array of overlapping elements. The approach I took was to: (a) iterate over all recipes and find the “set intersection” of recipe-allergens with the array of filtered-allergens; (b) compare the “set intersection” to the filtered-allergens; and (c) if the “set intersection” matched the filtered-allergens then push the recipe into a new collection of “matched recipes”. The class method I coded is below:

class Recipe < ActiveRecord::Base
  def self.match_allergens(search_allergen_ids)
    search_allergen_ids = search_allergen_ids.collect { |id| id.to_i }
    matched_recipes = []
    self.all.each do |recipe|
      intersect_ids = (recipe.allergen_ids & search_allergen_ids)  
      matched_recipes << recipe if search_allergen_ids.sort == intersect_ids.sort
    end
    matched_recipes
  end
end

4.Custom Writer for Deeply Nested Form (Two-levels of Nesting). As noted above in “Key Database Relationships”, the heart of the application is the many-to-many relationship between Recipe and Items through the join-table Ingredients. The “new recipe form” encapsulates this relationship in a nested form with two-levels deep of nesting: (1) the ingredient, associated with a recipe; and (2) the item, associated with the ingredient. I was able to make good use of the Cocoon gem to create a dynamic nested form using jQuery. However, the challenge was properly parsing the params hash to properly create the new or update the existing recipe in the Recipe model and then: (a) create or update the ingredient; and (c) find or create the item (i.e., look for an existing “Salt” item, if it already exists, rather than making a duplicate). The custom writer method for the ingredients-attributes= I coded is below:

class Recipe < ActiveRecord::Base
  def ingredients_attributes=(params)
    params.each do |i, ingredient|
      # Avoid duplicating existing ingredient, if recipe is being updated
      if ingredient[:id]
        @ingredient = Ingredient.find(ingredient[:id])
      else
        @ingredient = self.ingredients.build
      end 

      # Set other ingredient attributes of amount and recipe_id
      @ingredient.amount = ingredient[:amount]
      @ingredient.recipe_id = self.id
      
      # Avoid duplicating existing item with find_or_initialize
      @item =  Item.find_or_initialize_by(name: ingredient[:item_attributes][:name].downcase.capitalize)
      @item.save

      @ingredient.item_id = @item.id 
      @ingredient.save
    end
  end
end

Overall, this was a great learning experience for using Rails to build a feature-rich application for the user. I can’t wait to build on this knowledge with increased user-interaction. Up next: Javascript!