after_ship 0.0.2

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.
data/lib/after_ship.rb ADDED
@@ -0,0 +1,323 @@
1
+ require 'typhoeus'
2
+ require 'multi_json'
3
+
4
+ require 'attributes'
5
+ require 'date_utils'
6
+ require 'after_ship/version'
7
+ require 'after_ship/tracking'
8
+ require 'after_ship/checkpoint'
9
+
10
+ # Init the client:
11
+ #
12
+ # client = AfterShip.new(api_key: 'your-aftership-api-key')
13
+ #
14
+ # Get a list of trackings
15
+ # https://www.aftership.com/docs/api/3.0/tracking/get-trackings
16
+ #
17
+ # client.trackings
18
+ #
19
+ # # Will return list of Tracking objects:
20
+ #
21
+ # [
22
+ # #<AfterShip::Tracking ...>,
23
+ # #<AfterShip::Tracking ...>,
24
+ # ...
25
+ # ]
26
+ #
27
+ # Get a tracking
28
+ # https://www.aftership.com/docs/api/3.0/tracking/get-trackings-slug-tracking_number
29
+ #
30
+ # client.tracking('tracking-number', 'ups')
31
+ #
32
+ # # Will return Tracking object or raise AfterShip::ResourceNotFoundError
33
+ # # if not exists:
34
+ #
35
+ # #<AfterShip::Tracking:0x007fe555bd9560
36
+ # @active=false,
37
+ # @courier="UPS",
38
+ # @created_at=#<DateTime: 2014-05-08T15:25:01+00:00 ...>,
39
+ # @updated_at=#<DateTime: 2014-07-18T09:00:47+00:00 ...>>
40
+ # @custom_fields={},
41
+ # @customer_name=nil,
42
+ # @destination_country_iso3="USA",
43
+ # @emails=[],
44
+ # @expected_delivery=nil,
45
+ # @order_id="PL-12480166",
46
+ # @order_id_path=nil,
47
+ # @origin_country_iso3="IND",
48
+ # @shipment_package_count=0,
49
+ # @shipment_type="EXPEDITED",
50
+ # @signed_by="FRONT DOOR",
51
+ # @slug="ups",
52
+ # @smses=[],
53
+ # @source="api",
54
+ # @status="Delivered",
55
+ # @tag="Delivered",
56
+ # @title="1ZA2207X6790326683",
57
+ # @tracked_count=47,
58
+ # @tracking_number="1ZA2207X6790326683",
59
+ # @unique_token="ly9ueXUJC",
60
+ # @checkpoints=[
61
+ # #<AfterShip::Checkpoint:0x007fe555bb0340
62
+ # @checkpoint_time=#<DateTime: 2014-05-12T14:07:00+00:00 ...>,
63
+ # @city="NEW YORK",
64
+ # @country_iso3=nil,
65
+ # @country_name="US",
66
+ # @courier="UPS",
67
+ # @created_at=#<DateTime: 2014-05-12T18:34:32+00:00 ...>,
68
+ # @message="DELIVERED",
69
+ # @slug="ups",
70
+ # @state="NY",
71
+ # @status="Delivered",
72
+ # @tag="Delivered",
73
+ # @zip="10075">
74
+ # #<AfterShip::Checkpoint ...>,
75
+ # ...
76
+ # ]>
77
+ #
78
+ # Create a new tracking
79
+ # https://www.aftership.com/docs/api/3.0/tracking/post-trackings
80
+ #
81
+ # client.create_tracking('tracking-number', 'ups', order_id: 'external-id')
82
+ #
83
+ # # Will return Tracking object or raise AfterShip::InvalidArgumentError
84
+ # # if it exists:
85
+ #
86
+ # #<AfterShip::Tracking ...>
87
+ #
88
+ # Update a tracking
89
+ # https://www.aftership.com/docs/api/3.0/tracking/put-trackings-slug-tracking_number
90
+ #
91
+ # client.update_tracking('tracking-number', 'ups', order_id: 'external-id')
92
+ #
93
+ # To debug:
94
+ #
95
+ # AfterShip.debug = true
96
+ #
97
+ # client.tracking('9405903699300211343566', 'usps') # In transit
98
+ # client.tracking('1ZA2207X6794165804', 'ups') # Delivered, wild
99
+ # client.tracking('1ZA2207X6791425225', 'ups') # Delivered, ok
100
+ # client.tracking('1ZA2207X6790326683', 'ups') # Delivered, ok
101
+ class AfterShip
102
+ class Error < StandardError; end
103
+ class InvalidJSONDataError < Error; end # 400
104
+ class InvalidCredentialsError < Error; end # 401
105
+ class RequestFailedError < Error; end # 402
106
+ class ResourceNotFoundError < Error; end # 404
107
+ class InvalidArgumentError < Error; end # 409
108
+ class TooManyRequestsError < Error; end # 429
109
+ class ServerError < Error; end # 500, 502, 503, 504
110
+ class UnknownError < Error; end
111
+
112
+ DEFAULT_API_ADDRESS = 'https://api.aftership.com/v3'
113
+ TRACKINGS_ENDPOINT = "#{ DEFAULT_API_ADDRESS }/trackings"
114
+
115
+ JSON_OPTIONS = {
116
+ symbolize_keys: true # Symbol keys to string keys
117
+ }
118
+
119
+ # Tag to human-friendly status conversion
120
+ TAG_STATUS = {
121
+ 'Pending' => 'Pending',
122
+ 'InfoReceived' => 'Info Received',
123
+ 'InTransit' => 'In Transit',
124
+ 'OutForDelivery' => 'Out for Delivery',
125
+ 'AttemptFail' => 'Attempt Failed',
126
+ 'Delivered' => 'Delivered',
127
+ 'Exception' => 'Exception',
128
+ 'Expired' => 'Expired'
129
+ }
130
+
131
+ class << self
132
+ # If debugging is turned on, it is passed to Typhoeus as "verbose" options,
133
+ # which is passed down to Ethon and displays request/response in STDERR.
134
+ #
135
+ # @return [Bool]
136
+ attr_accessor :debug
137
+ end
138
+
139
+ attr_reader :api_key
140
+
141
+ # @param options [Hash]
142
+ # api_key [String]
143
+ def initialize(options)
144
+ require_arguments(
145
+ api_key: options[:api_key]
146
+ )
147
+
148
+ @api_key = options.delete(:api_key)
149
+ end
150
+
151
+ # Get a list of trackings.
152
+ # https://www.aftership.com/docs/api/3.0/tracking/get-trackings
153
+ #
154
+ # @return [Hash]
155
+ def trackings
156
+ response = request_response(TRACKINGS_ENDPOINT, {}, :get)
157
+ data = response.fetch(:data).fetch(:trackings)
158
+
159
+ data.map { |datum| Tracking.new(datum) }
160
+ end
161
+
162
+ # Get a single tracking. Raises an error if not found.
163
+ # https://www.aftership.com/docs/api/3.0/tracking/get-trackings-slug-tracking_number
164
+ #
165
+ # @param tracking_number [String]
166
+ # @param courier [String]
167
+ #
168
+ # @return [Hash]
169
+ def tracking(tracking_number, courier)
170
+ require_arguments(tracking_number: tracking_number, courier: courier)
171
+
172
+ url = "#{ TRACKINGS_ENDPOINT }/#{ courier }/#{ tracking_number }"
173
+
174
+ response = request_response(url, {}, :get)
175
+ data = response.fetch(:data).fetch(:tracking)
176
+
177
+ Tracking.new(data)
178
+ end
179
+
180
+ # Create a new tracking.
181
+ # https://www.aftership.com/docs/api/3.0/tracking/post-trackings
182
+ #
183
+ # @param tracking_number [String]
184
+ # @param courier [String]
185
+ # @param options [Hash]
186
+ #
187
+ # @return [Hash]
188
+ def create_tracking(tracking_number, courier, options = {})
189
+ require_arguments(tracking_number: tracking_number, courier: courier)
190
+
191
+ params = {
192
+ tracking: {
193
+ tracking_number: tracking_number,
194
+ slug: courier
195
+ }.merge(options)
196
+ }
197
+
198
+ response = request_response(TRACKINGS_ENDPOINT, params, :post)
199
+ data = response.fetch(:data).fetch(:tracking)
200
+
201
+ Tracking.new(data)
202
+ end
203
+
204
+ # https://www.aftership.com/docs/api/3.0/tracking/put-trackings-slug-tracking_number
205
+ #
206
+ # @param tracking_number [String]
207
+ # @param courier [String]
208
+ # @param options [Hash]
209
+ #
210
+ # @return [Hash]
211
+ def update_tracking(tracking_number, courier, options = {})
212
+ require_arguments(tracking_number: tracking_number, courier: courier)
213
+
214
+ url = "#{ TRACKINGS_ENDPOINT }/#{ courier }/#{ tracking_number }"
215
+ params = {
216
+ tracking: options
217
+ }
218
+
219
+ response = request_response(url, params, :put)
220
+ data = response.fetch(:data).fetch(:tracking)
221
+
222
+ Tracking.new(data)
223
+ end
224
+
225
+ # Raises an ArgumentError if any of the args is empty or nil.
226
+ #
227
+ # @param hash [Hash] arguments needed in options
228
+ def require_arguments(hash)
229
+ hash.each do |name, value|
230
+ if value.respond_to?(:empty?)
231
+ invalid_argument!(name) if value.empty?
232
+ else
233
+ invalid_argument!(name)
234
+ end
235
+ end
236
+ end
237
+
238
+ protected
239
+
240
+ # @param name [Symbol]
241
+ def invalid_argument!(name)
242
+ fail ArgumentError, "Argument #{ name } cannot be empty"
243
+ end
244
+
245
+ # Prepare a `Typhoeus::Request`, send it over the net and deal
246
+ # with te response by either returning a Hash or raising an error.
247
+ #
248
+ # @param url [String]
249
+ # @param body_hash [Hash]
250
+ # @param method [Symbol]
251
+ #
252
+ # @return [Hash]
253
+ def request_response(url, body_hash, method = :get)
254
+ body_json = MultiJson.dump(body_hash)
255
+
256
+ request = Typhoeus::Request.new(
257
+ url,
258
+ method: method,
259
+ verbose: self.class.debug,
260
+ body: body_json,
261
+ headers: {
262
+ 'aftership-api-key' => @api_key,
263
+ 'Content-Type' => 'application/json'
264
+ }
265
+ )
266
+
267
+ if self.class.debug
268
+ request.on_complete do |response|
269
+ puts
270
+ puts 'Request body:'
271
+ puts request.options[:body]
272
+ puts
273
+ puts 'Response body:'
274
+ puts response.body
275
+ puts
276
+ end
277
+ end
278
+
279
+ response = request.run
280
+ response_to_json(response)
281
+ end
282
+
283
+ # Deal with API response, either return a Hash or raise an error.
284
+ #
285
+ # @param response [Typhoeus::Response]
286
+ #
287
+ # @return [Hash]
288
+ #
289
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
290
+ def response_to_json(response)
291
+ json_response = parse_response(response)
292
+
293
+ case json_response[:meta][:code]
294
+ when 200, 201
295
+ return json_response
296
+ when 400
297
+ fail InvalidJSONDataError, json_response[:meta][:error_message]
298
+ when 401
299
+ fail InvalidCredentialsError, json_response[:meta][:error_message]
300
+ when 402
301
+ fail RequestFailedError, json_response[:meta][:error_message]
302
+ when 404
303
+ fail ResourceNotFoundError, json_response[:meta][:error_message]
304
+ when 409
305
+ fail InvalidArgumentError, json_response[:meta][:error_message]
306
+ when 429
307
+ fail TooManyRequestsError, json_response[:meta][:error_message]
308
+ when 500, 502, 503, 504
309
+ fail ServerError, json_response[:meta][:error_message]
310
+ else
311
+ fail UnknownError, json_response[:meta][:error_message]
312
+ end
313
+ end
314
+
315
+ # Parse response body into a Hash.
316
+ #
317
+ # @param response [Typhoeus::Response]
318
+ #
319
+ # @return [Hash]
320
+ def parse_response(response)
321
+ MultiJson.load(response.body, JSON_OPTIONS)
322
+ end
323
+ end
data/lib/attributes.rb ADDED
@@ -0,0 +1,12 @@
1
+ # Extracted attributes loading.
2
+ module Attributes
3
+ # Loop through the data hash and for each key call a setter with the value.
4
+ #
5
+ # @param data [Hash]
6
+ def load_attributes(data)
7
+ data.each do |attribute, value|
8
+ setter = "#{ attribute }="
9
+ send(setter, value) if respond_to?(setter)
10
+ end
11
+ end
12
+ end
data/lib/date_utils.rb ADDED
@@ -0,0 +1,61 @@
1
+ # Simple utility class for parsing dates and datetimes.
2
+ class DateUtils
3
+ # Date:
4
+ #
5
+ # +YYYY-MM-DD+
6
+ DATE_REGEX = /
7
+ \A
8
+ \d{4}-\d{2}-\d{2}
9
+ \Z
10
+ /x
11
+
12
+ # Datetime without zone:
13
+ #
14
+ # +YYYY-MM-DDTHH:MM:SS+
15
+ DATETIME_REGEX = /
16
+ \A
17
+ \d{4}-\d{2}-\d{2}
18
+ T
19
+ \d{2}:\d{2}:\d{2}
20
+ \Z
21
+ /x
22
+
23
+ # Datetime with zone:
24
+ #
25
+ # +YYYY-MM-DDTHH:MM:SSZ+
26
+ # +YYYY-MM-DDTHH:MM:SS+HH:MM+
27
+ # +YYYY-MM-DDTHH:MM:SS-HH:MM+
28
+ DATETIME_WITH_ZONE_REGEX = /
29
+ \A
30
+ \d{4}-\d{2}-\d{2}
31
+ T
32
+ \d{2}:\d{2}:\d{2}
33
+ (Z|[+-]\d{2}:\d{2})
34
+ \Z
35
+ /x
36
+
37
+ # Try to parse a date or datetime from a string.
38
+ #
39
+ # @param value [String]
40
+ # Empty String,
41
+ # YYYY-MM-DD,
42
+ # YYYY-MM-DDTHH:MM:SS,
43
+ # YYYY-MM-DDTHH:MM:SSZ,
44
+ # YYYY-MM-DDTHH:MM:SS+HH:MM or
45
+ # YYYY-MM-DDTHH:MM:SS-HH:MM.
46
+ #
47
+ def self.parse(value)
48
+ case value
49
+ when ''
50
+ nil
51
+ when nil
52
+ nil
53
+ when DATE_REGEX
54
+ Date.parse(value)
55
+ when DATETIME_REGEX, DATETIME_WITH_ZONE_REGEX
56
+ DateTime.parse(value)
57
+ else
58
+ fail ArgumentError, "Invalid expected_delivery date #{ value.inspect }"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,137 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe AfterShip do
4
+ it 'fails to make a client' do
5
+ expect { AfterShip.new }.to raise_error
6
+ end
7
+
8
+ context 'With api_key' do
9
+ before do
10
+ @client = AfterShip.new(api_key: 'key')
11
+ end
12
+
13
+ it 'api_key' do
14
+ expect(@client.api_key).to eq('key')
15
+ end
16
+
17
+ context 'require_arguments' do
18
+ it 'tracking_number: nil raises error' do
19
+ expect { @client.require_arguments tracking_number: nil }
20
+ .to raise_error(ArgumentError)
21
+ end
22
+
23
+ it "tracking_number: 1, courier: '' raises error" do
24
+ expect { @client.require_arguments tracking_number: 1, courier: '' }
25
+ .to raise_error(ArgumentError)
26
+ end
27
+
28
+ it "tracking_number: 'abc', courier: 'def' does not raise error" do
29
+ options = { tracking_number: 'abc', courier: 'def' }
30
+ expect { @client.require_arguments(options) }
31
+ .to_not raise_error
32
+ end
33
+ end
34
+
35
+ context 'trackings' do
36
+ it 'response 200' do
37
+ expect { @client.trackings }
38
+ .to_not raise_error
39
+ end
40
+
41
+ it 'response 200 with debug' do
42
+ AfterShip.debug = true
43
+
44
+ expect { @client.trackings }
45
+ .to_not raise_error
46
+
47
+ AfterShip.debug = nil
48
+ end
49
+ end
50
+
51
+ context 'tracking' do
52
+ it 'response 200' do
53
+ expect { @client.tracking('ABC123', 'ups') }
54
+ .to_not raise_error
55
+ end
56
+
57
+ it 'response 201' do
58
+ expect { @client.tracking('201', 'ups') }
59
+ .to_not raise_error
60
+ end
61
+
62
+ it 'response 400' do
63
+ expect { @client.tracking('400', 'ups') }
64
+ .to raise_error(AfterShip::InvalidJSONDataError)
65
+ end
66
+
67
+ it 'response 401' do
68
+ expect { @client.tracking('401', 'ups') }
69
+ .to raise_error(AfterShip::InvalidCredentialsError)
70
+ end
71
+
72
+ it 'response 402' do
73
+ expect { @client.tracking('402', 'ups') }
74
+ .to raise_error(AfterShip::RequestFailedError)
75
+ end
76
+
77
+ it 'response 404' do
78
+ expect { @client.tracking('404', 'ups') }
79
+ .to raise_error(AfterShip::ResourceNotFoundError)
80
+ end
81
+
82
+ it 'response 409' do
83
+ expect { @client.tracking('409', 'ups') }
84
+ .to raise_error(AfterShip::InvalidArgumentError)
85
+ end
86
+
87
+ it 'response 429' do
88
+ expect { @client.tracking('429', 'ups') }
89
+ .to raise_error(AfterShip::TooManyRequestsError)
90
+ end
91
+
92
+ it 'response 500' do
93
+ expect { @client.tracking('500', 'ups') }
94
+ .to raise_error(AfterShip::ServerError)
95
+ end
96
+
97
+ it 'response 502' do
98
+ expect { @client.tracking('502', 'ups') }
99
+ .to raise_error(AfterShip::ServerError)
100
+ end
101
+
102
+ it 'response 503' do
103
+ expect { @client.tracking('503', 'ups') }
104
+ .to raise_error(AfterShip::ServerError)
105
+ end
106
+
107
+ it 'response 504' do
108
+ expect { @client.tracking('504', 'ups') }
109
+ .to raise_error(AfterShip::ServerError)
110
+ end
111
+
112
+ it 'response 666' do
113
+ expect { @client.tracking('666', 'ups') }
114
+ .to raise_error(AfterShip::UnknownError)
115
+ end
116
+ end
117
+
118
+ context 'create_tracking' do
119
+ it 'response 200' do
120
+ expect { @client.create_tracking('ABC123', 'ups') }
121
+ .to_not raise_error
122
+ end
123
+
124
+ it 'with options response 200' do
125
+ expect { @client.create_tracking('ABC123', 'ups', order_id: '1234') }
126
+ .to_not raise_error
127
+ end
128
+ end
129
+
130
+ context 'update_tracking' do
131
+ it 'response 200' do
132
+ expect { @client.update_tracking('ABC123', 'ups', order_id: '1234') }
133
+ .to_not raise_error
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,139 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe AfterShip::Tracking do
4
+ before do
5
+ @client = AfterShip.new(api_key: 'key')
6
+ end
7
+
8
+ context 'Attributes' do
9
+ before do
10
+ data = {
11
+ checkpoints: [
12
+ {
13
+ slug: 'ups',
14
+ city: 'Mumbai',
15
+ created_at: '2014-05-06T08:03:52+00:00',
16
+ country_name: 'IN',
17
+ message: 'BILLING INFORMATION RECEIVED',
18
+ country_iso3: 'IND',
19
+ tag: 'InfoReceived',
20
+ checkpoint_time: '2014-05-01T10:33:38',
21
+ coordinates: [],
22
+ state: nil,
23
+ zip: nil
24
+ }
25
+ ]
26
+ }
27
+
28
+ tracking = AfterShip::Tracking.new(data)
29
+ @checkpoint = tracking.checkpoints.first
30
+ end
31
+
32
+ it 'slug' do
33
+ expect(@checkpoint.slug).to eq('ups')
34
+ end
35
+
36
+ it 'city' do
37
+ expect(@checkpoint.city).to eq('Mumbai')
38
+ end
39
+
40
+ it 'courier' do
41
+ expect(@checkpoint.courier).to eq('UPS')
42
+ end
43
+
44
+ it 'created_at is a DateTime' do
45
+ expect(@checkpoint.created_at).to be_a(DateTime)
46
+ end
47
+
48
+ it 'created_at matches pattern' do
49
+ expect(@checkpoint.created_at.to_s).to eq('2014-05-06T08:03:52+00:00')
50
+ end
51
+
52
+ it 'country_name' do
53
+ expect(@checkpoint.country_name).to eq('IN')
54
+ end
55
+
56
+ it 'country_iso3' do
57
+ expect(@checkpoint.country_iso3).to eq('IND')
58
+ end
59
+
60
+ it 'message' do
61
+ expect(@checkpoint.message).to eq('BILLING INFORMATION RECEIVED')
62
+ end
63
+
64
+ it 'tag' do
65
+ expect(@checkpoint.tag).to eq('InfoReceived')
66
+ end
67
+
68
+ it 'checkpoint_time is a DateTime' do
69
+ expect(@checkpoint.checkpoint_time).to be_a(DateTime)
70
+ end
71
+
72
+ it 'checkpoint_time matches pattern' do
73
+ expect(@checkpoint.checkpoint_time.to_s).to eq('2014-05-01T10:33:38+00:00')
74
+ end
75
+
76
+ it 'state' do
77
+ expect(@checkpoint.state).to be_nil
78
+ end
79
+
80
+ it 'zip' do
81
+ expect(@checkpoint.zip).to be_nil
82
+ end
83
+ end
84
+
85
+ context 'status' do
86
+ it 'Pending' do
87
+ data = { tag: 'Pending' }
88
+ checkpoint = AfterShip::Checkpoint.new(data)
89
+ expect(checkpoint.status).to eq('Pending')
90
+ end
91
+
92
+ it 'InfoReceived' do
93
+ data = { tag: 'InfoReceived' }
94
+ checkpoint = AfterShip::Checkpoint.new(data)
95
+ expect(checkpoint.status).to eq('Info Received')
96
+ end
97
+
98
+ it 'InTransit' do
99
+ data = { tag: 'InTransit' }
100
+ checkpoint = AfterShip::Checkpoint.new(data)
101
+ expect(checkpoint.status).to eq('In Transit')
102
+ end
103
+
104
+ it 'OutForDelivery' do
105
+ data = { tag: 'OutForDelivery' }
106
+ checkpoint = AfterShip::Checkpoint.new(data)
107
+ expect(checkpoint.status).to eq('Out for Delivery')
108
+ end
109
+
110
+ it 'AttemptFail' do
111
+ data = { tag: 'AttemptFail' }
112
+ checkpoint = AfterShip::Checkpoint.new(data)
113
+ expect(checkpoint.status).to eq('Attempt Failed')
114
+ end
115
+
116
+ it 'Delivered' do
117
+ data = { tag: 'Delivered' }
118
+ checkpoint = AfterShip::Checkpoint.new(data)
119
+ expect(checkpoint.status).to eq('Delivered')
120
+ end
121
+
122
+ it 'Exception' do
123
+ data = { tag: 'Exception' }
124
+ checkpoint = AfterShip::Checkpoint.new(data)
125
+ expect(checkpoint.status).to eq('Exception')
126
+ end
127
+
128
+ it 'Expired' do
129
+ data = { tag: 'Expired' }
130
+ checkpoint = AfterShip::Checkpoint.new(data)
131
+ expect(checkpoint.status).to eq('Expired')
132
+ end
133
+
134
+ it 'error' do
135
+ data = { tag: 'error' }
136
+ expect { AfterShip::Checkpoint.new(data) }.to raise_error(KeyError)
137
+ end
138
+ end
139
+ end