freight_kit 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +51 -52
  3. data/README.md +36 -0
  4. data/VERSION +1 -1
  5. data/freight_kit.gemspec +10 -7
  6. data/lib/freight_kit/carriers/abfs.rb +2 -2
  7. data/lib/freight_kit/carriers/ccyq.rb +2 -9
  8. data/lib/freight_kit/carriers/clni.rb +2 -2
  9. data/lib/freight_kit/carriers/cnwy.rb +3 -3
  10. data/lib/freight_kit/carriers/dphe.rb +7 -9
  11. data/lib/freight_kit/carriers/drrq.rb +18 -18
  12. data/lib/freight_kit/carriers/fwda.rb +3 -3
  13. data/lib/freight_kit/carriers/otcl.rb +4 -12
  14. data/lib/freight_kit/carriers/saia.rb +4 -6
  15. data/lib/freight_kit/carriers/sefl.rb +2 -2
  16. data/lib/freight_kit/carriers/tqyl.rb +1 -3
  17. data/lib/freight_kit/carriers/wrds.rb +3 -3
  18. data/lib/freight_kit/carriers.rb +24 -25
  19. data/lib/freight_kit/errors/response_error.rb +1 -1
  20. data/lib/freight_kit/http_client/cache_store.rb +61 -0
  21. data/lib/freight_kit/http_client/log_device.rb +15 -0
  22. data/lib/freight_kit/http_client/response.rb +40 -0
  23. data/lib/freight_kit/http_client/revalidation.rb +27 -0
  24. data/lib/freight_kit/http_client/safe_retries.rb +25 -0
  25. data/lib/freight_kit/http_client/zstd.rb +30 -0
  26. data/lib/freight_kit/http_client.rb +80 -0
  27. data/lib/freight_kit/package.rb +1 -1
  28. data/lib/freight_kit/package_item.rb +1 -1
  29. data/lib/freight_kit/platforms/carrier_logistics.rb +3 -3
  30. data/lib/freight_kit/platforms/next.rb +2 -2
  31. data/lib/freight_kit/platforms/the_great_information_factory.rb +1 -2
  32. data/lib/freight_kit.rb +12 -21
  33. metadata +104 -43
  34. data/lib/freight_kit/api_clients.rb +0 -3
  35. data/lib/freight_kit/helpers.rb +0 -6
  36. data/lib/freight_kit/platforms.rb +0 -5
@@ -42,9 +42,9 @@ module FreightKit
42
42
  url = request_url(action)
43
43
 
44
44
  response = if @conf.dig(:api, :methods, action) == :post
45
- options[:params].blank? ? HTTParty.post(url) : HTTParty.post(url, query: options[:params])
45
+ options[:params].blank? ? HTTPClient.post(url) : HTTPClient.post(url, query: options[:params])
46
46
  else
47
- HTTParty.get(url)
47
+ HTTPClient.get(url)
48
48
  end
49
49
 
50
50
  response.parsed_response if response&.parsed_response
@@ -60,7 +60,7 @@ module FreightKit
60
60
  document_response = DocumentResponse.new(request: URI.parse(url))
61
61
 
62
62
  begin
63
- response = HTTParty.get(url)
63
+ response = HTTPClient.get(url)
64
64
  rescue StandardError => e
65
65
  document_response.error = e
66
66
  return document_response
@@ -8,8 +8,7 @@ module FreightKit
8
8
 
9
9
  @registered = []
10
10
 
11
- def register(class_name, autoload_require)
12
- FreightKit.autoload(class_name, autoload_require)
11
+ def register(class_name)
13
12
  registered << class_name
14
13
  end
15
14
 
@@ -23,28 +22,28 @@ module FreightKit
23
22
  end
24
23
  end
25
24
 
