DEV Community

newAlicorn
newAlicorn

Posted on • Edited on

Fun Drink Recipes App

The Fun Drink Recipe App is built with Ruby on Rails framework. This web application is for drink lovers to find and create ingredients and recipes from cocktails to milkshakes. Users can also comment and rating on any ingredient. Besides CRUD operations, the app also provides name search and filter functionality.

Models and Associations
There are five models in my project - a User model, an Ingredient model, a Drink model, a Measurement model and a Review model. The associations among these models are shown as below (created with Draw.io):
Alt Text

Data Validations
Besides built-in validation methods, I wrote custom validation methods the attributes in my models. For example: the user's input of a drink name will be automatically title cased before the data saved into the database.

# app/models/drink.rb

class Drink < ApplicationRecord
  before_validation :make_title_case, :set_defalut_thumb

  def make_title_case
        self.drink_name = drink_name.titlecase
  end
end
Enter fullscreen mode Exit fullscreen mode

Also, if users do not provide an image URL, they would be given a default one by the system.

# app/models/drink.rb

    def set_defalut_thumb
        if self.drink_thumb == nil || self.drink_thumb == ""
            self.drink_thumb = "https://www.thecocktaildb.com/images/media/drink/mrz9091589574515.jpg"
        end
    end
Enter fullscreen mode Exit fullscreen mode

Scope Methods to Query Data
A scope is an efficient way to filter an application using ActiveRecord queries or SQL. It's also easy to write a WHERE, ORDER, LIMIT, etc. with just one line.

In my project, I defined several scope methods for querying data from the database to implement search and filter functionality. Below is an example of how I implemented name search with a scope method:

# app/models/item.rb

scope :item_search, ->(name) { where("name LIKE ?", "%#{name.titlecase}%") } 

Enter fullscreen mode Exit fullscreen mode

On the homepage, the data of latest alcoholic and nonalcoholic drinks are also found with two scope methods:

# app/models/drink.rb

scope :alcohol_drink_filter, -> { Drink.joins(:item). where(item: {alcohol: "yes"}).last(3) }
scope :non_alcohol_drink_filter, -> { Drink.joins(:item). where(item: {alcohol: "no"}).last(3) }
Enter fullscreen mode Exit fullscreen mode

User Authentication
My application provides standard user authentication, including signup, login, logout, and secure passwords. The bcrypt gem I used helps generate salt and hashed passwords, while the has_secure_password method that is provided by ActiveRecord saves the passwords in the database and authenticates users.

There are two main methods we get from the #has_many_password method:
(1) The password= method allows us to create a user with a password attribute rather than a password_digest attribute. The method uses Bcrypt gem to hash and salt the password and then save it in the password_digest column in our table. Note that we don’t have to validate the password manually in our code. #has_secure_password validates the password automatically when being invoked.

(2) The authenticate method takes in the password a user enters when logging in and uses Bcrypt to hash and salt the input and compare it to what is saved in the password_digest column for that user.

Log In with a Third Party
The authentication system of my app also allows users to login from Google. To implement this functionality we need three gems in total -
(1) omniauth - this gem helps us log in with a third party
(2) dotenv-rails - thie gem ensures that the environment variables are correctly loaded into the ENV hash in a secure manner
(3) omniauth-google-oauth2

We also need to add two files -
.env - this file is where you save your ‘GOOGLE_CLIENT_ID’ and ‘GOOGLE_CLIENT_SECRET’ key-value pairs. Don’t forget to save it into the gitignore file!

/config/initializers/omniauth.rb - this files is to tell Rails to use OmniAuth to Authenticate our user.

# /config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
    provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], :skip_jwt => true
  end
Enter fullscreen mode Exit fullscreen mode

Then set up the route -

# config/routes.rb
get '/auth/:provider/callback', to: 'sessions#omniauth'
Enter fullscreen mode Exit fullscreen mode

Note that we don’t have a get ‘/auth/google’ route set up because Omniauth uses Rack Middleware to intercept the request to ‘/auth/google’.

Once the user clicks 'Login with Google’, it momentarily sends the user to mysite.com/auth/google, which quickly redirects to the Google sign-in page.

After the user agrees to let mysite.com access the user’s information, the user is redirected back to
mysite.com/auth/google/callback, and from there, to the page they initially tried to access.

Nested Resources
Making use of a nested resource with the appropriate RESTful URLs has helped me keep my routes neat and tidy. It is better than dynamic route segments for representing parent/child relationships in the system. Note that do not nest more than one level deep.

In my app, I included nested a new measurement route to the drinks resource - new_drink_ingredient_path(@drink)

And nested review routes to the ingredients resource -

new: new_ingredient_review_path(@ingredient)
index: ingredient_reviews_path(@ingredient)
show: ingredient_review_path(@ingredient, @review)

Below is how I created the nested index review route to the ingredients resource step by step. Note that the URL helper generated for each nested route will accept at least one argument.

Step 1 - set up nested routes in config/routes.rb:

resources :ingredients, only: [:show] do
     resources :reviews
  end
Enter fullscreen mode Exit fullscreen mode

Step 2: make changes in the reviews#index controller action. Add if-else statement to reuse the controller action:

# app/controllers/reviews_controller.rb 

  def index
        if params[:ingredient_id]
          @ingredient = Ingredient.find_by(id: params[:ingredient_id])
            @reviews = @ingredient.reviews
        else
        @reviews = Review.all
        end
    end
Enter fullscreen mode Exit fullscreen mode

If the user comes from nested routes (/ingredients/:id/) and makes a index request (/ingredients/:id/reviews), the ingredient_id key-value pair would be automatically saved in the params hash. Else the user is determined coming from the review index page (/reviews). That’s why we check the user’s http request by params[:ingredient_id].

Step 3 - make changes to links in views:

# app/views/ingredients/show.html.erb

<%= link_to "Add a Review", ingredient_reviews_path(@ingredient) %>
Enter fullscreen mode Exit fullscreen mode

Step 4 - make changes in the index form:

# app/views/reviews/_form.html.erb

<div class="container">

<% if @ingredient %>

<h3>All Reviews on <%= @ingredient.name %></h3>

<%= render partial: "reviews/reviews", locals: {reviews: @ingredient.reviews } %>

<%= link_to "Return #{@ingredient.name}", ingredient_path(@ingredient), class: "btn btn-info btn-sm" %>

<%else%>
<h3>All Reviews</h3>
<%= render partial: "reviews", locals: {reviews: @reviews} %>

<%end%>

</div>
Enter fullscreen mode Exit fullscreen mode

DRY Code
Writing appropriate partials and helpers are crucial to DRY up the code and follow the single responsibility principle. Practicing more is extremely important to acquire these techniques.

Further Thoughts
During the project, one of my biggest challenge is how to build up the most effective and workable model relationships. I would like to do more researches on the model associations such as has_one :throughand has_and_belongs_to_many later on. Furthermore, I want to practice set up the forms with the #form_with method to substitute form_for and form_tag.

Top comments (0)