From fc149efd884c4b72955b8338ea8456416684498d Mon Sep 17 00:00:00 2001 From: Logan Leon Date: Mon, 29 Jan 2024 18:37:16 -0500 Subject: [PATCH 1/7] first version,implement articles to pass all the test --- app/controllers/articles_controller.rb | 98 ++++++++++++++++++++ app/models/article.rb | 13 +++ db/migrate/20240129233312_create_articles.rb | 11 +++ db/schema.rb | 23 +++++ 4 files changed, 145 insertions(+) create mode 100644 app/controllers/articles_controller.rb create mode 100644 app/models/article.rb create mode 100644 db/migrate/20240129233312_create_articles.rb create mode 100644 db/schema.rb diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb new file mode 100644 index 000000000..55c2b301c --- /dev/null +++ b/app/controllers/articles_controller.rb @@ -0,0 +1,98 @@ +class ArticlesController < ApplicationController + before_action :set_article, only: [:show, :edit, :update, :destroy] + + # GET /articles + # Responds to: HTML, JSON + def index + @articles = Article.search(params[:search]) + 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 + @article = Article.new(article_params) + respond_to do |format| + if @article.save + 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) + 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 + @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 + 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/db/migrate/20240129233312_create_articles.rb b/db/migrate/20240129233312_create_articles.rb new file mode 100644 index 000000000..f0bfef5f1 --- /dev/null +++ b/db/migrate/20240129233312_create_articles.rb @@ -0,0 +1,11 @@ +class CreateArticles < ActiveRecord::Migration[7.1] + def change + create_table :articles do |t| + t.string :title + t.text :content + t.string :author + t.date :date + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..a1ac8236c --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,23 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.1].define(version: 2024_01_29_233312) do + create_table "articles", force: :cascade do |t| + t.string "title" + t.text "content" + t.string "author" + t.date "date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + +end From 8dd555e81d12363103c4c5caa7f17146c8874873 Mon Sep 17 00:00:00 2001 From: Logan Leon Date: Mon, 29 Jan 2024 20:19:51 -0500 Subject: [PATCH 2/7] add view to project, so that we could access the backend through http://localhost/articles --- Gemfile | 3 ++ Gemfile.lock | 24 +++++++++++++ app/assets/javascripts/application.js | 5 +++ .../{application.css => application.scss} | 1 + app/views/articles/_form.html.erb | 34 +++++++++++++++++++ app/views/articles/edit.html.erb | 7 ++++ app/views/articles/index.html.erb | 17 ++++++++++ app/views/articles/new.html.erb | 7 ++++ app/views/articles/show.html.erb | 10 ++++++ config/routes.rb | 2 ++ 10 files changed, 110 insertions(+) create mode 100644 app/assets/javascripts/application.js rename app/assets/stylesheets/{application.css => application.scss} (97%) create mode 100644 app/views/articles/_form.html.erb create mode 100644 app/views/articles/edit.html.erb create mode 100644 app/views/articles/index.html.erb create mode 100644 app/views/articles/new.html.erb create mode 100644 app/views/articles/show.html.erb diff --git a/Gemfile b/Gemfile index c4bc14b11..2db5f84ae 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,9 @@ gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" +gem "bootstrap", "~> 5.1.3" + +gem 'jquery-rails' # Use Redis adapter to run Action Cable in production # gem "redis", ">= 4.0.1" diff --git a/Gemfile.lock b/Gemfile.lock index 2b317df1b..461c64d2c 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,12 @@ PLATFORMS DEPENDENCIES bootsnap + bootstrap (~> 5.1.3) capybara debug importmap-rails jbuilder + jquery-rails puma (>= 5.0) rails (~> 7.1.2) selenium-webdriver 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/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? %> +
+

<%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:

+
    + <% article.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :title %> + <%= form.text_field :title, class: 'form-control' %> +
+ +
+ <%= form.label :content %> + <%= form.text_area :content, class: 'form-control' %> +
+ +
+ <%= form.label :author %> + <%= form.text_field :author, class: 'form-control' %> +
+ +
+ <%= form.label :date %> + <%= form.date_select :date, class: 'form-control' %> +
+ + <%= form.submit class: 'btn btn-primary' %> +<% end %> diff --git a/app/views/articles/edit.html.erb b/app/views/articles/edit.html.erb new file mode 100644 index 000000000..915095020 --- /dev/null +++ b/app/views/articles/edit.html.erb @@ -0,0 +1,7 @@ +
+

Edit Article

+ + <%= render 'form', article: @article %> + + <%= link_to 'Back', articles_path, class: 'btn btn-outline-primary' %> +
diff --git a/app/views/articles/index.html.erb b/app/views/articles/index.html.erb new file mode 100644 index 000000000..36d0d0fb6 --- /dev/null +++ b/app/views/articles/index.html.erb @@ -0,0 +1,17 @@ +
+

Articles

+ + <%= link_to 'New Article', new_article_path, class: 'btn btn-primary mb-3' %> + +
    + <% @articles.each do |article| %> +
  • + <%= link_to article.title, article_path(article) %> + + <%= 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' %> + +
  • + <% end %> +
+
diff --git a/app/views/articles/new.html.erb b/app/views/articles/new.html.erb new file mode 100644 index 000000000..4548ad1d9 --- /dev/null +++ b/app/views/articles/new.html.erb @@ -0,0 +1,7 @@ +
+

New Article

+ + <%= render 'form', article: @article %> + + <%= link_to 'Back', articles_path, class: 'btn btn-outline-primary' %> +
diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb new file mode 100644 index 000000000..bf104caa6 --- /dev/null +++ b/app/views/articles/show.html.erb @@ -0,0 +1,10 @@ +
+

