Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Xukun Liu's backend technical challenge #302

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions Gemfile
Expand Up @@ -26,6 +26,11 @@ gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"

gem "bootstrap", "~> 5.1.3"

gem 'jquery-rails'

gem 'concurrent-ruby'
# Use Redis adapter to run Action Cable in production
# gem "redis", ">= 4.0.1"

Expand Down
25 changes: 25 additions & 0 deletions Gemfile.lock
Expand Up @@ -77,11 +77,17 @@ GEM
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
autoprefixer-rails (10.4.16.0)
execjs (~> 2)
base64 (0.2.0)
bigdecimal (3.1.5)
bindex (0.8.1)
bootsnap (1.17.0)
msgpack (~> 1.2)
bootstrap (5.1.3)
autoprefixer-rails (>= 9.1.0)
popper_js (>= 2.9.3, < 3)
sassc-rails (>= 2.0.0)
builder (3.2.4)
capybara (3.39.2)
addressable
Expand All @@ -102,6 +108,8 @@ GEM
drb (2.2.0)
ruby2_keywords
erubi (1.12.0)
execjs (2.9.1)
ffi (1.16.3)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.1)
Expand All @@ -117,6 +125,10 @@ GEM
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jquery-rails (4.6.0)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
Expand Down Expand Up @@ -149,6 +161,7 @@ GEM
racc (~> 1.4)
nokogiri (1.15.5-x86_64-linux)
racc (~> 1.4)
popper_js (2.11.8)
psych (5.1.1.1)
stringio
public_suffix (5.0.4)
Expand Down Expand Up @@ -201,6 +214,14 @@ GEM
rexml (3.2.6)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
railties (>= 4.0.0)
sassc (>= 2.0)
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (4.9.0)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
Expand All @@ -220,6 +241,7 @@ GEM
railties (>= 6.0.0)
stringio (3.1.0)
thor (1.3.0)
tilt (2.3.0)
timeout (0.4.1)
turbo-rails (1.5.0)
actionpack (>= 6.0.0)
Expand Down Expand Up @@ -252,10 +274,13 @@ PLATFORMS

DEPENDENCIES
bootsnap
bootstrap (~> 5.1.3)
capybara
concurrent-ruby
debug
importmap-rails
jbuilder
jquery-rails
puma (>= 5.0)
rails (~> 7.1.2)
selenium-webdriver
Expand Down
20 changes: 20 additions & 0 deletions README.md
@@ -1,3 +1,23 @@
## What I did

- **MVC Architecture:** Follows the standard Model-View-Controller (MVC) pattern.
- **Search Functionality:** Users can search for articles using keywords.
- **Frontend:** Implemented using jQuery and Bootstrap for a responsive and interactive user interface.
- **Caching:** Utilizes file-based caching to improve performance and reduce database load.
- **Rate Limiting:** Protects the application from excessive use of the create article endpoint.
- **Concurrency:** Handles write operations using a multithreading approach.

## What I could do more

- Add Redis to handle cache more efficiently
- Add message queue to better handle more write requests

# Instruction on how to run
1. Clone this repo
2. Navitage to the repo and perform `bundle install` then `rails server` to start the server
3. Go to http://127.0.0.1:3000/articles, the default link, and open it to access the UI


# Technical Instructions
1. Fork this repo to your local Github account.
2. Create a new branch to complete all your work in.
Expand Down
5 changes: 5 additions & 0 deletions app/assets/javascripts/application.js
@@ -0,0 +1,5 @@
//= require rails-ujs
//= require jquery3
//= require popper
//= require bootstrap
//= require_tree .
Expand Up @@ -13,3 +13,4 @@
*= require_tree .
*= require_self
*/
@import "bootstrap";
162 changes: 162 additions & 0 deletions app/controllers/articles_controller.rb
@@ -0,0 +1,162 @@
require 'concurrent'
class ArticlesController < ApplicationController
RATE_LIMIT = 20 # Maximum requests for creating per minute
RATE_LIMIT_PERIOD = 60 # Period in seconds

THREAD_POOL = Concurrent::ThreadPoolExecutor.new(
min_threads: 1,
max_threads: 5,
max_queue: 10,
fallback_policy: :caller_runs
)


before_action :set_article, only: [:show, :edit, :update, :destroy]

# GET /articles
# Responds to: HTML, JSON
def index
# Create a unique cache key based on the search term
cache_key = params[:search].present? ? "articles_search_#{params[:search]}" : "articles_all"

# Fetch from cache or perform the search query
@articles = Rails.cache.fetch(cache_key, expires_in: 12.hours) do
Article.search(params[:search]).to_a
end

respond_to do |format|
format.html
format.json { render json: @articles }
end
end

# GET /articles/:id
# Responds to: HTML, JSON
def show
respond_to do |format|
format.html
format.json { render json: @article }
end
end

# GET /articles/new
# Responds to: HTML
def new
@article = Article.new
end

# POST /articles
# Responds to: HTML, JSON
def create
# in case someone maliciously call our api
if rate_limit_exceeded?(request.remote_ip)
respond_to do |format|
format.html do
flash[:alert] = 'Rate limit exceeded. Please try again later.'
redirect_to articles_path
end
format.json { render json: { error: 'Rate limit exceeded' }, status: :too_many_requests }
end
return
end

@article = Article.new(article_params)
# use multi-threading to handle more requests
future = Concurrent::Future.execute(executor: THREAD_POOL) do
ActiveRecord::Base.connection_pool.with_connection do
@article.save
end
end
future.wait # Wait for the thread to complete

