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: +![image](https://github.com/user-attachments/assets/7b3f7d83-4b94-4e7a-b2c0-ad2452a09f84) -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