diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52c3bd1..d3e9e85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,9 +32,11 @@ jobs: - name: Run tests run: bundle exec rspec - - name: Upload coverage report - if: matrix.os == 'ubuntu-latest' # Only upload from Ubuntu - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: coverage/ \ No newline at end of file + # Temporarily disable uploading of coverage report as it is failing with error as: + # Error: Failed to CreateArtifact: Received non-retryable error: Failed request: (409) Conflict: an artifact with this name already exists on the workflow run + # - name: Upload coverage report + # if: matrix.os == 'ubuntu-latest' # Only upload from Ubuntu + # uses: actions/upload-artifact@v4 + # with: + # name: coverage-report + # path: coverage/ \ No newline at end of file diff --git a/lib/api_wrapper/http_client/base_client.rb b/lib/api_wrapper/http_client/base_client.rb new file mode 100644 index 0000000..4682f8b --- /dev/null +++ b/lib/api_wrapper/http_client/base_client.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ApiWrapper + module HttpClient + # Base class for HTTP clients + class BaseClient + # Initializes a new instance of the BaseClient class. + # + # @param base_url [String] The base URL for the HTTP client. + def initialize(base_url, cache_policy) + @base_url = base_url + @cache_policy = cache_policy + end + + # Sends a GET request to the specified endpoint. + # + # @param endpoint [String] The endpoint to send the GET request to. + # @raise [NotImplementedError] If the method is not implemented by subclasses. + def get(endpoint) + raise NotImplementedError, 'Subclasses must implement the get method' + end + + # TODO: Other HTTP methods like post, put, delete can be added here if needed + end + end +end diff --git a/lib/api_wrapper/http_client/faraday_client.rb b/lib/api_wrapper/http_client/faraday_client.rb new file mode 100644 index 0000000..d452f4c --- /dev/null +++ b/lib/api_wrapper/http_client/faraday_client.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'faraday' +require 'faraday-http-cache' +require 'json' +require_relative 'base_client' + +module ApiWrapper + module HttpClient + # FaradayClient class is responsible for making HTTP requests using Faraday. + class FaradayClient < BaseClient + # Sends a GET request to the specified endpoint. + # + # @param endpoint [String] The endpoint to send the request to. + # @param force_refresh [Boolean] Whether to force a cache refresh. + # @return [Faraday::Response] The response object. + def get(endpoint, force_refresh: false) + # Use the cache policy to determine whether to fetch from cache or refresh. + @cache_policy.fetch(endpoint, force_refresh: force_refresh) do + handle_connection(endpoint) do |connection| + connection.get(endpoint) + end + end + end + + private + + # Handles the connection to the base URL. + # + # @yield [connection] The Faraday connection object. + # @yieldparam connection [Faraday::Connection] The Faraday connection object. + # @return [Object] The result of the block execution. + # @raise [Faraday::Error] If there is an error with the connection. + def handle_connection(endpoint) + connection = build_faraday_connection(endpoint) + yield(connection) + rescue Faraday::Error => e + handle_faraday_error(e) + end + + # Builds the Faraday connection with proper configuration. + # + # @param endpoint [String] The endpoint for the connection. + # @return [Faraday::Connection] The configured Faraday connection. + def build_faraday_connection(endpoint) + Faraday.new(url: @base_url) do |faraday| + configure_faraday(faraday) + apply_cache_policy(faraday, endpoint) if @cache_policy.use_cache?(endpoint) + faraday.adapter Faraday.default_adapter + end + end + + # Configures Faraday with default headers and request options. + # + # @param faraday [Faraday::Connection] The Faraday connection object. + def configure_faraday(faraday) + faraday.request :json + faraday.headers['User-Agent'] = 'ApiWrapperClient/1.0' + faraday.response :json + faraday.headers['Accept'] = 'application/json' + end + + # Applies the cache policy to the Faraday connection if caching is enabled. + # + # @param faraday [Faraday::Connection] The Faraday connection object. + # @param endpoint [String] The endpoint to be cached. + def apply_cache_policy(faraday, endpoint) + ttl = @cache_policy.ttl_for(endpoint) + faraday.use :http_cache, store: @cache_policy.cache_store, expire_after: ttl + end + + # Handles any Faraday errors. + # + # @param error [Faraday::Error] The error raised by Faraday. + # @raise [StandardError] Raises the original error with full context. + def handle_faraday_error(error) + raise StandardError, "Faraday error: #{error.message}" + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b46d546..cd3d3f8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,6 +6,10 @@ end require 'api_wrapper' +require 'webmock/rspec' + +# Disable external requests to ensure all requests are stubbed +WebMock.disable_net_connect!(allow_localhost: true) RSpec.configure do |config| # Enable flags like --only-failures and --next-failure diff --git a/spec/unit/http_client/base_client_spec.rb b/spec/unit/http_client/base_client_spec.rb new file mode 100644 index 0000000..d0f8854 --- /dev/null +++ b/spec/unit/http_client/base_client_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'api_wrapper/http_client/base_client' + +RSpec.describe ApiWrapper::HttpClient::BaseClient do + let(:base_url) { 'https://api.example.com' } + let(:cache_policy) { double('CachePolicy') } + + subject { described_class.new(base_url, cache_policy) } + + describe '#initialize' do + it 'sets the base_url' do + expect(subject.instance_variable_get(:@base_url)).to eq(base_url) + end + + it 'sets the cache_policy' do + expect(subject.instance_variable_get(:@cache_policy)).to eq(cache_policy) + end + end + + describe '#get' do + it 'raises NotImplementedError when calling get' do + expect do + subject.get('/endpoint') + end.to raise_error(NotImplementedError, 'Subclasses must implement the get method') + end + end +end diff --git a/spec/unit/http_client/faraday_client_spec.rb b/spec/unit/http_client/faraday_client_spec.rb new file mode 100644 index 0000000..c36ac81 --- /dev/null +++ b/spec/unit/http_client/faraday_client_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# spec/nse_data/http_client/faraday_client_spec.rb +require 'spec_helper' +require 'api_wrapper/http_client/faraday_client' +require 'api_wrapper/cache/cache_policy' +require 'api_wrapper/cache/cache_store' + +RSpec.describe ApiWrapper::HttpClient::FaradayClient do + let(:base_url) { 'https://www.nseindia.com/api/' } + let(:cache_store) { ApiWrapper::Cache::CacheStore.new } + let(:cache_policy) { ApiWrapper::Cache::CachePolicy.new(cache_store) } + let(:client) { described_class.new(base_url, cache_policy) } + + describe '#get' do + let(:endpoint) { 'special-preopen-listing' } + let(:response_body) { { 'status' => 'success' }.to_json } + + context 'when a connection is successful and data is cached' do + it 'returns the cached response if available' do + # Cache the response + cache_store.write(endpoint, response_body, 300) + + result = client.get(endpoint) + expect(JSON.parse(result.body)).to eq('status' => 'success') + end + + it 'fetches fresh data if cache is expired' do + # Store data in cache but simulate it being expired by setting TTL to 0 + cache_store.write(endpoint, response_body, 0) + + stub_request(:get, "#{base_url}#{endpoint}") + .with( + headers: { + 'Accept' => 'application/json', + 'User-Agent' => 'ApiWrapperClient/1.0' + } + ) + .to_return(status: 200, body: response_body, headers: {}) + + result = client.get(endpoint) + expect(JSON.parse(result.body)).to eq('status' => 'success') + end + end + + context 'when a connection is successful but data is not cached' do + it 'fetches fresh data and stores it in the cache' do + # Ensure cache is empty + expect(cache_store.read(endpoint)).to be_nil + + # Simulate a successful API response + stub_request(:get, "#{base_url}#{endpoint}") + .with( + headers: { + 'Accept' => 'application/json', + 'User-Agent' => 'ApiWrapperClient/1.0' + } + ) + .to_return(status: 200, body: response_body, headers: {}) + + result = client.get(endpoint) + expect(JSON.parse(result.body)).to eq('status' => 'success') + + # Check if the data is cached + cached_data = cache_store.read(endpoint) + expect(JSON.parse(cached_data)).to eq('status' => 'success') + end + end + + context 'when a connection failure occurs' do + it 'raises a Faraday::Error' do + endpoint = 'some-endpoint' + + # Simulate a Faraday connection failure + allow_any_instance_of(Faraday::Connection).to receive(:get) + .and_raise(Faraday::Error.new('Connection failed')) + + expect { client.get(endpoint) }.to raise_error(StandardError, 'Faraday error: Connection failed') + end + end + + context 'when force_refresh is true' do + it 'bypasses the cache and fetches fresh data' do + # Cache the response + cache_store.write(endpoint, response_body, 300) + + # Simulate a successful API response + stub_request(:get, "#{base_url}#{endpoint}") + .with( + headers: { + 'Accept' => 'application/json', + 'User-Agent' => 'ApiWrapperClient/1.0' + } + ) + .to_return(status: 200, body: response_body, headers: {}) + + result = client.get(endpoint, force_refresh: true) + expect(JSON.parse(result.body)).to eq('status' => 'success') + + # Ensure cache is still there but fresh data was fetched + cached_data = cache_store.read(endpoint) + expect(JSON.parse(cached_data)).to eq('status' => 'success') + end + end + end +end