after_ship 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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