26
- FreightKit::Carriers.register(:ABFS, 'freight_kit/carriers/abfs')
27
- FreightKit::Carriers.register(:BTVP, 'freight_kit/carriers/btvp')
28
- FreightKit::Carriers.register(:CCYQ, 'freight_kit/carriers/ccyq')
29
- FreightKit::Carriers.register(:CLNI, 'freight_kit/carriers/clni')
30
- FreightKit::Carriers.register(:CNWY, 'freight_kit/carriers/cnwy')
31
- FreightKit::Carriers.register(:DLDS, 'freight_kit/carriers/dlds')
32
- FreightKit::Carriers.register(:DPHE, 'freight_kit/carriers/dphe')
33
- FreightKit::Carriers.register(:DRRQ, 'freight_kit/carriers/drrq')
34
- FreightKit::Carriers.register(:FWDA, 'freight_kit/carriers/fwda')
35
- FreightKit::Carriers.register(:MTVL, 'freight_kit/carriers/mtvl')
36
- FreightKit::Carriers.register(:NUMK, 'freight_kit/carriers/numk')
37
- FreightKit::Carriers.register(:OTCL, 'freight_kit/carriers/otcl')
38
- FreightKit::Carriers.register(:PENS, 'freight_kit/carriers/pens')
39
- FreightKit::Carriers.register(:RDFS, 'freight_kit/carriers/rdfs')
40
- FreightKit::Carriers.register(:SAIA, 'freight_kit/carriers/saia')
41
- FreightKit::Carriers.register(:SEFL, 'freight_kit/carriers/sefl')
42
- FreightKit::Carriers.register(:TQYL, 'freight_kit/carriers/tqyl')
43
- FreightKit::Carriers.register(:WRDS, 'freight_kit/carriers/wrds')
25
+ FreightKit::Carriers.register(:ABFS)
26
+ FreightKit::Carriers.register(:BTVP)
27
+ FreightKit::Carriers.register(:CCYQ)
28
+ FreightKit::Carriers.register(:CLNI)
29
+ FreightKit::Carriers.register(:CNWY)
30
+ FreightKit::Carriers.register(:DLDS)
31
+ FreightKit::Carriers.register(:DPHE)
32
+ FreightKit::Carriers.register(:DRRQ)
33
+ FreightKit::Carriers.register(:FWDA)
34
+ FreightKit::Carriers.register(:MTVL)
35
+ FreightKit::Carriers.register(:NUMK)
36
+ FreightKit::Carriers.register(:OTCL)
37
+ FreightKit::Carriers.register(:PENS)
38
+ FreightKit::Carriers.register(:RDFS)
39
+ FreightKit::Carriers.register(:SAIA)
40
+ FreightKit::Carriers.register(:SEFL)
41
+ FreightKit::Carriers.register(:TQYL)
42
+ FreightKit::Carriers.register(:WRDS)
44
43
 
45
44
  # Based on Platform
46
- FreightKit::Carriers.register(:CTBV, 'freight_kit/carriers/ctbv')
47
- FreightKit::Carriers.register(:DCHA, 'freight_kit/carriers/dcha')
48
- FreightKit::Carriers.register(:JFJTransportation, 'freight_kit/carriers/jfj_transportation')
49
- FreightKit::Carriers.register(:FCSY, 'freight_kit/carriers/fcsy')
50
- FreightKit::Carriers.register(:TOTL, 'freight_kit/carriers/totl')
45
+ FreightKit::Carriers.register(:CTBV)
46
+ FreightKit::Carriers.register(:DCHA)
47
+ FreightKit::Carriers.register(:JFJTransportation)
48
+ FreightKit::Carriers.register(:FCSY)
49
+ FreightKit::Carriers.register(:TOTL)
@@ -9,7 +9,7 @@ module FreightKit
9
9
  super(response.message)
10
10
  @response = response
11
11
  else
12
- super(response)
12
+ super
13
13
  end
14
14
  end
