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

Implement caching mechanism in the library #5

Merged
merged 6 commits into from
Sep 15, 2024
Merged
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: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ jobs:
run: bundle exec rspec

- name: Upload coverage report
uses: actions/upload-artifact@v2
if: matrix.os == 'ubuntu-latest' # Only upload from Ubuntu
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
5 changes: 1 addition & 4 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

source 'https://rubygems.org'

# Specify your gem's dependencies in nse_data.gemspec
gemspec

# Specify your gem's dependencies in nse_data.gemspec
# Specify your gem's dependencies in api_wrapper.gemspec
gemspec

group :development do
Expand Down
7 changes: 2 additions & 5 deletions api_wrapper.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']

# Uncomment to register a new dependency of your gem
# spec.add_dependency "example-gem", "~> 1.0"

# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
spec.add_dependency 'faraday', '~> 2.11'
spec.add_dependency 'faraday-http-cache', '~> 2.5', '>= 2.5.1'
end
78 changes: 78 additions & 0 deletions lib/api_wrapper/cache/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!-- Note: This readme is simply moved from nse_data, so reflect on this doc for any minor mistakes. -->
# Caching Mechanism Usage and Customization

## Overview

The **ApiWrapper** gem includes a flexible caching mechanism to improve performance and reduce redundant API calls. This documentation provides an overview of how to use and customize the caching mechanism.

## Cache Policy

The `CachePolicy` class is responsible for managing caching behavior, including setting global TTLs, custom TTLs for specific endpoints, and controlling which endpoints should bypass the cache.

### Initializing CachePolicy

To use caching, you need to initialize the `CachePolicy` with a cache store and optional global TTL:

```ruby
require 'api_wrapper/cache/cache_policy'
require 'api_wrapper/cache/cache_store'

# Initialize the cache store (e.g., in-memory cache store)
cache_store = ApiWrapper::Cache::CacheStore.new

# Initialize the CachePolicy with the cache store and a global TTL of 300 seconds (5 minutes)
cache_policy = ApiWrapper::Cache::CachePolicy.new(cache_store, global_ttl: 300)
```

### Configuring Cache Policy
#### Adding No-Cache Endpoints
You can specify endpoints that should bypass the cache:
```ruby
# Add an endpoint to the no-cache list
cache_policy.add_no_cache_endpoint('/no-cache')
```

#### Adding Custom TTLs
You can define custom TTLs for specific endpoints:

```ruby
# Set a custom TTL of 600 seconds (10 minutes) for a specific endpoint
cache_policy.add_custom_ttl('/custom-ttl', ttl: 600)
```

#### Fetching Data with Cache
Use the fetch method to retrieve data with caching applied:

```ruby
# Fetch data for an endpoint with optional cache
data = cache_policy.fetch('/some-endpoint') do
# The block should fetch fresh data if cache is not used or is stale
# e.g., perform an API call or other data retrieval operation
Faraday::Response.new(body: 'fresh data')
end
```
## Custom Cache Stores
You can extend the caching mechanism to support different types of cache stores. Implement a custom cache store by inheriting from the CacheStore base class and overriding the read and write methods.

### Example Custom Cache Store
```ruby
class CustomCacheStore < ApiWrapper::Cache::CacheStore
def read(key)
# Implement custom read logic
end

def write(key, value, ttl)
# Implement custom write logic
end
end
```
### Using a Custom Cache Store
To use a custom cache store, initialize CachePolicy with an instance of your custom cache store:

```ruby
# Initialize the custom cache store
custom_cache_store = CustomCacheStore.new

# Initialize CachePolicy with the custom cache store
cache_policy = ApiWrapper::Cache::CachePolicy.new(custom_cache_store, global_ttl: 300)
```
115 changes: 115 additions & 0 deletions lib/api_wrapper/cache/cache_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# frozen_string_literal: true

module ApiWrapper
module Cache
# CachePolicy manages caching behavior, including cache storage and time-to-live (TTL) settings.
#
# It allows setting global TTLs, custom TTLs for specific endpoints, and controlling which
# endpoints should not use the cache.
#
# @attr_reader [CacheStore] cache_store The cache store used for storing cached data.
class CachePolicy
attr_reader :cache_store

