Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ gem 'turbolinks'
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
gem 'uglifier'
gem 'web-console', group: :development
gem 'factory_bot_rails', group: [:development, :test]
gem "webmock", group: [:development, :test]
gem 'rails-controller-testing', group: [:development, :test]
21 changes: 21 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ GEM
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
bcrypt (3.1.18)
bigdecimal (3.1.8)
bindex (0.8.1)
builder (3.2.4)
byebug (11.1.3)
Expand All @@ -91,6 +92,9 @@ GEM
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.1.10)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
devise (4.8.1)
bcrypt (~> 3.0)
Expand All @@ -102,9 +106,15 @@ GEM
digest (3.1.0)
erubi (1.11.0)
execjs (2.8.1)
factory_bot (6.4.6)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.3)
factory_bot (~> 6.4)
railties (>= 5.0.0)
ffi (1.15.5)
globalid (1.0.0)
activesupport (>= 5.0)
hashdiff (1.1.0)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jbuilder (2.11.5)
Expand Down Expand Up @@ -170,6 +180,10 @@ GEM
activesupport (= 7.0.4)
bundler (>= 1.15.0)
railties (= 7.0.4)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
Expand Down Expand Up @@ -250,6 +264,10 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.23.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.9)
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
Expand All @@ -266,12 +284,14 @@ DEPENDENCIES
capybara
coffee-rails
devise
factory_bot_rails
jbuilder
listen
pg
pry-rails
puma
rails (~> 7.0.3)
rails-controller-testing
rspec-rails
sass-rails
selenium-webdriver
Expand All @@ -280,6 +300,7 @@ DEPENDENCIES
tzinfo-data
uglifier
web-console
webmock

RUBY VERSION
ruby 3.1.2p20
Expand Down
54 changes: 54 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,57 @@
*= require_tree .
*= require_self
*/

#main_component {
width: 65%;
margin: 0 auto;
background-color: #f6f6ef;
padding-left: 1em;
max-width: 1000px;
}

#login_buttons {
float: right;
margin: 5 auto;
padding: 1em;
}

.button {
width: 6em;
margin-bottom: .25em;
}

#title {
font-size: 3em;
color: #ff6600;
}
#header {
padding-bottom: 1em;
padding-top: 1em;
}

.story_box {
color: grey;
padding-bottom: .5em;
}

.arrow {
padding-right: .25em;
text-decoration: none;
color: grey
}

.title_line {
text-decoration: none;
font-weight: bold;
color: black
}

.counter {
padding-left: .3em;
padding-right: .45em;
}

.favorited {
color: orangered !important;
}
20 changes: 20 additions & 0 deletions app/controllers/favorites_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class FavoritesController < ApplicationController
before_action :restrict_access

def create
Favorite.find_or_create_by!(story_id: params[:story_id], user_id: current_user.id)
redirect_back(fallback_location: root_path)
end

def destroy
Favorite.find_by(story_id: params[:story_id], user_id: current_user&.id)&.destroy
redirect_back(fallback_location: root_path)
end

private

def restrict_access
redirect_to new_user_session_path unless current_user
end
end

11 changes: 11 additions & 0 deletions app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
class PagesController < ApplicationController
def home
@logged_in = current_user.present?
@stories_data = StoryService.get_stories_data(current_user)
end

def interesting_stories
@logged_in = current_user.present?
@stories_data = StoryService.get_interesting_stories(current_user)
render :home
end
end

6 changes: 6 additions & 0 deletions app/models/favorite.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Favorite < ApplicationRecord
belongs_to :user
belongs_to :story

validates :user_id, :story_id, presence: true
end
7 changes: 7 additions & 0 deletions app/models/story.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Story < ApplicationRecord
has_many :favorites, dependent: :destroy
has_many :users, through: :favorites

validates :external_story_id, :title, :url, :by, :time, presence: true

end
13 changes: 13 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,17 @@ class User < ApplicationRecord
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable

has_many :favorites, dependent: :destroy
has_many :stories, through: :favorites

before_create :placeholder_first_name

private

