diff --git a/Gemfile b/Gemfile index c4bc14b11..e1ad4e09c 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/Gemfile.lock b/Gemfile.lock index 2b317df1b..1374efc9b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 diff --git a/README.md b/README.md index 29c5b607c..c6ca091de 100644 --- a/README.md +++ b/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. diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 000000000..da5bd5e7a --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,5 @@ +//= require rails-ujs +//= require jquery3 +//= require popper +//= require bootstrap +//= require_tree . \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.scss similarity index 97% rename from app/assets/stylesheets/application.css rename to app/assets/stylesheets/application.scss index 288b9ab71..eef39caf6 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.scss @@ -13,3 +13,4 @@ *= require_tree . *= require_self */ + @import "bootstrap"; \ No newline at end of file diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb new file mode 100644 index 000000000..c69eab42a --- /dev/null +++ b/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 + \ No newline at end of file diff --git a/app/models/article.rb b/app/models/article.rb new file mode 100644 index 000000000..bb3101320 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/app/views/articles/_form.html.erb b/app/views/articles/_form.html.erb new file mode 100644 index 000000000..9392f8ded --- /dev/null +++ b/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? %> +
<%= @article.content %>
+ +Author: <%= @article.author %>
+Date: <%= @article.date %>
+ + <%= link_to 'Back', articles_path, class: 'btn btn-outline-primary' %> +