From cbd8280822805692f734c06b8aeba236b90ce0e5 Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Wed, 3 Jul 2024 13:51:07 -0600 Subject: [PATCH 01/10] Start work on connection using Faraday for now --- lib/base_command.rb | 11 +++++++++ lib/client/request.rb | 21 +++++++++++++++++ lib/rides/commands/compute_amount.rb | 9 +++++++ lib/rides/commands/get_direction_data.rb | 30 ++++++++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 lib/base_command.rb create mode 100644 lib/client/request.rb create mode 100644 lib/rides/commands/compute_amount.rb create mode 100644 lib/rides/commands/get_direction_data.rb diff --git a/lib/base_command.rb b/lib/base_command.rb new file mode 100644 index 0000000..816afc3 --- /dev/null +++ b/lib/base_command.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class BaseCommand + def self.call(**args) + new.call(args) + end + + def call(**args) + raise NotImplementedError, "Must define #call method" + end +end diff --git a/lib/client/request.rb b/lib/client/request.rb new file mode 100644 index 0000000..8a15987 --- /dev/null +++ b/lib/client/request.rb @@ -0,0 +1,21 @@ +module Client + class Request + CONNECTION = Faraday + private_constant :CONNECTION + + def self.connection(url, params = {}, headers = {}) + new(url, params, headers) + end + + def self.get(url, params = nil, headers = nil) + connection.get(url, params, headers) + end + + attr_reader :connection + private :connection + + private def initialize(url, params, headers) + @connection = CONNECTION.new(url, params:, headers:) + end + end +end diff --git a/lib/rides/commands/compute_amount.rb b/lib/rides/commands/compute_amount.rb new file mode 100644 index 0000000..ba5fdc0 --- /dev/null +++ b/lib/rides/commands/compute_amount.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Rides + module Commands + class ComputeAmount < BaseCommand + def call(ride); end + end + end +end diff --git a/lib/rides/commands/get_direction_data.rb b/lib/rides/commands/get_direction_data.rb new file mode 100644 index 0000000..96587e6 --- /dev/null +++ b/lib/rides/commands/get_direction_data.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Rides + module Commands + class GetDirectionData < BaseCommand + COMPUTE_ROUTES_URL = "/v2:computeRoutes" + DEFAULT_HEADERS = { "X-Goog-FieldMask" => "routes.distanceMeters,routes.duration" }.freeze + DEFAULT_PARAMS = { key: ENV["GOOGLE_API_KEY"], routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE" }.freeze + + def call(ride) + data = get_direction_data_for_ride(ride) + end + + private def connection + @connection ||= Client::Request.connection( + DIRECTIONS_API_URL, + DEFAULT_PARAMS, + DEFAULT_HEADERS + ) + end + + private def get_direction_data_for_ride(ride) + to_address = ride.to_address + from_address = ride.from_address + + connection.get(COMPUTE_ROUTES_URL) + end + end + end +end From ee75fd347d3c475a1e518a29522bf5a11a6b2609 Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Wed, 3 Jul 2024 13:55:30 -0600 Subject: [PATCH 02/10] Update to use post --- lib/client/request.rb | 6 +++--- lib/rides/commands/get_direction_data.rb | 9 +++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/client/request.rb b/lib/client/request.rb index 8a15987..36e2597 100644 --- a/lib/client/request.rb +++ b/lib/client/request.rb @@ -1,14 +1,14 @@ module Client class Request - CONNECTION = Faraday + CONNECTION = Faraday.freeze private_constant :CONNECTION def self.connection(url, params = {}, headers = {}) new(url, params, headers) end - def self.get(url, params = nil, headers = nil) - connection.get(url, params, headers) + def self.post(url, body = nil, headers = nil) + connection.post(url, body.to_json, headers) end attr_reader :connection diff --git a/lib/rides/commands/get_direction_data.rb b/lib/rides/commands/get_direction_data.rb index 96587e6..8e42607 100644 --- a/lib/rides/commands/get_direction_data.rb +++ b/lib/rides/commands/get_direction_data.rb @@ -3,9 +3,10 @@ module Rides module Commands class GetDirectionData < BaseCommand + DIRECTIONS_API_URL = "https://routes.googleapis.com/directions" COMPUTE_ROUTES_URL = "/v2:computeRoutes" DEFAULT_HEADERS = { "X-Goog-FieldMask" => "routes.distanceMeters,routes.duration" }.freeze - DEFAULT_PARAMS = { key: ENV["GOOGLE_API_KEY"], routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE" }.freeze + DEFAULT_PARAMS = { key: ENV["GOOGLE_API_KEY"] }.freeze def call(ride) data = get_direction_data_for_ride(ride) @@ -22,8 +23,12 @@ def call(ride) private def get_direction_data_for_ride(ride) to_address = ride.to_address from_address = ride.from_address + body = { + origin: from_address.full_address, destination: to_address.full_address, + routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE" + } - connection.get(COMPUTE_ROUTES_URL) + connection.post(COMPUTE_ROUTES_URL, body) end end end From 5215afb707124eda02a9035a88c530aadc28b60f Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Wed, 3 Jul 2024 14:38:57 -0600 Subject: [PATCH 03/10] Add command to call google api for route info --- lib/base_command.rb | 2 +- lib/client/request.rb | 6 +++--- lib/rides/commands/get_direction_data.rb | 18 ++++++++++-------- .../rides/commands/get_direction_data_spec.rb | 19 +++++++++++++++++++ 4 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 spec/lib/rides/commands/get_direction_data_spec.rb diff --git a/lib/base_command.rb b/lib/base_command.rb index 816afc3..7842910 100644 --- a/lib/base_command.rb +++ b/lib/base_command.rb @@ -2,7 +2,7 @@ class BaseCommand def self.call(**args) - new.call(args) + new.call(**args) end def call(**args) diff --git a/lib/client/request.rb b/lib/client/request.rb index 36e2597..9bc87f6 100644 --- a/lib/client/request.rb +++ b/lib/client/request.rb @@ -1,13 +1,13 @@ module Client class Request - CONNECTION = Faraday.freeze + CONNECTION = Faraday private_constant :CONNECTION - def self.connection(url, params = {}, headers = {}) + def self.connection(url:, params: {}, headers: {}) new(url, params, headers) end - def self.post(url, body = nil, headers = nil) + def post(url, body, headers = nil) connection.post(url, body.to_json, headers) end diff --git a/lib/rides/commands/get_direction_data.rb b/lib/rides/commands/get_direction_data.rb index 8e42607..b2fe2c9 100644 --- a/lib/rides/commands/get_direction_data.rb +++ b/lib/rides/commands/get_direction_data.rb @@ -5,18 +5,20 @@ module Commands class GetDirectionData < BaseCommand DIRECTIONS_API_URL = "https://routes.googleapis.com/directions" COMPUTE_ROUTES_URL = "/v2:computeRoutes" - DEFAULT_HEADERS = { "X-Goog-FieldMask" => "routes.distanceMeters,routes.duration" }.freeze - DEFAULT_PARAMS = { key: ENV["GOOGLE_API_KEY"] }.freeze + DEFAULT_HEADERS = { + "X-Goog-FieldMask" => "routes.distanceMeters,routes.duration", + "X-goog-api-key" => ENV["GOOGLE_API_KEY"], + "Content-Type" => "application/json" + }.freeze - def call(ride) + def call(ride:) data = get_direction_data_for_ride(ride) end private def connection @connection ||= Client::Request.connection( - DIRECTIONS_API_URL, - DEFAULT_PARAMS, - DEFAULT_HEADERS + url: DIRECTIONS_API_URL, + headers: DEFAULT_HEADERS ) end @@ -24,11 +26,11 @@ def call(ride) to_address = ride.to_address from_address = ride.from_address body = { - origin: from_address.full_address, destination: to_address.full_address, + origin: { placeId: from_address.place_id }, destination: { placeId: to_address.place_id }, routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE" } - connection.post(COMPUTE_ROUTES_URL, body) + connection.post(DIRECTIONS_API_URL + COMPUTE_ROUTES_URL, body) end end end diff --git a/spec/lib/rides/commands/get_direction_data_spec.rb b/spec/lib/rides/commands/get_direction_data_spec.rb new file mode 100644 index 0000000..953f86e --- /dev/null +++ b/spec/lib/rides/commands/get_direction_data_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe Rides::Commands::GetDirectionData do + it "gets data for an address" do + VCR.use_cassette("first_ride_directions") do + from_address = create( + :address, :with_out_place_id, line_1: "711 Oval Drive", city: "Fort Collins", state: "CO", + zip_code: "80521" + ) + to_address = create( + :address, :with_out_place_id, line_1: "151 N College Ave", city: "Fort Collins", state: "CO", + zip_code: "80524" + ) + ride = create(:ride, from_address:, to_address:) + data = described_class.call(ride:) + binding.pry + end + end +end From 2241626324f9bd4a463d561f6ab8a7e64956a48c Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Wed, 3 Jul 2024 15:46:09 -0600 Subject: [PATCH 04/10] remove constraints around null values for rides --- db/migrate/20240702182238_create_addresses.rb | 4 +++- db/migrate/20240702193305_create_rides.rb | 6 +++--- db/schema.rb | 11 ++++++----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/db/migrate/20240702182238_create_addresses.rb b/db/migrate/20240702182238_create_addresses.rb index c0c110a..9366954 100644 --- a/db/migrate/20240702182238_create_addresses.rb +++ b/db/migrate/20240702182238_create_addresses.rb @@ -10,10 +10,12 @@ def change t.string :zip_code, index: true, null: false t.float :latitude, null: false t.float :longitude, null: false - t.string :place_id, null: true, index: true + t.string :place_id, null: false t.timestamps end add_index :addresses, %i[city state] + add_index :addresses, %i[line_1 line_2 zip_code], unique: true + add_index :addresses, :place_id, unique: true end end diff --git a/db/migrate/20240702193305_create_rides.rb b/db/migrate/20240702193305_create_rides.rb index 78a8251..7c68f1d 100644 --- a/db/migrate/20240702193305_create_rides.rb +++ b/db/migrate/20240702193305_create_rides.rb @@ -1,9 +1,9 @@ class CreateRides < ActiveRecord::Migration[7.1] def change create_table :rides do |t| - t.float :duration, index: true, null: false - t.float :distance, index: true, null: false - t.float :commute_duration, index: true, null: false + t.float :duration, index: true + t.float :distance, index: true + t.float :commute_duration, index: true t.monetize :amount t.references :driver, null: true, index: true t.references :from_address, foreign_key: {to_table: :addresses}, index: true, null: false diff --git a/db/schema.rb b/db/schema.rb index c9ad6d7..4b02b60 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -22,11 +22,12 @@ t.string "zip_code", null: false t.float "latitude", null: false t.float "longitude", null: false - t.string "place_id" + t.string "place_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["city", "state"], name: "index_addresses_on_city_and_state" - t.index ["place_id"], name: "index_addresses_on_place_id" + t.index ["line_1", "line_2", "zip_code"], name: "index_addresses_on_line_1_and_line_2_and_zip_code", unique: true + t.index ["place_id"], name: "index_addresses_on_place_id", unique: true t.index ["state"], name: "index_addresses_on_state" t.index ["zip_code"], name: "index_addresses_on_zip_code" end @@ -50,9 +51,9 @@ end create_table "rides", force: :cascade do |t| - t.float "duration", null: false - t.float "distance", null: false - t.float "commute_duration", null: false + t.float "duration" + t.float "distance" + t.float "commute_duration" t.integer "amount_cents", default: 0, null: false t.string "amount_currency", default: "USD", null: false t.bigint "driver_id" From ef2b0e931b5b8748da623e2d4108f9b4c9d9f2d1 Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Wed, 3 Jul 2024 19:46:51 -0600 Subject: [PATCH 05/10] Implement pojo to return data for given rides --- app/models/ride.rb | 20 ++++++- lib/rides/commands/get_direction_data.rb | 54 ++++++++++++++----- spec/factories/rides.rb | 8 +-- .../rides/commands/get_direction_data_spec.rb | 9 ++-- 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/app/models/ride.rb b/app/models/ride.rb index eea732c..ae4bc12 100644 --- a/app/models/ride.rb +++ b/app/models/ride.rb @@ -5,10 +5,9 @@ class Ride < ApplicationRecord belongs_to :from_address, class_name: "Address" belongs_to :to_address, class_name: "Address" - validates :duration, :distance, :commute_duration, :amount_cents, presence: true + validates :duration, :distance, :commute_duration, :amount_cents, presence: true, on: :update, if: :should_validate? monetize :amount_cents, as: :amount, - allow_nil: false, numericality: { greater_than_or_equal_to: 0 } @@ -16,4 +15,21 @@ class Ride < ApplicationRecord scope :by_address, ->(address_id) { where(from_address_id: address_id).or(where(to_address_id: address_id)) } + scope :selectable, -> { + includes(:from_address, :to_address) + .select(:id, :from_address_id, :to_address_id) + .where(driver_id: nil, duration: nil, distance: nil, commute_duration: nil, amount_cents: 0) + } + + def origin_place_id + from_address.place_id + end + + def destination_place_id + to_address.place_id + end + + private def should_validate? + duration.present? && distance.present? && commute_duration.present? && amount_cents.present? + end end diff --git a/lib/rides/commands/get_direction_data.rb b/lib/rides/commands/get_direction_data.rb index b2fe2c9..2b9742e 100644 --- a/lib/rides/commands/get_direction_data.rb +++ b/lib/rides/commands/get_direction_data.rb @@ -2,17 +2,31 @@ module Rides module Commands + # Makes a request to the Google API to obtain the route information class GetDirectionData < BaseCommand - DIRECTIONS_API_URL = "https://routes.googleapis.com/directions" - COMPUTE_ROUTES_URL = "/v2:computeRoutes" + DIRECTIONS_API_URL = "https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix" + COMPUTE_ROUTES_URL = "v2:computeRouteMatrix" DEFAULT_HEADERS = { - "X-Goog-FieldMask" => "routes.distanceMeters,routes.duration", + "X-Goog-FieldMask" => "originIndex,destinationIndex,status,condition,distanceMeters,duration", "X-goog-api-key" => ENV["GOOGLE_API_KEY"], "Content-Type" => "application/json" }.freeze - def call(ride:) - data = get_direction_data_for_ride(ride) + def call(rides:) + data = get_direction_data_for_ride(rides) + + results(data) + end + + # Returns a list of objects, with attributes of + # @param[:distance_in_meters] = Integer + # @param[:duration] = String, e.g., "577s" + # Duration is in seconds + private def results(data) + data = data.select { _1[:originIndex] == _1[:destinationIndex] } + data = transform_keys!(data).map { |hash| hash.slice(:distance_meters, :duration) } + + data.map { OpenStruct.new(**_1) } end private def connection @@ -22,15 +36,29 @@ def call(ride:) ) end - private def get_direction_data_for_ride(ride) - to_address = ride.to_address - from_address = ride.from_address - body = { - origin: { placeId: from_address.place_id }, destination: { placeId: to_address.place_id }, - routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE" - } + private def get_direction_data_for_ride(rides) + body = build_request_body(rides) + + response = connection.post( + DIRECTIONS_API_URL, + body.merge(routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE") + ) + + JSON.parse(response.body, symbolize_names: true) + end + + private def build_request_body(rides) + rides.each_with_object({}) do |ride, acc| + acc[:origins] ||= [] + acc[:destinations] ||= [] + + acc[:origins] << { waypoint: { placeId: ride.origin_place_id } } + acc[:destinations] << { waypoint: { placeId: ride.destination_place_id } } + end + end - connection.post(DIRECTIONS_API_URL + COMPUTE_ROUTES_URL, body) + private def transform_keys!(data) + data.map { |d| d.transform_keys { |k| k.to_s.underscore.to_sym } } end end end diff --git a/spec/factories/rides.rb b/spec/factories/rides.rb index 554a575..93c5835 100644 --- a/spec/factories/rides.rb +++ b/spec/factories/rides.rb @@ -2,10 +2,10 @@ FactoryBot.define do factory :ride do - duration { 2.3 } - commute_duration { 1.0 } - distance { 30.1 } - amount_cents { 1200 } + duration { nil } + commute_duration { nil } + distance { nil } + amount_cents { 0 } driver { nil } diff --git a/spec/lib/rides/commands/get_direction_data_spec.rb b/spec/lib/rides/commands/get_direction_data_spec.rb index 953f86e..7e56324 100644 --- a/spec/lib/rides/commands/get_direction_data_spec.rb +++ b/spec/lib/rides/commands/get_direction_data_spec.rb @@ -11,9 +11,12 @@ :address, :with_out_place_id, line_1: "151 N College Ave", city: "Fort Collins", state: "CO", zip_code: "80524" ) - ride = create(:ride, from_address:, to_address:) - data = described_class.call(ride:) - binding.pry + create_list(:ride, 2, from_address:, to_address:) + rides = Ride.selectable + data = described_class.call(rides:) + + expect(data.length).to eq(2) + expect(data.all? { _1.distance_in_meters == 3105 && _1.duration == "577s" }) end end end From 72ebebd94a034fd64c85bbdadc7fefd395c71600 Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Wed, 3 Jul 2024 19:51:59 -0600 Subject: [PATCH 06/10] Update to include ride in return objects --- lib/rides/commands/get_direction_data.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/rides/commands/get_direction_data.rb b/lib/rides/commands/get_direction_data.rb index 2b9742e..cbde339 100644 --- a/lib/rides/commands/get_direction_data.rb +++ b/lib/rides/commands/get_direction_data.rb @@ -14,19 +14,22 @@ class GetDirectionData < BaseCommand def call(rides:) data = get_direction_data_for_ride(rides) - - results(data) + binding.pry + results(data, rides) end # Returns a list of objects, with attributes of # @param[:distance_in_meters] = Integer # @param[:duration] = String, e.g., "577s" # Duration is in seconds - private def results(data) + private def results(data, rides) + # The response keeps the array positioning on the return. Since we're getting a matrix + # of routes back, we only want the ones where we explicitly have a 'Ride'. This means that + # we want the computations where the indicies match. data = data.select { _1[:originIndex] == _1[:destinationIndex] } - data = transform_keys!(data).map { |hash| hash.slice(:distance_meters, :duration) } + data = transform_keys!(data) - data.map { OpenStruct.new(**_1) } + data.map.with_index { OpenStruct.new(ride: rides[_2], **_1) } end private def connection From 13afe1668d35e65e3f23171161b055dacf1ec3af Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Wed, 3 Jul 2024 20:45:00 -0600 Subject: [PATCH 07/10] Add functionality to get route duration for commuting the rides from address --- app/models/driver.rb | 6 +- lib/rides/commands/get_commute_duration.rb | 25 +++++++ lib/rides/commands/get_routes_data.rb | 66 +++++++++++++++++++ spec/factories/drivers.rb | 2 + .../commands/get_commute_duration_spec.rb | 23 +++++++ .../rides/commands/get_routes_data_spec.rb | 22 +++++++ spec/rails_helper.rb | 7 +- 7 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 lib/rides/commands/get_commute_duration.rb create mode 100644 lib/rides/commands/get_routes_data.rb create mode 100644 spec/lib/rides/commands/get_commute_duration_spec.rb create mode 100644 spec/lib/rides/commands/get_routes_data_spec.rb diff --git a/app/models/driver.rb b/app/models/driver.rb index 9c00472..145f47a 100644 --- a/app/models/driver.rb +++ b/app/models/driver.rb @@ -7,7 +7,11 @@ class Driver < ApplicationRecord class_name: "DriverAddress", dependent: :destroy, inverse_of: :driver - has_one :current_address, through: :current_driver_address, source: :driver, dependent: :destroy + has_one :current_address, through: :current_driver_address, source: :address, dependent: :destroy validates :first_name, :last_name, presence: true + + def origin_place_id + current_address.place_id + end end diff --git a/lib/rides/commands/get_commute_duration.rb b/lib/rides/commands/get_commute_duration.rb new file mode 100644 index 0000000..f37ea78 --- /dev/null +++ b/lib/rides/commands/get_commute_duration.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Rides + module Commands + # Computes the duration of the commute for the ride. + # This is used in the ranking for the rides that the driver will + # choose from. + # Returns a list of objects where the origin is the driver's current address(home) + class GetCommuteDuration < BaseCommand + def call(rides:, driver:) + commute_rides = convert_rides(rides, driver) + GetRoutesData.call(rides: commute_rides) + end + + # Converts the Driver's current home address and + # the Ride#from_address into structs that can be used to + # obtain the route information + private def convert_rides(rides, driver) + rides.each_with_object([]) do |ride, acc| + acc << OpenStruct.new(origin_place_id: driver.origin_place_id, destination_place_id: ride.origin_place_id) + end + end + end + end +end diff --git a/lib/rides/commands/get_routes_data.rb b/lib/rides/commands/get_routes_data.rb new file mode 100644 index 0000000..452e105 --- /dev/null +++ b/lib/rides/commands/get_routes_data.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Rides + module Commands + # Makes a request to the Google API to obtain the route information + class GetRoutesData < BaseCommand + DIRECTIONS_API_URL = "https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix" + DEFAULT_HEADERS = { + "X-Goog-FieldMask" => "originIndex,destinationIndex,status,condition,distanceMeters,duration", + "X-goog-api-key" => ENV["GOOGLE_API_KEY"], + "Content-Type" => "application/json" + }.freeze + + def call(rides:) + data = get_direction_data_for_ride(rides) + results(data, rides) + end + + # Returns a list of objects, with attributes of + # @param[:distance_in_meters] = Integer + # @param[:duration] = String, e.g., "577s" + # Duration is in seconds + private def results(data, rides) + # The response keeps the array positioning on the return. Since we're getting a matrix + # of routes back, we only want the ones where we explicitly have a 'Ride'. This means that + # we want the computations where the indicies match. + data = data.select { _1[:originIndex] == _1[:destinationIndex] } + data = transform_keys!(data) + + data.map.with_index { OpenStruct.new(ride: rides[_2], **_1) } + end + + private def connection + @connection ||= Client::Request.connection( + url: DIRECTIONS_API_URL, + headers: DEFAULT_HEADERS + ) + end + + private def get_direction_data_for_ride(rides) + body = build_request_body(rides) + + response = connection.post( + DIRECTIONS_API_URL, + body.merge(routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE") + ) + + JSON.parse(response.body, symbolize_names: true) + end + + private def build_request_body(rides) + rides.each_with_object({}) do |ride, acc| + acc[:origins] ||= [] + acc[:destinations] ||= [] + + acc[:origins] << { waypoint: { placeId: ride.origin_place_id } } + acc[:destinations] << { waypoint: { placeId: ride.destination_place_id } } + end + end + + private def transform_keys!(data) + data.map { |d| d.transform_keys { |k| k.to_s.underscore.to_sym } } + end + end + end +end diff --git a/spec/factories/drivers.rb b/spec/factories/drivers.rb index 34d7220..3cb805c 100644 --- a/spec/factories/drivers.rb +++ b/spec/factories/drivers.rb @@ -4,5 +4,7 @@ factory :driver do first_name { Faker::Name.first_name } last_name { Faker::Name.last_name } + + association :current_address, factory: :address end end diff --git a/spec/lib/rides/commands/get_commute_duration_spec.rb b/spec/lib/rides/commands/get_commute_duration_spec.rb new file mode 100644 index 0000000..5e26dfb --- /dev/null +++ b/spec/lib/rides/commands/get_commute_duration_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe Rides::Commands::GetCommuteDuration do + it "gets data for an address" do + VCR.use_cassette("first_commute") do + from_address = create( + :address, :with_out_place_id, line_1: "4705 Weitzel Street", city: "Timnath", state: "CO", + zip_code: "80547" + ) + to_address = create( + :address, :with_out_place_id, line_1: "151 N College Ave", city: "Fort Collins", state: "CO", + zip_code: "80524" + ) + create_list(:ride, 2, from_address:, to_address:) + driver = create(:driver, current_address: from_address) + rides = Ride.selectable + data = described_class.call(rides:, driver:) + + expect(data.length).to eq(2) + expect(data.all? { _1.duration == "577s" }) + end + end +end diff --git a/spec/lib/rides/commands/get_routes_data_spec.rb b/spec/lib/rides/commands/get_routes_data_spec.rb new file mode 100644 index 0000000..aba66cf --- /dev/null +++ b/spec/lib/rides/commands/get_routes_data_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe Rides::Commands::GetRoutesData do + it "gets data for an address" do + VCR.use_cassette("first_ride_directions") do + from_address = create( + :address, :with_out_place_id, line_1: "711 Oval Drive", city: "Fort Collins", state: "CO", + zip_code: "80521" + ) + to_address = create( + :address, :with_out_place_id, line_1: "151 N College Ave", city: "Fort Collins", state: "CO", + zip_code: "80524" + ) + create_list(:ride, 2, from_address:, to_address:) + rides = Ride.selectable + data = described_class.call(rides:) + + expect(data.length).to eq(2) + expect(data.all? { _1.distance_in_meters == 3105 && _1.duration == "577s" }) + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 31cf0eb..0a46939 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -13,9 +13,10 @@ VCR.configure do |config| config.cassette_library_dir = "spec/cassettes" config.hook_into :webmock - config.default_cassette_options = { - match_requests_on: %i[method uri] - } + config.debug_logger = File.open("log/vcr_debug.log", "w") + # config.default_cassette_options = { + # match_requests_on: %i[method uri] + # } end # Add additional requires below this line. Rails is not loaded until this point! From e63e027339e0d97c350d1f33c4aadc606587c135 Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Thu, 4 Jul 2024 09:44:11 -0600 Subject: [PATCH 08/10] Add functionality to compute the amount for a ride --- .rubocop.yml | 5 ++ app/models/address.rb | 4 +- lib/rides/commands/compute_amount.rb | 37 +++++++++- lib/rides/commands/get_direction_data.rb | 68 ------------------- lib/rides/commands/get_routes_data.rb | 3 +- .../lib/rides/commands/compute_amount_spec.rb | 23 +++++++ .../rides/commands/get_direction_data_spec.rb | 22 ------ 7 files changed, 69 insertions(+), 93 deletions(-) delete mode 100644 lib/rides/commands/get_direction_data.rb create mode 100644 spec/lib/rides/commands/compute_amount_spec.rb delete mode 100644 spec/lib/rides/commands/get_direction_data_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index a96bb10..65750c4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -47,6 +47,11 @@ Naming/VariableNumber: EnforcedStyle: snake_case Rails/InverseOf: Enabled: false +Rails/UniqueValidationWithoutIndex: + Description: "We will enforce this via PR review. This gives false positives at the moment." + Details: "https://github.com/rubocop/rubocop-rails/issues/221" + Enabled: true + Severity: warning Style/AccessModifierDeclarations: EnforcedStyle: inline Style/Documentation: diff --git a/app/models/address.rb b/app/models/address.rb index ee165c4..7bfc420 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -6,6 +6,8 @@ class Address < ApplicationRecord has_many :ride_destinations, class_name: "Ride", foreign_key: "to_address_id", dependent: nil, inverse_of: :to_address validates :line_1, :city, :state, :zip_code, :place_id, :latitude, :longitude, presence: true + validates :place_id, uniqueness: true + validates :zip_code, uniqueness: { scope: %i[line_1 line_2] } geocoded_by :full_address do |obj, results| if (geo = results.first) @@ -17,7 +19,7 @@ class Address < ApplicationRecord before_validation :geocode, if: ->(obj) { - obj.full_address.present? && %i[line_1 city state zip_code place_id latitude + obj.full_address.present? && %i[line_1 line_2 city state zip_code place_id latitude longitude].any? do obj.send("#{_1}_changed?") end diff --git a/lib/rides/commands/compute_amount.rb b/lib/rides/commands/compute_amount.rb index ba5fdc0..19cf067 100644 --- a/lib/rides/commands/compute_amount.rb +++ b/lib/rides/commands/compute_amount.rb @@ -3,7 +3,42 @@ module Rides module Commands class ComputeAmount < BaseCommand - def call(ride); end + BASE_PAY_AMOUNT = Money.new(12_00) + MILEAGE_BONUS_AMOUNT = Money.new(1_50) + MILEAGE_BONUS_CLIFF = 5.0 + DURATION_BONUS_AMOUNT = Money.new(0.7) + DURATION_BONUS_CLIFF = 0.25 + + def call(ride:) + distance_bonus_amount = compute_distance_bonus(ride.distance_meters) + duration_bonus_amount = compute_duration_bonus(ride.duration) + + BASE_PAY_AMOUNT + distance_bonus_amount + duration_bonus_amount + end + + private def compute_distance_bonus(distance_meters) + distance_in_miles = convert_distance_to_miles(distance_meters) + + amount = distance_in_miles > MILEAGE_BONUS_CLIFF ? MILEAGE_BONUS_AMOUNT * distance_in_miles : 0 + Money.new(amount) + end + + private def compute_duration_bonus(duration) + duration_in_hours = convert_duration_to_hours(duration) + + amount = duration_in_hours > DURATION_BONUS_CLIFF ? DURATION_BONUS_AMOUNT * duration_in_hours : 0 + Money.new(amount) + end + + private def convert_distance_to_miles(distance_meters) + # 1 mile = 1609.34 meters + distance_meters / 1609.34 + end + + private def convert_duration_to_hours(duration) + # Since there are 3,600 seconds in one hour, that's the conversion ratio used in the formula + duration.to_f / 3600 + end end end end diff --git a/lib/rides/commands/get_direction_data.rb b/lib/rides/commands/get_direction_data.rb deleted file mode 100644 index cbde339..0000000 --- a/lib/rides/commands/get_direction_data.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module Rides - module Commands - # Makes a request to the Google API to obtain the route information - class GetDirectionData < BaseCommand - DIRECTIONS_API_URL = "https://routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix" - COMPUTE_ROUTES_URL = "v2:computeRouteMatrix" - DEFAULT_HEADERS = { - "X-Goog-FieldMask" => "originIndex,destinationIndex,status,condition,distanceMeters,duration", - "X-goog-api-key" => ENV["GOOGLE_API_KEY"], - "Content-Type" => "application/json" - }.freeze - - def call(rides:) - data = get_direction_data_for_ride(rides) - binding.pry - results(data, rides) - end - - # Returns a list of objects, with attributes of - # @param[:distance_in_meters] = Integer - # @param[:duration] = String, e.g., "577s" - # Duration is in seconds - private def results(data, rides) - # The response keeps the array positioning on the return. Since we're getting a matrix - # of routes back, we only want the ones where we explicitly have a 'Ride'. This means that - # we want the computations where the indicies match. - data = data.select { _1[:originIndex] == _1[:destinationIndex] } - data = transform_keys!(data) - - data.map.with_index { OpenStruct.new(ride: rides[_2], **_1) } - end - - private def connection - @connection ||= Client::Request.connection( - url: DIRECTIONS_API_URL, - headers: DEFAULT_HEADERS - ) - end - - private def get_direction_data_for_ride(rides) - body = build_request_body(rides) - - response = connection.post( - DIRECTIONS_API_URL, - body.merge(routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE") - ) - - JSON.parse(response.body, symbolize_names: true) - end - - private def build_request_body(rides) - rides.each_with_object({}) do |ride, acc| - acc[:origins] ||= [] - acc[:destinations] ||= [] - - acc[:origins] << { waypoint: { placeId: ride.origin_place_id } } - acc[:destinations] << { waypoint: { placeId: ride.destination_place_id } } - end - end - - private def transform_keys!(data) - data.map { |d| d.transform_keys { |k| k.to_s.underscore.to_sym } } - end - end - end -end diff --git a/lib/rides/commands/get_routes_data.rb b/lib/rides/commands/get_routes_data.rb index 452e105..5058151 100644 --- a/lib/rides/commands/get_routes_data.rb +++ b/lib/rides/commands/get_routes_data.rb @@ -10,6 +10,7 @@ class GetRoutesData < BaseCommand "X-goog-api-key" => ENV["GOOGLE_API_KEY"], "Content-Type" => "application/json" }.freeze + DEFAULT_REQUEST_PARAMS = { routingPreference: "TRAFFIC_AWARE", travelMode: "DRIVE" }.freeze def call(rides:) data = get_direction_data_for_ride(rides) @@ -17,7 +18,7 @@ def call(rides:) end # Returns a list of objects, with attributes of - # @param[:distance_in_meters] = Integer + # @param[:distance_meters] = Integer # @param[:duration] = String, e.g., "577s" # Duration is in seconds private def results(data, rides) diff --git a/spec/lib/rides/commands/compute_amount_spec.rb b/spec/lib/rides/commands/compute_amount_spec.rb new file mode 100644 index 0000000..b79a492 --- /dev/null +++ b/spec/lib/rides/commands/compute_amount_spec.rb @@ -0,0 +1,23 @@ +RSpec.describe Rides::Commands::ComputeAmount do + let(:duration) { "577s" } + let(:distance_meters) { 3105 } + subject { described_class.call(ride:) } + + describe "CSU to The Still" do + let(:ride) { OpenStruct.new(duration:, distance_meters:) } + it "computes the amount" do + result = subject + expect(result.format).to eq("$12.00") + end + end + + describe "Fort Collins to Denver" do + let(:distance_meters) { 100_262.1 } + let(:duration) { "3600s" } + let(:ride) { OpenStruct.new(duration:, distance_meters:) } + it "computes the amount" do + result = subject + expect(result.format).to eq("$105.46") + end + end +end diff --git a/spec/lib/rides/commands/get_direction_data_spec.rb b/spec/lib/rides/commands/get_direction_data_spec.rb deleted file mode 100644 index 7e56324..0000000 --- a/spec/lib/rides/commands/get_direction_data_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Rides::Commands::GetDirectionData do - it "gets data for an address" do - VCR.use_cassette("first_ride_directions") do - from_address = create( - :address, :with_out_place_id, line_1: "711 Oval Drive", city: "Fort Collins", state: "CO", - zip_code: "80521" - ) - to_address = create( - :address, :with_out_place_id, line_1: "151 N College Ave", city: "Fort Collins", state: "CO", - zip_code: "80524" - ) - create_list(:ride, 2, from_address:, to_address:) - rides = Ride.selectable - data = described_class.call(rides:) - - expect(data.length).to eq(2) - expect(data.all? { _1.distance_in_meters == 3105 && _1.duration == "577s" }) - end - end -end From 2117a0846791f13add35b425b53fec68be1a62fd Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Thu, 4 Jul 2024 09:46:03 -0600 Subject: [PATCH 09/10] fix tests. will add further model tests in another PR --- app/models/ride.rb | 1 + spec/models/ride_spec.rb | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/models/ride.rb b/app/models/ride.rb index ae4bc12..fda9e66 100644 --- a/app/models/ride.rb +++ b/app/models/ride.rb @@ -8,6 +8,7 @@ class Ride < ApplicationRecord validates :duration, :distance, :commute_duration, :amount_cents, presence: true, on: :update, if: :should_validate? monetize :amount_cents, as: :amount, + allow_nil: false, numericality: { greater_than_or_equal_to: 0 } diff --git a/spec/models/ride_spec.rb b/spec/models/ride_spec.rb index 50a7dd6..7d42a82 100644 --- a/spec/models/ride_spec.rb +++ b/spec/models/ride_spec.rb @@ -11,10 +11,6 @@ end describe "attributes" do - it { is_expected.to validate_presence_of(:duration) } - it { is_expected.to validate_presence_of(:distance) } - it { is_expected.to validate_presence_of(:commute_duration) } - it { is_expected.to validate_presence_of(:amount_cents) } it { is_expected.to monetize(:amount_cents).as(:amount) } it { is_expected.to validate_numericality_of(:amount_cents) } end From 82b4366c321332c2aa41828594c188d8542b2167 Mon Sep 17 00:00:00 2001 From: Mike Heft Date: Thu, 4 Jul 2024 09:47:11 -0600 Subject: [PATCH 10/10] fix rubocop errors --- lib/client/request.rb | 2 ++ spec/lib/rides/commands/compute_amount_spec.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/client/request.rb b/lib/client/request.rb index 9bc87f6..cb4cb72 100644 --- a/lib/client/request.rb +++ b/lib/client/request.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Client class Request CONNECTION = Faraday diff --git a/spec/lib/rides/commands/compute_amount_spec.rb b/spec/lib/rides/commands/compute_amount_spec.rb index b79a492..f406488 100644 --- a/spec/lib/rides/commands/compute_amount_spec.rb +++ b/spec/lib/rides/commands/compute_amount_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + RSpec.describe Rides::Commands::ComputeAmount do let(:duration) { "577s" } let(:distance_meters) { 3105 }