Skip to content

Commit

Permalink
Merge pull request #175 from friendlycart/ship-engine-ltl/request-quote
Browse files Browse the repository at this point in the history
Request quotes from ShipEngine LTL API
  • Loading branch information
mamhoff authored Aug 3, 2023
2 parents 817a9e6 + f2f19f4 commit 77db124
Show file tree
Hide file tree
Showing 20 changed files with 1,306 additions and 5 deletions.
41 changes: 37 additions & 4 deletions lib/friendly_shipping/services/ship_engine_ltl.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# frozen_string_literal: true

require 'dry/monads/result'
require 'dry/monads'
require 'friendly_shipping/http_client'
require 'friendly_shipping/services/ship_engine/bad_request_handler'
require 'friendly_shipping/services/ship_engine_ltl/bad_request_handler'
require 'friendly_shipping/services/ship_engine_ltl/parse_carrier_response'
require 'friendly_shipping/services/ship_engine_ltl/parse_quote_response'
require 'friendly_shipping/services/ship_engine_ltl/serialize_packages'
require 'friendly_shipping/services/ship_engine_ltl/serialize_quote_request'
require 'friendly_shipping/services/ship_engine_ltl/quote_options'
require 'friendly_shipping/services/ship_engine_ltl/package_options'
require 'friendly_shipping/services/ship_engine_ltl/item_options'

module FriendlyShipping
module Services
Expand All @@ -13,10 +19,17 @@ class ShipEngineLTL
API_BASE = "https://api.shipengine.com/v-beta/ltl/"
API_PATHS = {
connections: "connections",
carriers: "carriers"
carriers: "carriers",
quotes: "quotes"
}.freeze

def initialize(token:, test: true, client: FriendlyShipping::HttpClient.new(error_handler: FriendlyShipping::Services::ShipEngine::BadRequestHandler))
def initialize(
token:,
test: true,
client: FriendlyShipping::HttpClient.new(
error_handler: FriendlyShipping::Services::ShipEngineLTL::BadRequestHandler
)
)
@token = token
@test = test
@client = client
Expand Down Expand Up @@ -85,6 +98,26 @@ def update_carrier(credentials, scac, carrier_id, debug: false)
end
end

# Request an LTL price quote from ShipEngine
#
# @param [String] carrier_id The carrier ID from ShipEngine that you want to quote against
# @param [Physical::Shipment] shipment The shipment to quote
# @param [FriendlyShipping::Services::ShipEngineLTL::QuoteOptions] options The options for the quote
#
# @return [Result<ApiResult<Hash>>] The price quote from ShipEngine
def request_quote(carrier_id, shipment, options, debug: false)
request = FriendlyShipping::Request.new(
url: API_BASE + API_PATHS[:quotes] + "/#{carrier_id}",
http_method: "POST",
body: SerializeQuoteRequest.call(shipment: shipment, options: options).to_json,
headers: request_headers,
debug: debug
)
client.post(request).bind do |response|
ParseQuoteResponse.call(request: request, response: response)
end
end

private

attr_reader :token, :test, :client
Expand Down
9 changes: 9 additions & 0 deletions lib/friendly_shipping/services/ship_engine_ltl/bad_request.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module FriendlyShipping
module Services
class ShipEngineLTL
class BadRequest < ShipEngine::BadRequest; end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

require 'friendly_shipping/services/ship_engine_ltl/bad_request'

module FriendlyShipping
module Services
class ShipEngineLTL
class BadRequestHandler
extend Dry::Monads::Result::Mixin

def self.call(error, original_request: nil, original_response: nil)
if error.http_code == 400
Failure(
ApiFailure.new(
BadRequest.new(error),
original_request: original_request,
original_response: original_response
)
)
else
Failure(
ApiFailure.new(
error,
original_request: original_request,
original_response: original_response
)
)
end
end
end
end
end
end
31 changes: 31 additions & 0 deletions lib/friendly_shipping/services/ship_engine_ltl/item_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module FriendlyShipping
module Services
class ShipEngineLTL
class ItemOptions < FriendlyShipping::ItemOptions
attr_reader :packaging_code,
:freight_class,
:nmfc_code,
:stackable,
:hazardous_materials