def placeholder_first_name
unless first_name
self.first_name = email.split("@").first
end
end
end
70 changes: 70 additions & 0 deletions app/services/hacker_news_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
class HackerNewsApi
BASE_URL = 'https://hacker-news.firebaseio.com'
RETRY_LIMIT = 3

class ApiInternalServiceError < StandardError; end

attr_accessor :retry_count

def initialize
@retry_count = RETRY_LIMIT
end

def self.get_current_stories_ids
new.get_current_stories_ids
end

def self.get_story_details(story_id)
new.get_story_details(story_id)
end

def get_current_stories_ids
default_return = []
uri = URI("#{BASE_URL}/v0/topstories.json")
api_request(uri, default_return)
end

# Return an object with story data
def get_story_details(story_id)
default_return = {}
return default_return if story_id.blank?

uri = URI("#{BASE_URL}/v0/item/#{story_id}.json")
api_request(uri, default_return)
end

private

def api_request(uri, default_return)
response = response(uri)
handle_response(response) || default_return
rescue
if @retry_count > 0
Rails.logger.info("Retrying request to #{uri}.")
@retry_count -= 1
retry
else
Rails.logger.error("Exceeded maximum retries. API request failed with code #{response.code}")
default_return
end
end

def response(uri)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
headers = {'Accept': 'application/json'}
request = Net::HTTP::Get.new(uri.request_uri, headers)
http.request(request)
end

def handle_response(response)
case response.code.to_i
when 200..299
JSON.parse(response.body)
when 400..499
Rails.logger.error("API request failed with code #{response.code}. Bad Request Error, please check request.")
when 500..599
raise ApiInternalServiceError
end
end
end
104 changes: 104 additions & 0 deletions app/services/story_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
class StoryService
DEFAULT_NUMBER_OF_STORIES = 15

attr_reader :user

def initialize(user = nil)
@user = user
end

def self.get_stories_data(user)
new(user).get_stories_data
end

def self.get_interesting_stories(user)
new(user).get_interesting_stories
end

def get_stories_data
# Fetch ids of current stories from api
external_stories_ids = get_stories_ids_from_api
process_external_story_id_data(external_stories_ids).compact
end

def get_interesting_stories
external_stories_ids = Story.joins(:favorites).distinct.pluck(:external_story_id)
process_external_story_id_data(external_stories_ids).compact
end

private

def process_external_story_id_data(external_stories_ids)
external_stories_ids.first(DEFAULT_NUMBER_OF_STORIES).map do |external_story_id|
story = find_or_fetch_story(external_story_id)
next unless story

generate_story_data_hash(story)
end
end

def generate_story_data_hash(story)
{
story: story,
favorite: get_favorite_user_string(story),
count: story_favorite_by_users(story).count,
favorite_by_user: favorite_by_user?(story)
}
end

def get_favorite_user_string(story)
usernames = story_favorite_by_users(story)
generate_favorite_user_string(usernames)
end

# Return a story object or nil if story is not found and api request fail
def find_or_fetch_story(external_story_id)
Story.includes(:users).find_by(external_story_id: external_story_id) ||
fetch_and_create_story(external_story_id) ||
nil
end

# Call api to get story data and create story if successful
def fetch_and_create_story(external_story_id)
fetched_story_data = get_story_data_from_api(external_story_id)
return nil unless fetched_story_data

create_story(fetched_story_data)
end

def create_story(data)
story = Story.new
story.external_story_id = data['id']
story.title = data['title']
story.by = data['by']
story.url = data['url']
story.time = Time.at(data['time'])
story.save!
story
rescue
nil
end

def generate_favorite_user_string(usernames)
count = usernames.count
output = usernames.first(3)
output << "and #{count - 3} more" if count > 3
output.join(", ")
end

def story_favorite_by_users(story)
story.users.pluck(:first_name)
end

def favorite_by_user?(story)
story.users.find_by(id: @user&.id).present?
end

def get_stories_ids_from_api
HackerNewsApi.get_current_stories_ids
end

def get_story_data_from_api(external_story_id)
HackerNewsApi.get_story_details(external_story_id)
end
end
Loading