diff --git a/Gemfile b/Gemfile
index a5a0ff9..1313126 100644
--- a/Gemfile
+++ b/Gemfile
@@ -20,3 +20,11 @@ end
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
+
+gem 'csv'
+gem 'httparty'
+gem 'pry'
+gem 'rack'
+gem 'rack-test'
+gem 'rackup'
+gem 'sinatra'
diff --git a/Gemfile.lock b/Gemfile.lock
index 9ef1eb1..8988c22 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -2,18 +2,48 @@ GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
+ base64 (0.2.0)
+ bigdecimal (3.1.9)
byebug (11.1.3)
+ coderay (1.1.3)
+ csv (3.3.2)
diff-lcs (1.5.1)
+ httparty (0.22.0)
+ csv
+ mini_mime (>= 1.0.0)
+ multi_xml (>= 0.5.2)
json (2.9.1)
language_server-protocol (3.17.0.3)
+ logger (1.6.5)
+ method_source (1.1.0)
+ mini_mime (1.1.5)
+ multi_xml (0.7.1)
+ bigdecimal (~> 3.1)
+ mustermann (3.0.3)
+ ruby2_keywords (~> 0.0.1)
nio4r (2.7.4)
parallel (1.26.3)
parser (3.3.7.0)
ast (~> 2.4.1)
racc
+ pry (0.15.2)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
puma (6.5.0)
nio4r (~> 2.0)
racc (1.8.1)
+ rack (3.1.8)
+ rack-protection (4.1.1)
+ base64 (>= 0.1.0)
+ logger (>= 1.6.0)
+ rack (>= 3.0.0, < 4)
+ rack-session (2.1.0)
+ base64 (>= 0.1.0)
+ rack (>= 3.0.0)
+ rack-test (2.2.0)
+ rack (>= 1.3)
+ rackup (2.2.1)
+ rack (>= 3)
rainbow (3.1.1)
regexp_parser (2.10.0)
rspec (3.13.0)
@@ -42,6 +72,15 @@ GEM
rubocop-ast (1.37.0)
parser (>= 3.3.1.0)
ruby-progressbar (1.13.0)
+ ruby2_keywords (0.0.5)
+ sinatra (4.1.1)
+ logger (>= 1.6.0)
+ mustermann (~> 3.0)
+ rack (>= 3.0.0, < 4)
+ rack-protection (= 4.1.1)
+ rack-session (>= 2.0.0, < 3)
+ tilt (~> 2.0)
+ tilt (2.6.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -52,9 +91,16 @@ PLATFORMS
DEPENDENCIES
byebug
+ csv
+ httparty
+ pry
puma (~> 6.5)
+ rack
+ rack-test
+ rackup
rspec (~> 3.10)
rubocop
+ sinatra
tzinfo-data
RUBY VERSION
diff --git a/README.md b/README.md
index 8f4adba..0f15221 100644
--- a/README.md
+++ b/README.md
@@ -1,33 +1,47 @@
-# Backend Interview Starting Point
+
+ Local setup
-This repo will serve as a starting point for your code challenge. Feel free to change anything in order to complete it: Change framework, other tests, new gems etc.
+ 1. Install ruby: `$ rvm install 3.4.1`
+ 2. `$ cd .` or `$ cd ` to auto-create the rvm gemset
+ 3. Install bundler: `$ gem install bundler`
+ 4. Install the dependencies with bundler: `$ bundle install`
+
-## Get this repo
+### Code Structure and Purpose
-- Fork this repo
-- Clone your fork
+- **Controller to Service**: When a request is received, the controller delegates the task to the appropriate service. **Sinatra** is used to handle HTTP requests and responses in a lightweight manner.
+- **Service to External API**: The `CoffeeShopFinderService` interacts with an external API to retrieve data about coffee shops. It processes this data to find the closest coffee shops based on the provided coordinates.
+- **Response Handling**: After processing the request, the service returns the result to the controller, which then formats the response and sends it back to the client.
-## Prerequisites
-- Have RVM installed: https://letmegooglethat.com/?q=install+rvm+on+ubuntu
+### Testing
-## Local setup
-1. Install ruby: `$ rvm install 3.4.1`
-2. `$ cd .` or `$ cd ` to auto-create the rvm gemset
-3. Install bundler: `$ gem install bundler`
-4. Install the dependencies with bundler: `$ bundle install`
+- **Unit Tests**: The project is thoroughly tested using the `rspec` gem.
+- Run tests ➡️ `$ bundle exec rspec`
-## Run sample CLI command
-`$ bin/ruby-interview`
-## Run tests
-`$ bundle exec rspec`
-## Tools
-- Write HTTP APIs [rails](https://rubyonrails.org/) or [roda](https://roda.jeremyevans.net/documentation.html) or others
-- Write CLI tools [thor](http://whatisthor.com/) or [tty](https://ttytoolkit.org/) or others (including [rake](https://github.com/ruby/rake))
-- Test your code with [rspec](https://rspec.info/)
+# Usage
+#### Run CLI command to start the server
+`$ bin/start`
----
+#### Choose a provider to call the endpoind, I choose [Thunder Client](https://www.thunderclient.com/) inside VS Code. (Postman, Insomnia)
+`http://localhost:9292/api/closest_shops?lat=47.6&lon=-122.4`
+#### Should see a reponse similar to:
+
-Good luck!
+
+#### It can be also tested using the terminal
+```curl "http://localhost:9292/api/closest_shops?lat=47.6&lon=-122.4"```
+```
+Coffee shops nearest (47.6, -122.4) by distance:
+
+0.0645 <--> Starbucks Seattle2
+0.0861 <--> Starbucks Seattle
+10.0793 <--> Starbucks SF'
+```
+
+### Disclaimers
+
+- I thought about setting up a DB, querying the endpoint to seed it and then seeding it periodically.
+- But taking into the consideration the size of the csv file, the minimal number of requests I choose to prioritize always having the latest data and calling the endpoint through my CoffeeShopFinderService on each request."
diff --git a/app.rb b/app.rb
new file mode 100644
index 0000000..3776579
--- /dev/null
+++ b/app.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+require_relative 'config/environment'
diff --git a/app/controllers/coffee_shop_controller.rb b/app/controllers/coffee_shop_controller.rb
new file mode 100644
index 0000000..fb28f30
--- /dev/null
+++ b/app/controllers/coffee_shop_controller.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'sinatra/base'
+require 'json'
+require_relative '../services/coffee_shop_finder_service'
+
+class CoffeeShopController < Sinatra::Base
+ configure do
+ set :protection, except: [:host_authorization]
+ set :show_exceptions, false
+ set :raise_errors, true
+ enable :logging
+ end
+
+ before do
+ content_type :json
+ end
+
+ get '/closest_shops' do
+ content_type 'text/plain'
+ lat, lon = validate_coordinates!(params)
+
+ shops_with_distances = coffee_shop_finder_service.closest_shops(lat, lon)
+ format_response(shops_with_distances, lat, lon)
+ rescue StandardError => e
+ handle_error(e)
+ end
+
+ private
+
+ # Format shops into "Name --> distance <-- (user-lat, user_lon)" strings
+ def format_response(shops_with_distances, user_lat, user_lon)
+ header = "Coffee shops nearest (#{user_lat}, #{user_lon}) by distance:\n\n"
+
+ header + shops_with_distances.map do |shops_with_distance|
+ shop = shops_with_distance[:shop]
+ distance = shops_with_distance[:distance]
+
+ "#{distance} <--> #{shop.name}"
+ end.join("\n")
+ end
+
+ def validate_coordinates!(parameters)
+ error!(400, 'Invalid coordinates') unless parameters[:lat] && parameters[:lon]
+ error!(400, 'Coordinates must be numeric') unless numeric?(parameters[:lat]) && numeric?(parameters[:lon])
+
+ lat = parameters[:lat].to_f
+ lon = parameters[:lon].to_f
+ error!(400, 'Invalid coordinates') unless lat.between?(-90, 90) && lon.between?(-180, 180)
+
+ [lat, lon]
+ end
+
+ def numeric?(str)
+ return false unless str =~ /\A[-+]?[0-9]*\.?[0-9]+\Z/
+
+ Float(str)
+ end
+
+ # Handle errors with appropriate HTTP status codes
+ def handle_error(error)
+ status_code = case error.message
+ when /Invalid CSV/ then 400
+ when /Failed to fetch CSV/ then 502
+ else 500
+ end
+
+ status status_code
+ { error: error.message }.to_json
+ end
+
+ def error!(code, message)
+ halt code, { error: message }.to_json
+ end
+
+ def coffee_shop_finder_service
+ CoffeeShopFinderService.new
+ end
+end
diff --git a/app/models/coffee_shop.rb b/app/models/coffee_shop.rb
new file mode 100644
index 0000000..300bdc1
--- /dev/null
+++ b/app/models/coffee_shop.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class CoffeeShop
+ class InvalidCoordinatesError < StandardError; end
+
+ attr_reader :name, :latitude, :longitude
+
+ def initialize(name, latitude, longitude)
+ @name = validate_name(name)
+ @latitude = validate_coordinate(latitude.to_f, -90..90, 'Latitude')
+ @longitude = validate_coordinate(longitude.to_f, -180..180, 'Longitude')
+ end
+
+ def distance_to(user_lat, user_lon)
+ user_lat = validate_coordinate(user_lat.to_f, -90..90, 'User Latitude')
+ user_lon = validate_coordinate(user_lon.to_f, -180..180, 'User Longitude')
+
+ Math.sqrt(((user_lat - latitude)**2) + ((user_lon - longitude)**2)).round(4)
+ end
+
+ private
+
+ def validate_name(name)
+ name = name.to_s.strip
+ raise ArgumentError, 'Name cannot be empty' if name.empty?
+
+ name
+ end
+
+ def validate_coordinate(coord, range, name)
+ raise InvalidCoordinatesError, "#{name} must be a number" unless coord.is_a?(Numeric)
+ raise InvalidCoordinatesError, "#{name} out of range" unless range.include?(coord)
+
+ coord
+ end
+end
diff --git a/app/services/coffee_shop_finder_service.rb b/app/services/coffee_shop_finder_service.rb
new file mode 100644
index 0000000..52dc2f2
--- /dev/null
+++ b/app/services/coffee_shop_finder_service.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'csv'
+require 'httparty'
+
+class CoffeeShopFinderService
+ def closest_shops(user_lat, user_lon, limit = 3)
+ coffee_shops = fetch_and_parse_coffee_shops
+
+ # Used for faster distance retrieval
+ distances = Hash.new { |h, shop| h[shop] = shop.distance_to(user_lat, user_lon) }
+ closest_shops = coffee_shops.min_by(limit) { |shop| distances[shop] }
+
+ closest_shops.map { |shop| { shop: shop, distance: distances[shop] } }
+ end
+
+ private
+
+ def fetch_and_parse_coffee_shops
+ response = fetch_csv
+ parse_coffee_shops(response)
+ end
+
+ def parse_coffee_shops(response)
+ CSV.parse(response.body, headers: headers).map do |row|
+ validate_csv_row!(row)
+ CoffeeShop.new(row['Name'], row['Lat Coordinate'], row['Lon Coordinate'])
+ end
+ rescue CSV::MalformedCSVError => e
+ raise "Malformed CSV: #{e.message}"
+ end
+
+ def fetch_csv
+ url = ENV['CSV_URL'] || APP_CONFIG[:csv_url]
+ response = HTTParty.get(url)
+ raise "Failed to fetch CSV: #{response.code}" unless response.success?
+
+ response
+ end
+
+ # Validate CSV row structure
+ def validate_csv_row!(row)
+ missing = headers.reject { |h| row[h] }
+ raise "Invalid CSV headers: #{missing.join(', ')}" if missing.any?
+ end
+
+ def headers
+ ['Name', 'Lat Coordinate', 'Lon Coordinate']
+ end
+end
diff --git a/bin/ruby-interview b/bin/start
similarity index 56%
rename from bin/ruby-interview
rename to bin/start
index 57f8e8c..c939cab 100755
--- a/bin/ruby-interview
+++ b/bin/start
@@ -1,4 +1,4 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
-puts 'Good luck!'
\ No newline at end of file
+system('bundle exec rackup config.ru')
diff --git a/config.ru b/config.ru
new file mode 100644
index 0000000..28c5b99
--- /dev/null
+++ b/config.ru
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require_relative 'app'
+
+map '/api' do
+ run CoffeeShopController
+end
diff --git a/config/environment.rb b/config/environment.rb
new file mode 100644
index 0000000..ebc4e06
--- /dev/null
+++ b/config/environment.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'yaml'
+require 'logger'
+
+APP_CONFIG = YAML.load_file(File.join(__dir__, 'settings.yml')).transform_keys(&:to_sym)
+APP_LOGGER = Logger.new($stdout)
+
+Dir[File.join(__dir__, '../app/**/*.rb')].each { |file| require file }
diff --git a/config/settings.yml b/config/settings.yml
new file mode 100644
index 0000000..92a0ebb
--- /dev/null
+++ b/config/settings.yml
@@ -0,0 +1 @@
+csv_url: 'https://raw.githubusercontent.com/Agilefreaks/test_oop/master/coffee_shops.csv'
\ No newline at end of file
diff --git a/spec/controller/coffee_shop_controller_spec.rb b/spec/controller/coffee_shop_controller_spec.rb
new file mode 100644
index 0000000..7b658c4
--- /dev/null
+++ b/spec/controller/coffee_shop_controller_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'rack/test'
+require_relative '../../app/controllers/coffee_shop_controller'
+require_relative '../../app/services/coffee_shop_finder_service'
+
+RSpec.describe CoffeeShopController do # rubocop:disable Metrics/BlockLength
+ include Rack::Test::Methods
+
+ def app
+ @app ||= Rack::Builder.new do
+ map '/api' do
+ run CoffeeShopController
+ end
+ end
+ end
+
+ before(:all) do
+ CoffeeShopController.set :environment, :test
+ end
+
+ let(:valid_lat) { 40.7128 }
+ let(:valid_lon) { -74.0060 }
+ let(:invalid_coord) { 200.0 }
+
+ let(:mock_shops) do
+ [
+ { shop: double(name: 'Shop A', distance: 0.5), distance: 0.5 },
+ { shop: double(name: 'Shop B', distance: 1.2), distance: 1.2 }
+ ]
+ end
+
+ before do
+ allow_any_instance_of(CoffeeShopController)
+ .to receive(:format_response) do |_instance, _shops_with_distances, lat, lon|
+ "Coffee shops nearest (#{lat}, #{lon}) by distance:\n\n0.5 <--> Shop A\n1.2 <--> Shop B"
+ end
+ end
+
+ describe 'GET /api/closest_shops' do # rubocop:disable Metrics/BlockLength
+ context 'with valid coordinates' do
+ before do
+ allow(CoffeeShopFinderService).to receive_message_chain(:new, :closest_shops).and_return(mock_shops)
+ end
+
+ it 'returns a 200 OK' do
+ get '/api/closest_shops', lat: valid_lat, lon: valid_lon
+ expect(last_response.status).to eq(200)
+ expect(last_response.content_type).to include('text/plain')
+ expect(last_response.body).to include('0.5 <--> Shop A')
+ expect(last_response.body).to include('1.2 <--> Shop B')
+ end
+ end
+
+ context 'with invalid coordinates' do
+ it 'returns 400 for missing lat' do
+ get '/api/closest_shops', lon: valid_lon
+ expect(last_response.status).to eq(400)
+ expect(JSON.parse(last_response.body)).to include('error' => 'Invalid coordinates')
+ end
+
+ it 'returns 400 for non-numeric lat' do
+ get '/api/closest_shops', lat: 'abc', lon: valid_lon
+ expect(last_response.status).to eq(400)
+ end
+
+ it 'returns 400 for out-of-range lat' do
+ get '/api/closest_shops', lat: invalid_coord, lon: valid_lon
+ expect(last_response.status).to eq(400)
+ end
+ end
+
+ context 'when service raises errors' do
+ it 'returns 502 for CSV fetch failure' do
+ allow(CoffeeShopFinderService).to receive_message_chain(:new, :closest_shops)
+ .and_raise(StandardError.new('Failed to fetch CSV'))
+
+ get '/api/closest_shops', lat: valid_lat, lon: valid_lon
+ expect(last_response.status).to eq(502)
+ end
+
+ it 'returns 400 for invalid CSV data' do
+ allow(CoffeeShopFinderService).to receive_message_chain(:new, :closest_shops)
+ .and_raise(StandardError.new('Invalid CSV'))
+
+ get '/api/closest_shops', lat: valid_lat, lon: valid_lon
+ expect(last_response.status).to eq(400)
+ end
+
+ it 'returns 500 for unexpected errors' do
+ allow(CoffeeShopFinderService).to receive_message_chain(:new, :closest_shops)
+ .and_raise(StandardError.new('Unknown error'))
+
+ get '/api/closest_shops', lat: valid_lat, lon: valid_lon
+ expect(last_response.status).to eq(500)
+ end
+ end
+ end
+end
diff --git a/spec/models/coffee_shop_spec.rb b/spec/models/coffee_shop_spec.rb
new file mode 100644
index 0000000..6748200
--- /dev/null
+++ b/spec/models/coffee_shop_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require_relative '../../app/models/coffee_shop'
+
+RSpec.describe CoffeeShop do
+ describe '#initialize' do
+ it 'creates a valid coffee shop' do
+ shop = CoffeeShop.new('Test Shop', 45.0, -122.0)
+
+ expect(shop.name).to eq('Test Shop')
+ expect(shop.latitude).to eq(45.0)
+ expect(shop.longitude).to eq(-122.0)
+ end
+
+ it 'raises error for invalid coordinates' do
+ expect { CoffeeShop.new('Test', 100.0, 0) }.to raise_error(CoffeeShop::InvalidCoordinatesError)
+ expect { CoffeeShop.new('Test', 0, 200.0) }.to raise_error(CoffeeShop::InvalidCoordinatesError)
+ end
+
+ it 'raises error for empty name' do
+ expect { CoffeeShop.new('', 0, 0) }.to raise_error(ArgumentError)
+ end
+ end
+
+ describe '#distance_to' do
+ let(:shop) { CoffeeShop.new('Test', 0.0, 0.0) }
+
+ it 'calculates distance correctly' do
+ expect(shop.distance_to(3.0, 4.0)).to eq(5.0)
+ expect(shop.distance_to(1.0, 1.0)).to eq(1.4142)
+ end
+
+ it 'raises error for invalid user coordinates' do
+ expect { shop.distance_to(95.0, 0) }.to raise_error(CoffeeShop::InvalidCoordinatesError)
+ end
+ end
+end
diff --git a/spec/services/coffee_shop_finder_service_spec.rb b/spec/services/coffee_shop_finder_service_spec.rb
new file mode 100644
index 0000000..3f44a36
--- /dev/null
+++ b/spec/services/coffee_shop_finder_service_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require_relative '../../app/services/coffee_shop_finder_service'
+require_relative '../../app/models/coffee_shop'
+
+RSpec.describe CoffeeShopFinderService do # rubocop:disable RSpec/BlockLength
+ let(:finder) { CoffeeShopFinderService.new }
+
+ context '#closest_shops' do # rubocop:disable RSpec/BlockLength
+ subject { finder.closest_shops(user_lat, user_lon) }
+
+ let(:user_lat) { 40.7128 }
+ let(:user_lon) { -74.0060 }
+
+ let(:shop_same) { CoffeeShop.new('Same Location', user_lat, user_lon) } # 0.0 distance
+ let(:shop_near) { CoffeeShop.new('Starbucks Seattle', 47.5869, -122.316) }
+ let(:shop_mid) { CoffeeShop.new('Starbucks Moscow', 55.752047, 37.595242) }
+ let(:shop_far) { CoffeeShop.new('Starbucks Sydney', -33.871843, 151.206767) }
+ let(:all_shops) { [shop_far, shop_near, shop_mid, shop_same] }
+
+ before do
+ allow_any_instance_of(CoffeeShopFinderService)
+ .to receive(:fetch_and_parse_coffee_shops)
+ .and_return(all_shops)
+ end
+
+ describe 'limit functionality' do
+ it 'returns the specified number of shops' do
+ results = finder.closest_shops(user_lat, user_lon, 2)
+ expect(results.size).to eq(2)
+ end
+
+ it 'returns all shops if limit is greater than the number of shops' do
+ results = finder.closest_shops(user_lat, user_lon, 20)
+ expect(results.size).to eq(4)
+ end
+ end
+
+ describe 'normal functionality' do
+ it 'returns shops in ascending order' do
+ expect(subject).to eq(
+ [
+ { shop: shop_same, distance: 0.0 },
+ { shop: shop_near, distance: 48.7966 },
+ { shop: shop_mid, distance: 112.61 }
+ ]
+ )
+ end
+ end
+
+ describe 'edge cases' do
+ context 'with empty shop list' do
+ it 'returns empty array' do
+ allow_any_instance_of(CoffeeShopFinderService)
+ .to receive(:fetch_and_parse_coffee_shops).and_return([])
+
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'with identical locations' do
+ let(:dupe_shop) { CoffeeShop.new('Dupe', user_lat, user_lon) }
+
+ it 'returns all matching shops' do
+ allow_any_instance_of(CoffeeShopFinderService)
+ .to receive(:fetch_and_parse_coffee_shops).and_return([shop_same, dupe_shop])
+
+ expect(subject.size).to eq(2)
+ expect(subject.map { |r| r[:distance] }).to all(eq(0.0))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 6a8a99f..8800722 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -17,6 +17,8 @@
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
require 'byebug'
+require 'pry'
+require 'rack/test'
RSpec.configure do |config|
# rspec-expectations config goes here. You can use an alternate
@@ -94,4 +96,5 @@
# test failures related to randomization by passing the same `--seed` value
# as the one that triggered the failure.
Kernel.srand config.seed
+ config.include Rack::Test::Methods
end