<%= @article.title %>

+ +

<%= @article.content %>

+ +

Author: <%= @article.author %>

+

Date: <%= @article.date %>

+ + <%= link_to 'Back', articles_path, class: 'btn btn-outline-primary' %> +
diff --git a/config/routes.rb b/config/routes.rb index a125ef085..5aa613ec1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,4 +7,6 @@ # Defines the root path route ("/") # root "posts#index" + resources :articles + root 'articles#index' end From a656d6cb924d1e6fabfd669dfac3b5ab5f0b74c9 Mon Sep 17 00:00:00 2001 From: Logan Leon Date: Mon, 29 Jan 2024 21:34:15 -0500 Subject: [PATCH 3/7] add search function to the frontend page --- app/views/articles/index.html.erb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/views/articles/index.html.erb b/app/views/articles/index.html.erb index 36d0d0fb6..c06283993 100644 --- a/app/views/articles/index.html.erb +++ b/app/views/articles/index.html.erb @@ -1,6 +1,11 @@

Articles

+ <%= 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' %>
    From a092adceb410094cdc68963283c87a43f8f0c4e6 Mon Sep 17 00:00:00 2001 From: Logan Leon Date: Mon, 29 Jan 2024 21:47:21 -0500 Subject: [PATCH 4/7] add file-based cache to development environment, in order to handle more read request --- app/controllers/articles_controller.rb | 20 +++++++++++++++----- config/environments/development.rb | 4 ++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb index 55c2b301c..c915e74ea 100644 --- a/app/controllers/articles_controller.rb +++ b/app/controllers/articles_controller.rb @@ -4,11 +4,18 @@ class ArticlesController < ApplicationController # GET /articles # Responds to: HTML, JSON def index - @articles = Article.search(params[:search]) - respond_to do |format| - format.html - format.json { render json: @articles } - end + # 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 @@ -64,6 +71,9 @@ def update # 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.' } diff --git a/config/environments/development.rb b/config/environments/development.rb index 2e7fb486b..42648d6e2 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -28,9 +28,9 @@ "Cache-Control" => "public, max-age=#{2.days.to_i}" } else - config.action_controller.perform_caching = false + config.action_controller.perform_caching = true - config.cache_store = :null_store + config.cache_store = :file_store, "#{Rails.root}/tmp/cache/" end # Store uploaded files on the local file system (see config/storage.yml for options). From 940fa5deebc891d216302395f7ec7d774ef0d57c Mon Sep 17 00:00:00 2001 From: Logan Leon Date: Mon, 29 Jan 2024 23:39:22 -0500 Subject: [PATCH 5/7] 1. fix bug of cache 2. add a multi thread and add rate-limit-control for creat article api, in case it is maliciously used --- Gemfile | 2 + Gemfile.lock | 1 + app/controllers/articles_controller.rb | 56 +++++++++++++++++++++++++- app/views/layouts/application.html.erb | 7 ++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 2db5f84ae..e1ad4e09c 100644 --- a/Gemfile +++ b/Gemfile @@ -29,6 +29,8 @@ 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 461c64d2c..1374efc9b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -276,6 +276,7 @@ DEPENDENCIES bootsnap bootstrap (~> 5.1.3) capybara + concurrent-ruby debug importmap-rails jbuilder diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb index c915e74ea..c69eab42a 100644 --- a/app/controllers/articles_controller.rb +++ b/app/controllers/articles_controller.rb @@ -1,4 +1,16 @@ +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 @@ -36,9 +48,30 @@ def new # 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 @article.save + 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 @@ -58,6 +91,7 @@ def edit 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 @@ -104,5 +138,25 @@ def set_article 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/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 812bfb90f..941b8777e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -11,6 +11,13 @@ + <% flash.each do |type, message| %> + <% alert_class = type.to_sym == :alert ? 'alert-danger' : 'alert-success' %> + + <% end %> <%= yield %> From e64563e96d20d2134a0fafcd2c2980fafce39784 Mon Sep 17 00:00:00 2001 From: Logan Leon Date: Mon, 29 Jan 2024 23:45:32 -0500 Subject: [PATCH 6/7] add some more tests --- test/models/article_test.rb | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/models/article_test.rb b/test/models/article_test.rb index 0d55b70ea..c9ad73302 100644 --- a/test/models/article_test.rb +++ b/test/models/article_test.rb @@ -65,4 +65,30 @@ class ArticleTest < ActiveSupport::TestCase assert_includes results, article2 assert_not_includes results, article1 end + + test 'ensures valid article creation' do + article1 = Article.create(title: 'Sample Article') + article2 = Article.create(content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.') + assert_equal 0, Article.count + end + + test 'search should not return results for an unrelated query' do + Article.create(title: 'Sample Article', content: 'Lorem ipsum dolor sit amet.') + results = Article.search('unrelated query') + assert_empty results, 'Search returned results for an unrelated query' + end + + test 'search should be case insensitive' do + article = Article.create(title: 'Sample Article', content: 'Lorem ipsum dolor sit amet.') + results = Article.search('sample article') + assert_includes results, article, 'Search did not include the correct article for a case-insensitive query' + end + + test 'serach should return results match word in both the title and content' do + article1 = Article.create(title: 'Sample Article', content: 'Test') + article2 = Article.create(title: 'Another Article', content: 'Sample') + results = Article.search('Sample') + assert_includes results, article1 + assert_includes results, article2 + end end From abc046863ce63d474c9f8f1c8a8e6b0c6c60be8a Mon Sep 17 00:00:00 2001 From: Logan Leon Date: Mon, 29 Jan 2024 23:53:02 -0500 Subject: [PATCH 7/7] add readme --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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.