# Initializes the CachePolicy with a cache store and global TTL.
#
# @param cache_store [CacheStore, RedisCacheStore] The cache store to use for caching.
# @param global_ttl [Integer] The default TTL (in seconds) for caching.
def initialize(cache_store, global_ttl = 300)
@cache_store = cache_store
@global_ttl = global_ttl
@custom_ttls = {}
@no_cache_endpoints = []
end

# Adds an endpoint that should bypass the cache.
#
# @param endpoint [String] The endpoint to exclude from caching.
def add_no_cache_endpoint(endpoint)
@no_cache_endpoints << endpoint
end

# Adds a custom TTL for a specific endpoint.
#
# @param endpoint [String] The endpoint to apply a custom TTL to.
# @param ttl [Integer] The custom TTL value in seconds.
def add_custom_ttl(endpoint, ttl = 300)
@custom_ttls[endpoint] = ttl
end

# Returns the TTL for a specific endpoint. Defaults to the global TTL if no custom TTL is set.
#
# @param endpoint [String] The endpoint to fetch the TTL for.
# @return [Integer] The TTL in seconds.
def ttl_for(endpoint)
@custom_ttls.fetch(endpoint, @global_ttl)
end

# Determines if caching should be used for the given endpoint.
#
# @param endpoint [String] The endpoint to check.
# @return [Boolean] True if caching is enabled for the endpoint, false otherwise.
def use_cache?(endpoint)
!@no_cache_endpoints.include?(endpoint)
end

# Fetches the data for the given endpoint, using cache if applicable.
#
# @param endpoint [String] The endpoint to fetch data for.
# @param force_refresh [Boolean] Whether to force refresh the data, bypassing the cache.
# @yield The block that fetches fresh data if cache is not used or is stale.
# @return [Object] The data fetched from cache or fresh data.
def fetch(endpoint, force_refresh: false, &block)
if force_refresh || !use_cache?(endpoint)
fetch_fresh_data(endpoint, &block)
else
fetch_cached_or_fresh_data(endpoint, &block)
end
end

private

# Fetches fresh data and writes it to the cache if applicable.
#
# @param endpoint [String] The endpoint to fetch fresh data for.
# @yield The block that fetches fresh data.
# @return [Object] The fresh data.
def fetch_fresh_data(endpoint)
fresh_data = yield
cache_fresh_data(endpoint, fresh_data)
fresh_data
end

# Fetches cached data or fresh data if not available in the cache.
#
# @param endpoint [String] The endpoint to fetch data for.
# @yield The block that fetches fresh data if cache is not used or is stale.
# @return [Object] The cached or fresh data.
def fetch_cached_or_fresh_data(endpoint, &block)
cached_data = @cache_store.read(endpoint)
if cached_data
Faraday::Response.new(body: cached_data)
else
fetch_fresh_data(endpoint, &block)
end
end

# Writes fresh data to the cache.
#
# @param endpoint [String] The endpoint for which to store the data.
# @param fresh_data [Object] The data to be stored in the cache.
def cache_fresh_data(endpoint, fresh_data)
ttl = determine_ttl(endpoint)
@cache_store.write(endpoint, fresh_data.body, ttl) if fresh_data.is_a?(Faraday::Response)
end

# Determines the TTL value for the given endpoint.
#
# @param endpoint [String] The endpoint to fetch the TTL for.
# @return [Integer] The TTL value in seconds.
def determine_ttl(endpoint)
@custom_ttls.fetch(endpoint, @global_ttl)
end
end
end
end
84 changes: 84 additions & 0 deletions lib/api_wrapper/cache/cache_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

module ApiWrapper
module Cache
# CacheStore class provides an in-memory caching mechanism.
class CacheStore
def initialize
@store = {}
end