15
15
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class HTTPClient
5
+ # Adapts an ActiveSupport::Cache::Store-like backend (#read, #write, #delete_matched)
6
+ # to the HTTPX cache store interface (#get, #set, #clear).
7
+ #
8
+ # HTTPX responses hold sockets and options, so only status, headers and body are stored.
9
+ class CacheStore
10
+ NAMESPACE = 'freight_kit/http_client'
11
+ VALIDATOR_HEADERS = ['if-modified-since', 'if-none-match'].freeze
12
+
13
+ def initialize(backend) = @backend = backend
14
+
15
+ # RedisCacheStore only supports glob strings, the other ActiveSupport stores only regular expressions
16
+ def clear
17
+ @backend.delete_matched(%r{\A#{Regexp.escape(NAMESPACE)}/})
18
+ rescue ArgumentError
19
+ @backend.delete_matched("#{NAMESPACE}/*")
20
+ end
21
+
22
+ def get(request)
23
+ entry = @backend.read(key(request))
24
+ return if entry.blank?
25
+
26
+ response = request.options.response_class.new(request, entry[:status], entry[:version], entry[:headers])
27
+ response.finish!
28
+ response.mark_as_cached!
29
+ response.body.write(entry[:body])
30
+ response.body.rewind
31
+ response
32
+ end
33
+
34
+ def set(request, response)
35
+ response.body.rewind
36
+
37
+ @backend.write(
38
+ key(request),
39
+ {
40
+ body: response.body.to_s,
41
+ headers: response.headers.to_h,
42
+ status: response.status,
43
+ version: response.version
44
+ },
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ # Unlike HTTPX's default key, every request header (e.g. Authorization, API keys) is
51
+ # part of the key so that responses are never shared across credentials.
52
+ # Query parameters given as `params:` are only part of the request path, not its URI.
53
+ def key(request)
54
+ headers = request.headers.to_a.reject { |field, _| VALIDATOR_HEADERS.include?(field) }.sort
55
+ digest = Digest::SHA256.hexdigest([request.verb, request.uri.merge(request.path).to_s, headers].to_json)
56
+
57
+ "#{NAMESPACE}/#{digest}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class HTTPClient
5
+ # HTTPX writes debug output to an IO-like object (#<<); this forwards it to a logger at debug level.
6
+ class LogDevice
7
+ def initialize(logger) = @logger = logger
8
+
9
+ def <<(message)
10
+ @logger.debug(message.chomp)
11
+ self
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class HTTPClient
5
+ # Like HTTParty::Response, unknown methods (e.g. #dig, #[]) are delegated to the parsed body.
6
+ class Response
7
+ JSON_CONTENT_TYPES = ['application/hal+json', 'application/json', 'application/vnd.api+json', 'text/json'].freeze
8
+ XML_CONTENT_TYPES = ['application/xml', 'text/xml'].freeze
9
+
10
+ attr_reader :body, :code, :content_type, :headers
11
+
12
+ # Delegation alone would make empty bodies (parsed as nil) look present
13
+ delegate :blank?, to: :parsed_response
14
+ delegate_missing_to :parsed_response
15
+
16
+ def initialize(response)
17
+ @body = response.body.to_s
18
+ @code = response.status
19
+ @content_type = response.content_type.mime_type
20
+ @headers = response.headers
21
+ end
22
+
23
+ def parsed_response
24
+ return @parsed_response if defined?(@parsed_response)
25
+
26
+ @parsed_response = if body.strip.empty? || body == 'null'
27
+ nil
28
+ elsif JSON_CONTENT_TYPES.include?(content_type)
29
+ JSON.parse(body, allow_nan: true)
30
+ elsif XML_CONTENT_TYPES.include?(content_type)
31
+ MultiXML.parse(body)
32
+ else
33
+ body
34
+ end
35
+ end
36
+
37
+ def to_s = body
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class HTTPClient
5
+ # HTTPX plugin making revalidated (304 Not Modified) responses look like the cached
6
+ # response they were revalidated against, so callers checking for 200 keep working.
7
+ #
8
+ # HTTPX renews freshness by mutating the cached response in memory, which serializing
9
+ # cache stores never see, so the refreshed response is stored again.
10
+ module Revalidation
11
+ module ResponseMethods
12
+ def copy_from_cached!
13
+ super
14
+ return unless (cached_response = @request.cached_response)
15
+
16
+ @status = cached_response.status
17
+ store = @request.options.response_cache_store
18
+ store.set(@request, self) if HTTPX::Plugins::ResponseCache.cacheable_response?(self)
19
+ end
20
+ end
21
+
22
+ class << self
23
+ def load_dependencies(klass) = klass.plugin(:response_cache)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class HTTPClient
5
+ # HTTPX plugin keeping connections open (HTTPX's persistent plugin) without risking duplicate
6
+ # rates, pickups or tenders.
7
+ #
8
+ # The persistent plugin retries any request failing with a connection error, but a POST may
9
+ # already have been processed by then, so only idempotent requests are retried.
10
+ # Streams refused by an HTTP/2 GOAWAY are resent by HTTPX itself, independently of retries.
11
+ module SafeRetries
12
+ IDEMPOTENT_METHODS = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT'].freeze
13
+
14
+ module InstanceMethods
15
+ private
16
+
17
+ def retryable_request?(request, *) = super && IDEMPOTENT_METHODS.include?(request.verb)
18
+ end
19
+
20
+ class << self
21
+ def load_dependencies(klass) = klass.plugin(:persistent)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ class HTTPClient
5
+ # HTTPX plugin decoding Zstandard ("zstd") response bodies, modeled on HTTPX's brotli plugin.
6
+ module Zstd
7
+ class Inflater
8
+ def initialize = @inflater = ::Zstd::StreamingDecompress.new
9
+
10
+ def call(chunk) = @inflater.decompress(chunk)
11
+ end
12
+
13
+ module ResponseBodyClassMethods
14
+ def initialize_inflater_by_encoding(encoding, response, **) = encoding == 'zstd' ? Inflater.new : super
15
+ end
16
+
17
+ class << self
18
+ def load_dependencies(*) = require('zstd-ruby')
19
+
20
+ def extra_options(options)
21
+ supported_compression_formats = (['zstd'] + options.supported_compression_formats).freeze
22
+
23
+ headers = options.headers.merge('accept-encoding' => supported_compression_formats)
24
+
25
+ options.merge(headers: options.headers_class.new(headers), supported_compression_formats:)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FreightKit
4
+ # Thin wrapper around HTTPX that negotiates Brotli/Zstandard compression, keeps connections
5
+ # open between requests and revalidates cached responses with conditional requests (ETag/Last-Modified).
6
+ #
7
+ # The cache and logger are hookable; they default to the host Rails/Hanami app's logger:
8
+ #
9
+ # FreightKit::HTTPClient.cache = Rails.cache
10
+ # FreightKit::HTTPClient.logger = Rails.logger
11
+ class HTTPClient
12
+ @sessions = Concurrent::Map.new
13
+
14
+ class << self
15
+ def cache = @cache ||= ActiveSupport::Cache::MemoryStore.new
16
+
17
+ def cache=(store)
18
+ @cache = store
19
+ @sessions.clear
20
+ end
21
+
22
+ def logger = @logger || framework_logger || ::Logger.new($stdout, level: ENV.fetch('LOG_LEVEL', 'info'))
23
+
24
+ def logger=(logger)
25
+ @logger = logger
26
+ @sessions.clear
27
+ end
28
+
29
+ def get(url, **) = request(:get, url, **)
30
+
31
+ def post(url, **) = request(:post, url, **)
32
+
33
+ def request(verb, url, body: nil, headers: {}, proxy: nil, query: nil)
34
+ options = { headers: }
35
+ options[:params] = query if query.present?
36
+
37
+ # Like HTTParty, hashes are form-encoded regardless of the Content-Type header
38
+ if body.is_a?(Hash)
39
+ options[:form] = body if body.present?
40
+ elsif body.present?
41
+ options[:body] = body
42
+ end
43
+
44
+ response = session(proxy.presence).request(verb.to_s.upcase, url, **options)
45
+ raise response.error if response.is_a?(HTTPX::ErrorResponse)
46
+
47
+ Response.new(response)
48
+ end
49
+
50
+ private
51
+
52
+ def framework_logger
53
+ if defined?(::Rails) && ::Rails.respond_to?(:logger)
54
+ ::Rails.logger
55
+ elsif defined?(::Hanami) && ::Hanami.respond_to?(:app?) && ::Hanami.app?
56
+ ::Hanami.app['logger']
57
+ end
58
+ end
59
+
60
+ # Sessions own their connection pool, so they are built once (per proxy) and reused
61
+ def session(proxy)
62
+ @sessions.compute_if_absent(proxy) do
63
+ session = HTTPX
64
+ .plugin(:brotli)
65
+ .plugin(Zstd)
66
+ .plugin(:follow_redirects)
67
+ .plugin(:response_cache, response_cache_store: CacheStore.new(cache))
68
+ .plugin(Revalidation)
69
+ .plugin(SafeRetries)
70
+ .with(close_on_fork: true)
71
+
72
+ logger = self.logger
73
+ session = session.with(debug: LogDevice.new(logger), debug_level: 2) if logger.level <= ::Logger::DEBUG
74
+
75
+ proxy ? session.plugin(:proxy).with(proxy: { uri: proxy }) : session
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -304,7 +304,7 @@ module FreightKit # :nodoc:
304
304
  weight
305
305
  when :volumetric, :dimensional
306
306
  @volumetric_weight ||= begin
307
- m = Measured::Weight.new((centimetres(:box_volume) / 6.0), :grams)
307
+ m = Measured::Weight.new(centimetres(:box_volume) / 6.0, :grams)
308
308
  @weight_unit_system == :imperial ? m.convert_to(:oz) : m
309
309
  end
310
310
  when :billable
@@ -31,7 +31,7 @@ module FreightKit # :nodoc:
31
31
  @weight
32
32
  when :volumetric, :dimensional
33
33
  @volumetric_weight ||= begin
34
- m = Measured::Weight.new((centimetres(:box_volume) / 6.0), :grams)
34
+ m = Measured::Weight.new(centimetres(:box_volume) / 6.0, :grams)
35
35
  @unit_system == :imperial ? m.in_ounces : m
36
36
  end
37
37
  when :billable
@@ -58,13 +58,13 @@ module FreightKit
58
58
  url = build_url(action, query:)
59
59
  save_request(url)
60
60
 
61
- HTTParty.get(url, logger: Logger.new($stdout))
61
+ HTTPClient.get(url)
62
62
  end
63
63
 
64
64
  def map_response_errors(response, not_found_error: DocumentNotFoundError)
65
65
  return ResponseError.new('Unknown response') if response.blank?
66
66
 
67
- webspeed_error = (response.is_a?(String) || response.is_a?(HTTParty::Response)) &&
67
+ webspeed_error = (response.is_a?(String) || response.is_a?(HTTPClient::Response)) &&
68
68
  response.include?('WebSpeed error')
69
69
  return ResponseError.new('Temporary error (WebSpeed error)') if webspeed_error
70
70
 
@@ -99,7 +99,7 @@ module FreightKit
99
99
  end
100
100
 
101
101
  begin
102
- response = HTTParty.get(url)
102
+ response = HTTPClient.get(url)
103
103
  rescue StandardError => e
104
104
  document_response.error = e
105
105
  return document_response
@@ -66,9 +66,9 @@ module FreightKit
66
66
 
67
67
  response = case method
68
68
  when :post
69
- HTTParty.post(url, headers:, body:)
69
+ HTTPClient.post(url, headers:, body:)
70
70
  else
71
- HTTParty.get(url, headers:)
71
+ HTTPClient.get(url, headers:)
72
72
  end
73
73
 
74
74
  JSON.parse(response.body) if response&.body
@@ -447,7 +447,6 @@ module FreightKit
447
447
  service:,
448
448
  shipment:
449
449
  )
450
-
451
450
  shipper_phone = shipment.origin.contact.phone.gsub(/\s+/, '').gsub(/[()-+.]/, '')
452
451
  shipper_phone = shipper_phone[1..] if shipper_phone.length == 11
453
452
 
@@ -499,7 +498,7 @@ module FreightKit
499
498
 
500
499
  url = [base_url, query_parameter].join
501
500
 
502
- response = HTTParty.get(url)
501
+ response = HTTPClient.get(url)
503
502
 
504
503
  image = response.deep_symbolize_keys.dig(:ImageRequest, :Image)
505
504
  image = image.last if image.is_a?(Array)
data/lib/freight_kit.rb CHANGED
@@ -4,9 +4,10 @@ require 'active_model'
4
4
  require 'active_support/all'
5
5
  require 'active_utils'
6
6
  require 'business_time'
7
- require 'httparty'
7
+ require 'httpx'
8
8
  require 'measured'
9
9
  require 'mimemagic'
10
+ require 'multi_xml'
10
11
  require 'nokogiri'
11
12
  require 'open-uri'
12
13
  require 'place_kit'
@@ -20,6 +21,11 @@ module FreightKit
20
21
 
21
22
  class Inflector < Zeitwerk::Inflector
22
23
  def camelize(basename, abspath)
24
+ # Carriers are named after their four-letter SCAC (e.g. abfs.rb => ABFS)
25
+ if File.basename(File.dirname(abspath)) == 'carriers' && basename.match?(/\A[a-z]{4}\z/)
26
+ return basename.upcase
27
+ end
28
+
23
29
  if basename =~ /\Ahttp_(.*)/
24
30
  return "HTTP#{super(::Regexp.last_match(1), abspath)}"
25
31
  end
@@ -31,29 +37,14 @@ end
31
37
 
32
38
  loader = Zeitwerk::Loader.for_gem
33
39
 
40
+ loader.collapse("#{__dir__}/freight_kit/api_clients")
41
+ loader.collapse("#{__dir__}/freight_kit/carriers")
34
42
  loader.collapse("#{__dir__}/freight_kit/errors")
43
+ loader.collapse("#{__dir__}/freight_kit/helpers")
35
44
  loader.collapse("#{__dir__}/freight_kit/models")
36
-
37
- # Carriers, platforms, helpers, and api_clients define top-level constants
38
- # under FreightKit:: (e.g. FreightKit::ABFS, FreightKit::Rateable) rather
39
- # than nested under the directory name, so they're loaded manually via the
40
- # explicit requires below.
41
- loader.ignore("#{__dir__}/freight_kit/api_clients.rb")
42
- loader.ignore("#{__dir__}/freight_kit/api_clients")
43
- loader.ignore("#{__dir__}/freight_kit/carriers.rb")
44
- loader.ignore("#{__dir__}/freight_kit/carriers")
45
- loader.ignore("#{__dir__}/freight_kit/helpers.rb")
46
- loader.ignore("#{__dir__}/freight_kit/helpers")
47
- loader.ignore("#{__dir__}/freight_kit/platforms.rb")
48
- loader.ignore("#{__dir__}/freight_kit/platforms")
45
+ loader.collapse("#{__dir__}/freight_kit/platforms")
49
46
 
50
47
  loader.inflector = FreightKit::Inflector.new
48
+ loader.inflector.inflect('jfj_transportation' => 'JFJTransportation')
51
49
 
52
50
  loader.setup
53
-
54
- require 'rmagick'
55
-
56
- require 'freight_kit/api_clients'
57
- require 'freight_kit/helpers'
58
- require 'freight_kit/platforms'
59
- require 'freight_kit/carriers'