def initialize(
packaging_code: nil,
freight_class: nil,
nmfc_code: nil,
stackable: true,
hazardous_materials: false,
**kwargs
)
@packaging_code = packaging_code
@freight_class = freight_class
@nmfc_code = nmfc_code
@stackable = stackable
@hazardous_materials = hazardous_materials
super(**kwargs)
end
end
end
end
end
15 changes: 15 additions & 0 deletions lib/friendly_shipping/services/ship_engine_ltl/package_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

require 'friendly_shipping/services/ups/label_item_options'

module FriendlyShipping
module Services
class ShipEngineLTL
class PackageOptions < FriendlyShipping::PackageOptions
def initialize(**kwargs)
super(**kwargs.merge(item_options_class: ItemOptions))
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

require 'json'

module FriendlyShipping
module Services
class ShipEngineLTL
class ParseQuoteResponse
extend Dry::Monads::Result::Mixin

class << self
def call(request:, response:)
parsed_json = JSON.parse(response.body)
rates = build_rates(parsed_json)
if rates.any?
Success(
ApiResult.new(
rates,
original_request: request,
original_response: response
)
)
else
errors = parsed_json.fetch('errors', [{ 'message' => 'Unknown error' }])
Failure(
ApiResult.new(
errors.map { |e| e['message'] },
original_request: request,
original_response: response
)
)
end
end

private

def build_rates(parsed_json)
total = build_total(parsed_json)
return [] unless total.positive?

[
FriendlyShipping::Rate.new(
shipping_method: build_shipping_method(parsed_json),
amounts: { total: total }
)
]
end

def build_shipping_method(parsed_json)
description = parsed_json.dig("service", "carrier_description")
code = parsed_json.dig("service", "code")

FriendlyShipping::ShippingMethod.new(
name: description,
service_code: code,
multi_package: true
)
end

def build_total(parsed_json)
total_charges = parsed_json.fetch("charges", []).detect { |e| e['type'] == "total" }
return 0 unless total_charges

currency = Money::Currency.new(total_charges.dig("amount", "currency"))
value = total_charges.dig("amount", "value")
Money.new(value * currency.subunit_to_unit, currency)
end
end
end
end
end
end
34 changes: 34 additions & 0 deletions lib/friendly_shipping/services/ship_engine_ltl/quote_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

require 'friendly_shipping/shipment_options'

module FriendlyShipping
module Services
class ShipEngineLTL
class QuoteOptions < ShipmentOptions
attr_reader :service_code,
:pickup_date,
:accessorial_service_codes,
:packages_serializer_class

# @param [String] service_code
# @param [Time] pickup_date
# @param [Array<String>] accessorial_service_codes
# @param [Class] packages_serializer_class
def initialize(
service_code: nil,
pickup_date: nil,
accessorial_service_codes: [],
packages_serializer_class: SerializePackages,
**kwargs
)
@service_code = service_code
@pickup_date = pickup_date
@accessorial_service_codes = accessorial_service_codes
@packages_serializer_class = packages_serializer_class
super(**kwargs.merge(package_options_class: PackageOptions))
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module FriendlyShipping
module Services
class ShipEngineLTL
class SerializePackages
class << self
# @param [Array<Physical::Package>] packages
# @param [FriendlyShipping::Services::ShipEngineLTL::QuoteOptions] options
def call(packages:, options:)
packages.flat_map do |package|
package_options = options.options_for_package(package)
package.items.map do |item|
item_options = package_options.options_for_item(item)
{
code: item_options.packaging_code,
freight_class: item_options.freight_class,
nmfc_code: item_options.nmfc_code,
description: item.description || "Commodities",
dimensions: {
width: item.width.convert_to(:inches).value.ceil,
height: item.height.convert_to(:inches).value.ceil,
length: item.length.convert_to(:inches).value.ceil,
unit: "inches"
},
weight: {
value: item.weight.convert_to(:pounds).value.ceil,
unit: "pounds"
},
quantity: 1, # we don't support this yet
stackable: item_options.stackable,
hazardous_materials: item_options.hazardous_materials
}
end
end
end
end
end
end
end
end
Loading

0 comments on commit 77db124

Please sign in to comment.