respond_to do |format|
if future.value
clear_articles_cache
format.html { redirect_to @article, notice: 'Article was successfully created.' }
format.json { render json: @article, status: :created, location: @article }
else
format.html { render :new }
format.json { render json: @article.errors, status: :unprocessable_entity }
end
end
end

# GET /articles/:id/edit
# Responds to: HTML
def edit
end

# PATCH/PUT /articles/:id
# Responds to: HTML, JSON
def update
respond_to do |format|
if @article.update(article_params)
clear_articles_cache
format.html { redirect_to @article, notice: 'Article was successfully updated.' }
format.json { render json: @article }
else
format.html { render :edit }
format.json { render json: @article.errors, status: :unprocessable_entity }
end
end
end

# DELETE /articles/:id
# Responds to: HTML, JSON
def destroy
begin
# Clear cache related to articles before destroying the article
Rails.cache.delete_matched("articles_*")

@article.destroy
respond_to do |format|
format.html { redirect_to articles_url, notice: 'Article was successfully destroyed.' }
format.json { head :no_content }
end
rescue => e
# Handle the exception and provide feedback to the user
respond_to do |format|
format.html { redirect_to articles_url, alert: "Failed to destroy the article: #{e.message}" }
format.json { render json: { error: "Failed to destroy the article: #{e.message}" }, status: :unprocessable_entity }
end
end
end

private
def set_article
begin
@article = Article.find(params[:id])
rescue ActiveRecord::RecordNotFound
# Handle the case when the article is not found
respond_to do |format|
format.html { redirect_to articles_url, alert: 'Article not found.' }
format.json { render json: { error: 'Article not found.' }, status: :not_found }
end
end
end

def article_params
params.require(:article).permit(:title, :content, :author, :date)
end

def clear_articles_cache
# Clear general articles cache
Rails.cache.delete("articles_all")

# Clear caches for specific search terms
Rails.cache.delete_matched("articles_search_*")
end

def rate_limit_exceeded?(ip)
key = "rate_limit:#{ip}"
count = Rails.cache.read(key) || 0

if count >= RATE_LIMIT
true
else
Rails.cache.write(key, count + 1, expires_in: RATE_LIMIT_PERIOD)
false
end
end
end

13 changes: 13 additions & 0 deletions app/models/article.rb
@@ -0,0 +1,13 @@
class Article < ApplicationRecord

validates :title, presence: true
validates :content, presence: true

def self.search(search_term)
if search_term
where('title LIKE ? OR content LIKE ?', "%#{search_term}%", "%#{search_term}%")
else
all
end
end
end
34 changes: 34 additions & 0 deletions app/views/articles/_form.html.erb
@@ -0,0 +1,34 @@
<%= form_with model: article, local: true, html: { class: 'mb-3' } do |form| %>
<% if article.errors.any? %>
<div id="error_explanation" class="alert alert-danger">
<h2><%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:</h2>
<ul>
<% article.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<div class="form-group mb-3">
<%= form.label :title %>
<%= form.text_field :title, class: 'form-control' %>
</div>

<div class="form-group mb-3">
<%= form.label :content %>
<%= form.text_area :content, class: 'form-control' %>
</div>

<div class="form-group mb-3">
<%= form.label :author %>
<%= form.text_field :author, class: 'form-control' %>
</div>

<div class="form-group mb-3">
<%= form.label :date %>
<%= form.date_select :date, class: 'form-control' %>
</div>

<%= form.submit class: 'btn btn-primary' %>
<% end %>
7 changes: 7 additions & 0 deletions app/views/articles/edit.html.erb
@@ -0,0 +1,7 @@
<div class="container">
<h1>Edit Article</h1>

<%= render 'form', article: @article %>

<%= link_to 'Back', articles_path, class: 'btn btn-outline-primary' %>
</div>
22 changes: 22 additions & 0 deletions app/views/articles/index.html.erb
@@ -0,0 +1,22 @@
<div class="container">
<h1>Articles</h1>

<%= form_with url: articles_path, method: :get, class: 'form-inline mb-3' do |form| %>
<%= form.text_field :search, value: params[:search], placeholder: "Search articles", class: 'form-control mr-sm-2' %>
<%= form.submit "Search", class: 'btn btn-outline-success' %>
<% end %>

<%= link_to 'New Article', new_article_path, class: 'btn btn-primary mb-3' %>

<ul class="list-group">
<% @articles.each do |article| %>
<li class="list-group-item d-flex justify-content-between align-items-center">
<%= link_to article.title, article_path(article) %>
<span>
<%= link_to 'Edit', edit_article_path(article), class: 'btn btn-outline-secondary btn-sm' %>
<%= link_to 'Delete', article_path(article), method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-outline-danger btn-sm' %>
</span>
</li>
<% end %>
</ul>
</div>
7 changes: 7 additions & 0 deletions app/views/articles/new.html.erb
@@ -0,0 +1,7 @@
<div class="container">
<h1>New Article</h1>

<%= render 'form', article: @article %>

<%= link_to 'Back', articles_path, class: 'btn btn-outline-primary' %>
</div>
10 changes: 10 additions & 0 deletions app/views/articles/show.html.erb
@@ -0,0 +1,10 @@
<div class="container">
<h1><%= @article.title %></h1>

<p><%= @article.content %></p>

<p>Author: <%= @article.author %></p>
<p>Date: <%= @article.date %></p>

<%= link_to 'Back', articles_path, class: 'btn btn-outline-primary' %>
</div>