Skip to content

Commit

Permalink
Improve Caching and Cache-invalidation (#6)
Browse files Browse the repository at this point in the history
* feat: Implement a caching module for controllers

* test: Fix errors with cache keys after implementing Cacheable module

* feat: Move category filtering logic to the model

* feat: Include the Cacheable module so other controllers can use it

* feat: Improve caching and clean up code
  • Loading branch information
nanafox authored Sep 18, 2024
1 parent 105b26b commit e2cc653
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 107 deletions.
64 changes: 39 additions & 25 deletions app/controllers/api/v1/categories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,60 @@ module V1
# This controller handles CRUD operations for categories.
class CategoriesController < ApplicationController
# Before actions to set up authorization and category object
before_action :set_api_v1_category, only: %i[show update destroy]
before_action :set_category, only: %i[show update destroy]

# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize

# GET /api/v1/categories
# GET /api/v1/categories.json
# Retrieves a paginated list of categories filtered by developer token
def index
categories = Category.all

categories = perform_filtering(categories)

paginated_categories = categories.page(params[:page]).per(page_size)
categories = Category.by_developer(developer_id)
.by_name(params[:name])
.by_search(params[:search])
.page(params[:page])
.per(page_size)

# implement caching for the collection of categories
response = cache_collection(
categories, base_key,
page: params[:page],
page_size:,
filters: {
name: params[:name],
search: params[:search]
}
) do |collection|
json_response(
collection,
message: 'Categories retrieved successfully',
serializer:
)
end

render json: json_response(
paginated_categories, serializer:,
message: 'Categories retrieved successfully'
)
render json: response
end

# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize

# GET /api/v1/categories/:id
# GET /api/v1/categories/:id.json
# Retrieves a specific category by ID
def show
# Render the JSON response with the category
render json: json_response(
category, serializer:,
message: 'Category retrieved successfully'
category,
message: 'Category retrieved successfully',
serializer:
)
end

# POST /api/v1/categories
# POST /api/v1/categories.json
# Creates a new category
def create
@category = Category.new(api_v1_category_params)

@category.save! # Raise an error when the category could not be saved
@category = Category.create!(category_params)

# Render the JSON response with the created category
render json: json_response(@category,
Expand All @@ -53,7 +71,7 @@ def create
# PATCH/PUT /api/v1/categories/:id.json
# Updates an existing category
def update
if @category.update!(api_v1_category_params)
if @category.update!(category_params)
# Render the JSON response with the updated category
render json: json_response(@category,
serializer:,
Expand Down Expand Up @@ -81,20 +99,16 @@ def destroy

# Sets the category instance variable based on the ID and developer
# token
def set_api_v1_category
cache_key = "category/#{params[:id]}_#{developer_id}"
@category = Rails.cache.fetch(cache_key, expires_in: 12.hours) do
Category.find_by!(
id: params[:id],
developer_id:
)
def set_category
@category = cache_resource(current_cache_key, expires_in: 12.hours) do
Category.find_by!(id: params[:id], developer_id:)
end
end

# Only allow a list of trusted parameters through.
# Permits the name and description parameters and merges the developer
# token
def api_v1_category_params
def category_params
params.require(:category)
.permit(:name, :description)
.merge(developer_id:)
Expand Down
110 changes: 36 additions & 74 deletions app/controllers/api/v1/products_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ class ProductsController < ApplicationController
before_action :set_product,
only: %i[show update destroy upload_images delete_image]
before_action :set_product_image, only: %i[delete_image]
after_action :invalidate_cache,
only: %i[update destroy upload_images delete_image]

# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize
Expand All @@ -23,29 +21,27 @@ def index

# Initialize the products query with developer_id and app_id
products = Product.where(developer_id:, app_id:)
.by_name(params[:name])
.by_category(params[:category_id])
.by_price_range(params[:min_price],
params[:max_price])
.page(page)
.per(page_size)

# Apply filtering based on query parameters
products = products.by_name(params[:name])
.by_category(params[:category_id])
.by_price_range(params[:min_price],
params[:max_price])

# Set the cache key based on the filtered products
cache_key = products_cache_key(developer_id:, page:, page_size:,
name: params[:name],
category_id: params[:category_id],
min_price: params[:min_price],
max_price: params[:max_price])

# Fetch or cache the response
response = Rails.cache.fetch(cache_key, expires_in: 2.hours) do
products_array = products.to_a
# Cache the collection of products
response = cache_collection(
products, base_key,
page:, page_size:,
filters: {
name: params[:name],
category_id: params[:category_id],
min_price: params[:min_price],
max_price: params[:max_price]
}
) do |collection|
json_response(
products_array,
message: 'Products retrieved successfully',
serializer:
collection, message: 'Products retrieved successfully',
serializer: ProductSerializer
)
end

Expand All @@ -60,8 +56,8 @@ def index
def show
render json: json_response(
@product,
serializer:,
message: 'Product retrieved successfully'
message: 'Product retrieved successfully',
serializer:
)
end

Expand All @@ -87,8 +83,8 @@ def update

render json: json_response(
@product,
serializer:,
message: 'Product updated successfully'
message: 'Product updated successfully',
serializer:
)
end

Expand All @@ -99,6 +95,7 @@ def destroy
head :no_content
end

# Uploads images to the product.
def upload_images
if @product.images.attach(image_params)
render json: { message: 'Images uploaded successfully' }
Expand All @@ -108,6 +105,7 @@ def upload_images
end
end

# Deletes a specific image from the product.
def delete_image
@product_image.purge_later
render json: { message: 'Image deleted successfully' }, status: :ok
Expand All @@ -120,33 +118,25 @@ def delete_image
# to @product. If not found or the cached product is stale, it is
# fetched from the database.
def set_product
@product = Rails.cache.fetch(cache_key, expires_in: 2.hours) do
@product = cache_resource(current_cache_key) do
Product.find_by!(id: params[:id], developer_id:, app_id:)
end
end

# Strong parameters for product creation and updates.
def product_params
params.require(:product)
.permit(:name, :description, :price, :category_id, :available,
:currency, :stock_quantity)
.merge(developer_id:, user_id:, app_id:)
.permit(
:name, :description, :price, :category_id, :available,
:currency, :stock_quantity
).merge(developer_id:, user_id:, app_id:)
end

# Strong parameters for product image creation.
def image_params
params.require(:images)
end

def set_product_image
@product_image = @product.images.find_by!(id: params[:image_id])
return unless @product_image.nil?

render_error(
error: 'Image not found',
status: :not_found
)
end

# Returns the serializer class for the product.
def serializer
ProductSerializer
Expand Down Expand Up @@ -174,43 +164,15 @@ def validate_category_id(category_id)
end
end

# rubocop:disable Metrics/ParameterLists

# Generates a cache key for product pagination based on developer ID
# and page info.
def products_cache_key(developer_id:, page:, page_size:, name: nil,
category_id: nil, min_price: nil, max_price: nil)

key_parts = %W[developer_#{developer_id} page_#{page}
size-#{page_size}]

key_parts << "name-#{name}" if name.present?
key_parts << "category_id-#{category_id}" if category_id.present?
key_parts << "min_price-#{min_price}" if min_price.present?
key_parts << "max_price-#{max_price}" if max_price.present?

key_parts.join('_')
end

# rubocop:enable Metrics/ParameterLists

# Generates the cache key for the specific product.
def cache_key
"product_#{params[:id]}_#{developer_id}"
end

# Invalidates the cache for the product and its updated_at timestamp.
def invalidate_cache
Rails.cache.delete(cache_key)
Rails.cache.delete(updated_at_cache_key)
end
# Sets the product image based on the provided ID.
def set_product_image
@product_image = @product.images.find_by!(id: params[:image_id])
return unless @product_image.nil?

# Generates the cache key for the product's updated_at timestamp.
def updated_at_cache_key
"#{cache_key}_updated_at"
render_error(error: 'Image not found', status: :not_found)
end
end
end

# rubocop:enable Metrics/ClassLength
# rubocop:enable Metrics/ClassLength
end
end
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
class ApplicationController < ActionController::API
include Authentication
include JsonResponse
include Cacheable

rescue_from ActiveRecord::RecordNotFound, with: :object_not_found
rescue_from ActiveRecord::RecordNotUnique, with: :duplicate_object
Expand Down
71 changes: 71 additions & 0 deletions app/controllers/concerns/cacheable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

# Implements caching for controllers
module Cacheable
extend ActiveSupport::Concern

included do
# rubocop:disable Rails/LexicallyScopedActionFilter
after_action :invalidate_cache, except: %i[index show]
# rubocop:enable Rails/LexicallyScopedActionFilter
end

def base_key
self.class.name.underscore
end

# rubocop:disable Metrics/ParameterLists

# Caches a collection of resources with a generated cache key.
def cache_collection(
collection, base_key, page:, page_size:, filters: {},
expires_in: 2.hours
)
cache_key = generate_list_cache_key(
base_key:, page:, page_size:, filters:
)

Rails.cache.fetch(cache_key, expires_in:) do
yield(collection)
end
end

# rubocop:enable Metrics/ParameterLists

# Caches a single resource with a generated cache key.
def cache_resource(cache_key, expires_in: 2.hours, &block)
Rails.cache.fetch(cache_key, expires_in:, &block)
end

# Invalidates the cache for both the resource and related lists.
def invalidate_cache
invalidate_list_cache(base_key:) if respond_to?(:invalidate_list_cache)
return unless respond_to?(:invalidate_single_resource_cache)

invalidate_single_resource_cache(current_cache_key)
end

# Generates a cache key for paginated lists based on filters.
def generate_list_cache_key(base_key:, page:, page_size:, filters: {})
key_parts = ["#{base_key}_page_#{page}_size_#{page_size}"]
filters.each do |filter_key, filter_value|
key_parts << "#{filter_key}_#{filter_value}" if filter_value.present?
end
key_parts.join('_')
end

# Invalidates all caches for paginated lists matching the developer ID.
def invalidate_list_cache(base_key:)
Rails.cache.delete_matched("#{base_key}/*")
end

# Invalidates a specific resource's cache.
def invalidate_single_resource_cache(cache_key)
Rails.cache.delete(cache_key)
end

# Returns the current cache key based on the controller's context.
def current_cache_key
"#{base_key}_#{params[:id]}_#{developer_id}"
end
end
13 changes: 13 additions & 0 deletions app/models/category.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,17 @@ class Category < ApplicationRecord

validates_word_count_of :description, min_words: 2, max_words: 10
validates_word_count_of :name, min_words: 1, max_words: 10

scope :by_developer, ->(developer_id) { where(developer_id:) }
scope :by_name, lambda { |name|
where('name ILIKE ?', "%#{name}%") if name.present?
}
scope :by_search, lambda { |search|
if search.present?
where(
'name ILIKE :search OR description ILIKE :search',
search: "%#{search}%"
)
end
}
end
Loading

0 comments on commit e2cc653

Please sign in to comment.