# Retrieves the cached data for the given key, or fetches fresh data if not cached or expired.
#
# @param key [String] The cache key.
# @param ttl [Integer] The time-to-live in seconds.
# @yield Fetches fresh data if cache is expired or not present.
# @return [Object] The cached data or the result of the block if not cached or expired.
def fetch(key, ttl)
if cached?(key, ttl)
@store[key][:data]
else
fresh_data = yield
store(key, fresh_data, ttl)
fresh_data
end
end

# Reads data from the cache.
#
# @param key [String] The cache key.
# @return [Object, nil] The cached data, or nil if not present.
def read(key)
cached?(key) ? @store[key][:data] : nil
end

# Writes data to the cache with an expiration time.
#
# @param key [String] The cache key.
# @param data [Object] The data to cache.
# @param ttl [Integer] The time-to-live in seconds.
def write(key, data, ttl)
store(key, data, ttl)
end

# Deletes data from the cache.
#
# @param key [String] The cache key.
def delete(key)
@store.delete(key)
end

private

# Checks if the data for the given key is cached and not expired.
#
# @param key [String] The cache key.
# @param ttl [Integer] The time-to-live in seconds.
# @return [Boolean] Whether the data is cached and valid.
def cached?(key, ttl = nil)
return false unless @store.key?(key)

!expired?(key, ttl)
end

# Checks if the cached data for the given key has expired.
#
# @param key [String] The cache key.
# @param ttl [Integer] The time-to-live in seconds.
# @return [Boolean] Whether the cached data has expired.
def expired?(key, ttl)
stored_time = @store[key][:timestamp]
ttl && (Time.now - stored_time) >= ttl
end

# Stores the data in the cache.
#
# @param key [String] The cache key.
# @param data [Object] The data to cache.
# @param ttl [Integer] The time-to-live in seconds.
def store(key, data, ttl)
@store[key] = { data: data, timestamp: Time.now, ttl: ttl }
end
end
end
end
3 changes: 3 additions & 0 deletions lib/api_wrapper/cache/redis_cache_store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true

# TODO: Implement in near future :)
5 changes: 5 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# frozen_string_literal: true

require 'simplecov'
SimpleCov.start do
add_filter '/spec/'
end

require 'api_wrapper'

RSpec.configure do |config|
Expand Down
51 changes: 51 additions & 0 deletions spec/unit/cache/cache_policy_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require 'spec_helper'
require 'api_wrapper/cache/cache_policy'
require 'api_wrapper/cache/cache_store'
require 'faraday'

RSpec.describe ApiWrapper::Cache::CachePolicy do
let(:cache_store) { ApiWrapper::Cache::CacheStore.new }
let(:cache_policy) { described_class.new(cache_store) }

before do
cache_policy.add_no_cache_endpoint('/no-cache')
cache_policy.add_custom_ttl('/custom-ttl', 600) # 10 minutes
end

describe '#use_cache?' do
it 'returns true for endpoints that are not in the no-cache list' do
expect(cache_policy.use_cache?('/some-endpoint')).to be(true)
end

it 'returns false for endpoints that are in the no-cache list' do
expect(cache_policy.use_cache?('/no-cache')).to be(false)
end
end

describe '#fetch' do
it 'uses cache if available and not forced to refresh' do
# Simulate a Faraday::Response object
cached_response = Faraday::Response.new(body: 'cached data')
cache_policy.fetch('/some-endpoint') { cached_response }
result = cache_policy.fetch('/some-endpoint')
expect(result.body).to eq('cached data')
end

it 'bypasses cache if force_refresh is true' do
cached_response = Faraday::Response.new(body: 'cached data')
cache_policy.fetch('/some-endpoint') { cached_response }
fresh_response = Faraday::Response.new(body: 'fresh data')
result = cache_policy.fetch('/some-endpoint', force_refresh: true) { fresh_response }
expect(result.body).to eq('fresh data')
end

it 'uses custom TTL for specific endpoints' do
fresh_response = Faraday::Response.new(body: 'data with custom ttl')
cache_policy.fetch('/custom-ttl') { fresh_response }
result = cache_policy.fetch('/custom-ttl')
expect(result.body).to eq('data with custom ttl')
end
end
end
Loading
Loading