tesco 0.4.1 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (7) hide show
  1. data/README.md +64 -66
  2. data/Rakefile +19 -19
  3. data/VERSION +1 -1
  4. data/lib/tesco.rb +568 -566
  5. data/tesco.gemspec +39 -0
  6. metadata +23 -44
  7. data/readme.md +0 -67
data/README.md CHANGED
@@ -1,67 +1,65 @@
1
- Tesco Grocery API
2
- =================
3
- A seriously simple library for accessing the Tesco Grocery API.
4
-
5
- Preparations
6
- ------------
7
- The first step here is to [register](https://secure.techfortesco.com/tescoapiweb/) at the Tech for Tesco so you can use their Grocery API.
8
- Some of the calls (like working with baskets) will require non-anonymous login, so you may also want a Tesco account. They don't use any Oauth type stuff, you'll just need a username and password.
9
-
10
- Examples
11
- --------
12
- Everyone loves examples. You don't want to read through all the documentation to get started!
13
-
14
- require 'tesco'
15
-
16
- t = Tesco::Groceries.new('dev_key','api_key')
17
-
18
- # Returns a Products object (see below, it's basically a read-only array)
19
- s = t.search("Chocolates")
20
-
21
- # Check out the Product class for more info about these badboys.
22
- p milky = s[0]
23
- # => Tesco Milk Chocolate Big Buttons 170G
24
-
25
- # Now you'll need to log in:
26
- t.login('joebloggs@tesco.com','supersecret!')
27
-
28
- # This will return *the* instance (ie. calling it twice will give you the same object)
29
- # of the basket for t's currently logged in user.
30
- b = t.basket
31
-
32
- # You can arrange products in the basket like so:
33
- b < milky # Push into the basket (or increment quantity)
34
- b > milky # Completely remove from the basket
35
- # b[milky] is now a 'BasketItem' Object, which you can use to alter amounts and the shopper note.
36
- b[milky].quantity = 5 # Set a specific quantity
37
- b[milky].note = "Please say \"I'm the Milky Bar Kid!\" as you pick it up. Please!"
38
-
39
- # Potentially counter-intuitive, request
40
-
41
- # There can only ever be one basket instance per logged in user:
42
- b.object_id == t.basket.object_id # => true
43
-
44
- # But logging in as a new user won't bugger things up:
45
- t.login('fredsmith@tesco.com','supersecreter!')
46
- b2 = t.basket
47
- b.customer_id # => 1234
48
- b2.customer_id # => 5678
49
-
50
- # And it'll make sure you're not going to bugger things up:
51
- b > milky
52
- # NotAuthenticatedError, Please reauthenticate as this basket's owner before attempting to modify it
53
-
54
-
55
- ### The Products Class
56
- As with most APIs that cover loads of information, the data is usually paginated. In order to make life simple I've designed this library so you don't have to worry about that at all. Paginated responses are
57
-
58
- ### Keeping up with Tesco API development
59
- The default endpoint for the service is currently the [beta 1](http://www.techfortesco.com/groceryapi_b1/RESTService.aspx). You can alter this after instantiating a Tesco object with:
60
-
61
- t.endpoint = "http://another.tesco.api/testing"
62
-
63
- I'll endeavour to keep this library as up to date as I can, but in the event that there's a method you want to use that I haven't created you can either hack at the code by forking the [repo on Github](http://github.com/jphastings/TescoGroceries) or you can just do this:
64
-
65
- t.the_new_api_call(:searchtext => "A string!", :becool => true)
66
-
1
+ # Tesco Grocery API
2
+ A seriously simple library for accessing the Tesco Grocery API.
3
+
4
+ ## Preparations
5
+ The first step here is to register (https://secure.techfortesco.com/tescoapiweb/) at the Tech for Tesco so you can use their Grocery API.
6
+ Some of the calls (like working with baskets) will require non-anonymous login, so you may also want a Tesco account. They don't use any Oauth type stuff, you'll just need a username and password.
7
+
8
+ ## Examples
9
+
10
+ Everyone loves examples. You don't want to read through all the documentation to get started!
11
+
12
+ require 'tesco'
13
+
14
+ t = Tesco::Groceries.new('dev_key','api_key')
15
+
16
+ # Returns a Products object (see below, it's basically a read-only array)
17
+ s = t.search("Chocolates")
18
+
19
+ # Check out the Product class for more info about these badboys.
20
+ p milky = s[0]
21
+ # => Tesco Milk Chocolate Big Buttons 170G
22
+
23
+ # Now you'll need to log in:
24
+ t.login('joebloggs@tesco.com','supersecret!')
25
+
26
+ # This will return *the* instance (ie. calling it twice will give you the same object)
27
+ # of the basket for t's currently logged in user.
28
+ b = t.basket
29
+
30
+ # You can arrange products in the basket like so:
31
+ b < milky # Push into the basket (or increment quantity)
32
+ b > milky # Completely remove from the basket
33
+ # b[milky] is now a 'BasketItem' Object, which you can use to alter amounts and the shopper note.
34
+ b[milky].quantity = 5 # Set a specific quantity
35
+ b[milky].note = "Please say \"I'm the Milky Bar Kid!\" as you pick it up. Please!"
36
+
37
+ # Potentially counter-intuitive, request
38
+
39
+ # There can only ever be one basket instance per logged in user:
40
+ b.object_id == t.basket.object_id # => true
41
+
42
+ # But logging in as a new user won't bugger things up:
43
+ t.login('fredsmith@tesco.com','supersecreter!')
44
+ b2 = t.basket
45
+ b.customer_id # => 1234
46
+ b2.customer_id # => 5678
47
+
48
+ # And it'll make sure you're not going to bugger things up:
49
+ b > milky
50
+ # NotAuthenticatedError, Please reauthenticate as this basket's owner before attempting to modify it
51
+
52
+
53
+ ### The Products Class
54
+ As with most APIs that cover loads of information, the data is usually paginated. In order to make life simple I've designed this library so you don't have to worry about that at all. Paginated responses are
55
+
56
+ ### Keeping up with Tesco API development
57
+ The default endpoint for the service is currently the beta 1 (http://www.techfortesco.com/groceryapi_b1/RESTService.aspx). You can alter this after instantiating a Tesco object with:
58
+
59
+ t.endpoint = "http://another.tesco.api/testing"
60
+
61
+ I'll endeavour to keep this library as up to date as I can, but in the event that there's a method you want to use that I haven't created you can either hack at the code by forking the repo on Github (http://github.com/jphastings/TescoGroceries) or you can just do this:
62
+
63
+ t.the_new_api_call(:searchtext => "A string!", :becool => true)
64
+
67
65
  This will send the command to the Tesco API as 'THENEWAPICALL' with the parameters in tow. You'll get a Hash back of the parsed JSON that comes direct from the API, so no fancy objects/DSL to work with, but the `api_request` method (which does all the hard work) *will* raise a subclass of TescoApiError if your request was dodgey, as per usual.
data/Rakefile CHANGED
@@ -1,20 +1,20 @@
1
- require 'rubygems'
2
- require 'rake'
3
-
4
- begin
5
- require 'jeweler'
6
- Jeweler::Tasks.new do |gem|
7
- gem.name = "tesco"
8
- gem.summary = %Q{An extremely straightforward library for the Tesco Grocery API}
9
- gem.description = %Q{Search the Tesco Groceries API, through a very object oriented library}
10
- gem.email = "jphastings@gmail.com"
11
- gem.homepage = "http://github.com/jphastings/TescoGroceries"
12
- gem.authors = ["JP Hastings-Spital"]
13
- # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
14
- end
15
- Jeweler::GemcutterTasks.new
16
- rescue LoadError
17
- puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
18
- end
19
-
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "tesco"
8
+ gem.summary = %Q{An extremely straightforward library for the Tesco Grocery API}
9
+ gem.description = %Q{Search the Tesco Groceries API, through a very object oriented library}
10
+ gem.email = "jphastings@gmail.com"
11
+ gem.homepage = "http://github.com/jphastings/TescoGroceries"
12
+ gem.authors = ["JP Hastings-Spital"]
13
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
14
+ end
15
+ Jeweler::GemcutterTasks.new
16
+ rescue LoadError
17
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
18
+ end
19
+
20
20
  task :default => :build
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.1
1
+ 0.4.2
@@ -1,567 +1,569 @@
1
- # == Tesco API
2
- # See the readme.md for examples and such.
3
- #
4
- # = TODO
5
- # * Evaluate usefulness of the Department/Aisle/Shelf class division currently used
6
- # * Substitution with the basket?
7
- # * Might need class for basket item
8
-
9
- require 'net/http'
10
- require 'uri'
11
- require 'json'
12
- require 'time'
13
- require 'digest/md5'
14
- require 'delegate'
15
-
16
- module Tesco
17
-
18
- # Unobtrusive modifications to the Class class.
19
- class Class
20
- # Pass a block to attr_reader and the block will be evaluated in the context of the class instance before
21
- # the instance variable is returned.
22
- def attr_reader(*params,&block)
23
- if block_given?
24
- params.each do |sym|
25
- # Create the reader method
26
- define_method(sym) do
27
- # Force the block to execute before we…
28
- self.instance_eval(&block)
29
- # return the instance variable
30
- self.instance_variable_get("@#{sym}")
31
- end
32
- end
33
- else # Keep the original function of attr_reader
34
- params.each do |sym|
35
- attr sym
36
- end
37
- end
38
- end
39
- end
40
-
41
- # You'll need an API and Developer key from https://secure.techfortesco.com/tescoapiweb/
42
- class Groceries
43
- attr_accessor :endpoint
44
- attr_reader(:customer_name,:customer_forename, :customer_id, :branch_number) { raise NotAuthenticatedError if @anonymous_mode }
45
-
46
- # Instantiates a tesco object with your developer and application keys
47
- def initialize(developer_key,application_key)
48
- @endpoint = URI.parse('http://www.techfortesco.com/groceryapi_b1/RESTService.aspx')
49
- @developer_key = developer_key
50
- @application_key = application_key
51
- end
52
-
53
- # Sets the api endpoint as a URI object
54
- def endpoint=(uri)
55
- @endpoint = URI.parse(uri)
56
- end
57
-
58
- # Search Tesco's grocery product listing. Returns a list of Products in a special object that acts like a read-only array.
59
- def search(q)
60
- Products.new(self,api_request(:productsearch,:searchtext => q))
61
- end
62
-
63
- # List all products currently on offer
64
- def on_offer
65
- Products.new(self,api_request(:listproductoffers))
66
- end
67
-
68
- # List all favourite grocery items (requires a non-anonymous login)
69
- def favourites
70
- raise NotAuthenticatedError if @anonymous_mode
71
- Products.new(self,api_request(:listfavourites))
72
- end
73
-
74
- def departments
75
- @@shelves = []
76
- api_request(:listproductcategories)['Departments'].collect {|dept|
77
- dept['Aisles'].each do |aisle|
78
- aisle['Shelves'].each do |shelf|
79
- @@shelves.push(Shelf.new(self,shelf))
80
- end
81
- end
82
- Department.new(self,dept)
83
- }
84
- end
85
-
86
- # Returns a Basket instance, Tesco API keeps track of the items in your basket in between sessions (TODO: i think!)
87
- def basket
88
- Basket.new(self)
89
- end
90
-
91
- # Lists all the products in the given category, as determined from the shelf id. You're probably better off using #departments, then
92
- # #Department#aisles, then #Aisle#shelves then Shelf#products which is an alias for this method.
93
- #
94
- # ie. tesco.departments[0].aisles[0].shelves[0].products
95
- def products_by_category(shelf_id)
96
- raise ArgumentError, "#{shelf_id} is not a valid Shelf ID" if not shelf_id.to_i > 0
97
- Products.new(self,api_request(:listproductsbycategory,:category => shelf_id))
98
- end
99
-
100
- # A convenience method, this will search all the shelves by name and return an array of Shelf objects that match.
101
- #
102
- # You'll probably want to send a regexp with the case insensitive tag: /kitchen/i
103
- def search_shelves(q)
104
- raise ArgumentError, "The argument needs to be a Regular Expression." if not q.is_a? Regexp
105
- departments if not @@shelves.is_a? Array
106
- @@shelves.select {|shelf| shelf.name =~ q }
107
- end
108
-
109
- # Authenticates as the given user or as anonymous with the default parameters. Anonymous login will occur automatically
110
- # upon any request if a login hasn't already occured
111
- def login(email = '',password = '')
112
- res = api_request(:login,:email => email, :password => password)
113
- @anonymous_mode = (res['ChosenDeliverySlotInfo'] == "Not applicable with anonymous login")
114
- # TODO:InAmendOrderMode
115
-
116
- # The Tesco API returns "Mrs. Test-Farquharson-Symthe" for CustomerName in anonymous mode, for now I'll not include this in the Ruby library
117
- if !@anonymous_mode # Not for anonymous mode
118
- @customer_forename = res['CustomerForename']
119
- @customer_name = res['CustomerName']
120
- @customer_id = res['CustomerId']
121
- @branch_number = res['BranchNumber']
122
- end
123
- @session_key = res['SessionKey']
124
- return true
125
- end
126
-
127
- # Are we in anonymous mode?
128
- def anonymous?
129
- !@anonymous_mode
130
- end
131
-
132
- # Send a command to the Tesco API directly using the keys set up already. It will return a parsed version
133
- # of the direct output from the RESTful service. Status codes other than 0 will still raise errors as usual.
134
- #
135
- # Useful if you want a little more control over the the results, shouldn't be necessary.
136
- def api_request(command,params = {})
137
- login if @session_key.nil? and command != :login # Do an anonymous login if we're not authenticated
138
- params.merge!({:sessionkey => @session_key}) if not @session_key.nil?
139
- params = {
140
- :command => command,
141
- :applicationkey => @application_key,
142
- :developerkey => @developer_key,
143
- :page => 1 # Will be overwritten by a page in params
144
- }.merge(params)
145
-
146
- json = Net::HTTP.get(@endpoint.host,@endpoint.path+"?"+params.collect { |k,v| "#{k}=#{URI::escape(v.to_s)}" }.join('&'))
147
-
148
- res = JSON::load(json)
149
- res[:requestparameters] = params
150
-
151
- case res['StatusCode']
152
- when 0
153
- # Everything went well
154
- return res
155
- when 200
156
- raise NotAuthenticatedError
157
- # TODO: Other status codes
158
- else
159
- p res
160
- raise TescoApiError, "Unknown status code! Something went wrong — sorry"
161
- end
162
- end
163
-
164
- # If there are any other (ie. new) Tesco API calls this will make them available directly:
165
- #
166
- # An api command 'SEARCHELECTRONICS' (if one existed) would be available as
167
- # #search_electronics(:searchtext => 'computer',:parameter1 => 'an option')
168
- def method_missing(method,params = {})
169
- api_request(method.to_s.gsub("_",""),params)
170
- end
171
-
172
- # Represents an individual grocery item, #healthier_alternative, #cheaper_alternative and #base_product are
173
- # populated as detailess Products. Requesting any information from these will retrieve full information from
174
- # the API.
175
- class Product
176
- attr_reader(:image_url, :name, :max_quantity, :offer) { details if @name.nil? }
177
- attr_reader :healthier_alternative, :cheaper_alternative, :base_product
178
- attr_reader :product_id
179
-
180
- # Don't use this yourself!
181
- #
182
- # The unusual initialization here is so that there is only ever one instance of each product.
183
- # This means that using #Products as keys in a hash will always work, and (as they're identical)
184
- # it'll also save memory.
185
- def self.new(api,product_id,more = nil) # :nodoc:
186
- raise ArgumentError, "Not a product id" if not product_id =~ /^\d+$/
187
- @@instances ||= {}
188
-
189
- # Make sure we only ever have on instance of each product
190
- # If we have an instance then we should just return that
191
- if @@instances[product_id]
192
- # If we've been passed more then set it as it'll be more up-to-date
193
- @@instances[product_id].instance_variable_set(:@more,more) if !more.nil?
194
- return @@instances[product_id]
195
- end
196
-
197
- # We don't have an instance of this product yet, go ahead and make one
198
- new_product = self.allocate
199
- new_product.instance_variable_set(:@product_id,product_id)
200
- new_product.instance_variable_set(:@api,api)
201
- new_product.instance_variable_set(:@more,more)
202
- new_product.details if !more.nil?
203
- @@instances[product_id] = new_product
204
- end
205
-
206
- def inspect
207
- name
208
- end
209
-
210
- # Will refresh the details of the product.
211
- def details
212
- # If we had some free 'more' data from the instanciation we should use it!
213
- if @more
214
- more = @more
215
- remove_instance_variable(:@more)
216
- end
217
-
218
- # If we have no data then we should get it from the ProductId
219
- if more.nil?
220
- # TODO: check to see if there are more than one
221
- more = @api.api_request('productsearch',:searchtext => @product_id)['Products'][0]
222
- end
223
-
224
- @healthier_alternative = Product.new(@api,more['HealthierAlthernativeProductId']) rescue nil
225
- @cheaper_alternative = Product.new(@api,more['CheaperAlthernativeProductId']) rescue nil
226
- @base_product = Product.new(@api,more['BaseProductId']) rescue nil
227
- @image_url = more['ImagePath']
228
- # ProducyType?
229
- # OfferValidity?
230
- @name = more['Name']
231
- @max_quantity = more['MaximumPurchaseQuantity']
232
- @barcode = Barcode.new(more['EANBarcode'])
233
- @offer = Offer.new(more['OfferLabelImagePath'],more['OfferPromotion'],more['OfferValidity']) rescue nil
234
- end
235
- end
236
-
237
- # Represents a shopping basket.
238
- class Basket < Hash
239
- attr_reader :basket_id, :quantity, :customer_id
240
- attr_reader(:price, :multi_buy_savings, :clubcard_points) { sync } # These are calculated on the server, so we need to sync before returning them
241
-
242
- # You can initialize your own basket with Basket.new(tesco_api_instance), but I'd recommend using
243
- # #Tesco#basket.
244
- #
245
- # Because this object will sync with the Tesco server there can only ever be one instance. It will
246
- # keep track of different users' baskets. (Wipe this memory with #Basket#flush)
247
- def self.new(api)
248
- raise ArgumentError, "The argument needs to be a Tesco instance" if not api.is_a? Tesco::Groceries
249
- begin
250
- return @@basket[api.customer_id]
251
- rescue
252
- (@@basket ||= {})[api.customer_id] = self.allocate
253
- @@basket[api.customer_id].instance_variable_set(:@api,api)
254
- @@basket[api.customer_id].instance_variable_set(:@customer_id,api.customer_id)
255
- @@basket[api.customer_id].sync
256
- @@basket[api.customer_id]
257
- end
258
- end
259
-
260
- # This class keeps track of all the baskets for each user that's been logged in, to save on API calls.
261
- # If you need to remove this information from memory this method will destroy the class variable that holds
262
- # it, without affecting anything on the Tesco servers.
263
- def self.flush
264
- @@basket.clean
265
- end
266
-
267
- # Makes sure this object reflects the basket on Tesco online.
268
- def sync
269
- authtest
270
- res = @api.api_request(:listbasket)
271
- @basket_id = res['BasketId'].to_i
272
- @price = res['BasketGuidePrice'].to_f
273
- @multi_buy_savings = res['BasketGuideMultiBuySavings'].to_f
274
- @clubcard_points = res['BasketTotalClubcardPoints'].to_i
275
- @quantity = res['BasketQuantity'] # TODO: Is this just length?
276
-
277
- res['BasketLines'].each do |more|
278
- self[Product.new(@api,more['ProductId'],more)] = BasketItem.new(self,more)
279
- end
280
-
281
- return true
282
- end
283
-
284
- # Change the note for the shopper on a product in your basket
285
- def note(product,note) # TODO: should work with multiple products
286
- authtest
287
- raise IndexError, "That item is not in the basket" if not self[product]
288
- @api.api_request(:changebasket,:productid => product.product_id,:changequantity => 0,:noteforshopper => note)
289
- self[product].instance_variable_set(:@note,note)
290
- end
291
-
292
- # Adds the given product(s) to the basket. It increments that item's quantity if it's already present in the basket. Leave a note for the shopper against these items with note.
293
- def <(products)
294
- authtest
295
- [products].flatten.each do |product|
296
- raise ArgumentError, "That is not a Tesco Product object" if !product.is_a?(Tesco::Groceries::Product)
297
- (self[product] ||= BasketItem.new(self,{'ProductId' => product.product_id})).add(1)
298
- end
299
- end
300
- alias_method :add, :<
301
-
302
- # Removes the given product(s) completely from the basket.
303
- def >(products)
304
- authtest
305
- [products].flatten.each do |product|
306
- raise ArgumentError, "That is not a Tesco Product object" if !product.is_a?(Tesco::Groceries::Product)
307
- delete(product).remove # Removes the product from the basket, then deletes it from the API
308
- end
309
- end
310
- alias_method :remove, :>
311
-
312
- # Empties the basket completely — this may take a while for large baskets
313
- def clean
314
- authtest
315
- self.each_pair do |product,basket_item|
316
- delete(product).remove # Removes the product from the basket, then deletes it from the API
317
- end
318
- end
319
-
320
- # tests to make sure you are authenticated as this basket's owner
321
- def authtest
322
- raise NotAuthenticatedError, "Please reauthenticate as this basket's owner before attempting to modify it" if @customer_id != @api.customer_id
323
- end
324
- end
325
-
326
- # Assists in the modification of basketed products
327
- #
328
- # TODO: Correct basket auth?
329
- class BasketItem < DelegateClass(Product)
330
- attr_accessor :note, :quantity, :error_message, :promo_message
331
- # I wouldn't mess around with this from your code, its essentially internal
332
- def self.new(basket,more) # :nodoc:
333
- @basket = basket
334
-
335
- # With a little hackiness because Product initializes with self.new, not initialize
336
- basket_item = super(Product.new(basket.instance_variable_get(:@api),more['ProductId'],more))
337
- # Set it's instance variables
338
- basket_item.instance_variable_set(:@quantity,(more['BasketLineQuantity'].to_i rescue 0))
339
- basket_item.instance_variable_set(:@error_message,(more['BasketLineErrorMessage'] rescue "")) # TODO: Parse this
340
- basket_item.instance_variable_set(:@promo_message,(more['BasketLinePromoMessage'] rescue ""))
341
- basket_item.instance_variable_set(:@note,(more['NoteForPersonalShopper'] rescue ""))
342
-
343
- basket_item
344
- end
345
-
346
- # Update the server if the NoteForShopper is changed
347
-
348
- # TODO: set shopper note
349
- def note=(note)
350
- @basket.authtest
351
- end
352
-
353
- # Add a certain number of items to the basket
354
- def add(val = 1)
355
- @basket.authtest
356
- return remove if (@quantity + val) <= 0
357
- __getobj__.instance_variable_get(:@api).api_request(:changebasket,:productid => self.product_id,:changequantity => val,:noteforshopper => @note)
358
- @quantity
359
- end
360
-
361
- # Remove a certain number of items from the basket
362
- def drop(val = 1)
363
- @basket.authtest
364
- add(val * -1)
365
- end
366
-
367
- # Alter the quantity to a specific amount
368
- def quantity=(amount)
369
- @basket.authtest
370
- return @quantity if @quantity == amount # No need to do anything if they're the same
371
- raise ArgumentError, "amount must be >= 0 and <= #{self.max_quantity}" if (not amount.is_a? Integer) or amount < 0 or amount > self.max_quantity
372
- __getobj__.instance_variable_get(:@api).api_request(:changebasket,:productid => self.product_id,:changequantity => amount - @quantity,:noteforshopper => @note)
373
- @quantity -= amount
374
- end
375
-
376
- # Remove this item from the basket completely
377
- def remove
378
- @basket.authtest
379
- @basket.remove(__getobj__)
380
- end
381
-
382
- def inspect
383
- @basket.authtest
384
- "#{@quantity} item"<<((@quantity == 1) ? "" : "s")
385
- end
386
- end
387
-
388
- class Department
389
- attr_reader :id, :name
390
- # No point in creating these by hand
391
- def initialize(api,details) # :nodoc:
392
- @id = details['Id']
393
- @name = details['Name']
394
- @aisles = details['Aisles'].collect { |aisle|
395
- Aisle.new(api,aisle)
396
- }
397
- end
398
-
399
- # Lists all aisles in this department. Each item is an Aisle object
400
- def aisles
401
- @aisles
402
- end
403
-
404
- def inspect
405
- "#{@name} Department"
406
- end
407
- end
408
-
409
- class Aisle
410
- attr_reader :aisle_id, :name
411
- # No point in creating these by hand.
412
- def initialize(api,details) # :nodoc:
413
- @id = details['Id']
414
- @name = details['Name']
415
- @shelves = details['Shelves'].collect { |shelf|
416
- Shelf.new(api,shelf)
417
- }
418
- end
419
-
420
- # Lists all shelves in this aisle. Each item is a Shelf object
421
- def shelves
422
- @shelves
423
- end
424
-
425
- def inspect
426
- "#{@name} Aisle"
427
- end
428
- end
429
-
430
- class Shelf
431
- attr_reader :department,:aisle, :aisle_id, :name
432
- # No point in creating these by hand.
433
- def initialize(api,details) # :nodoc:
434
- @api = api
435
- @id = details['Id']
436
- @name = details['Name']
437
- end
438
-
439
- def products
440
- @api.products_by_category(@id)
441
- end
442
-
443
- def inspect
444
- "#{@name} Shelf"
445
- end
446
- end
447
-
448
- # A special class that takes care of product pagination automatically. It'll work like a read-only array for the most part
449
- # but you can request a specific page with #page and requesting a specific item with #[] will request the required page automatically
450
- # if it hasn't already been retrieved and stored within the instance's cache.
451
- class Paginated
452
- attr_reader :length, :pages
453
- # Don't use this yourself!
454
- def initialize(api,res) # :nodoc:
455
- @cached_pages = {}
456
- @api = api
457
- # Do the page we've been given (usually the first)
458
- @cached_pages[res['PageNumber'] || 1] = parse_items(res)
459
- @pages = res['TotalPageCount'] || 1
460
- @perpage = res['PageProductCount']
461
- @length = res['TotalProductCount'] || @perpage
462
- @params = res[:requestparameters]
463
- end
464
-
465
- # Will return the item at the requested index, even if that page hasn't yet been retreived
466
- def [](n)
467
- raise TypeError, "That isn't a valid array reference" if not (n.is_a? Integer and n >= 0)
468
- raise PaginationError, "That index exceeds the number of items" if n >= @length
469
- page_num = (n / @perpage).floor + 1
470
- page(page_num)[n - page_num * @perpage]
471
- end
472
-
473
- # Will return all the items on the requested page (indeces will be relative to the page).
474
- # Specifying page = 0 or page = :all will give an array of all items, retrieving all details first
475
- # This could take a very, vey long time!
476
- def page(page)
477
- page = 0 if page == :all
478
- raise PaginationError, "That isn't a valid page reference" if not (page.is_a? Integer and page >= 0 and page <= @pages)
479
- if !@cached_pages.keys.include?(page)
480
- @cached_pages[res['PageNumber'] || 1] = parse_items(@api.api_request(nil,@params.merge({:page => page})))
481
- end
482
- @cached_pages[page]
483
- end
484
-
485
- # Akin to Array#each, except you must specify which page, or range of pages, of products you wish to iterate over.
486
- #
487
- # Specifying page = 0 or page = :all will iterate over every item on every page
488
- #
489
- # The items on each page will be passed to your block as they're retrieved, so you'll get spurts of output.
490
- #
491
- # NB. This method won't check your enumberable for each item being a valid page until it's processed all prior pages.
492
- def each(pages = :all)
493
- pages = (1..@pages) if (pages == :all or pages == 0)
494
- pages = [pages] if not pages.is_a? Enumerable
495
- pages.each do |page|
496
- raise PaginationError, "#{page.inspect} isn't a valid page reference" if not (page.is_a? Integer and page >= 0 and page <= @pages)
497
- page(page).each do |item|
498
- yield item
499
- end
500
- end
501
- end
502
-
503
- def inspect
504
- output = ""
505
- previous = 0
506
- @cached_pages.each_pair do |page,content|
507
- output << ", … #{(page - previous) * @perpage} more …" if (previous + 1) != page
508
- output << ", " << content.inspect[1..-2]
509
- previous = page
510
- end
511
- output << ", … #{@length - previous * @perpage} more" if previous != @pages
512
- "[#{output[2..-1]}]"
513
- end
514
-
515
- private
516
- # There's no parsing for the default pagination class, make a subclass and write your own parse method
517
- # Take a look at #Products if you want to see how it's done.
518
- def parse_items(res); res; end
519
- end
520
-
521
- # Deals with the specifics of paginating products.
522
- class Products < Paginated
523
- private
524
- def parse_items(res)
525
- res['Products'].collect{|json|
526
- Product.new(@api,json['ProductId'],json)
527
- }
528
- end
529
- end
530
-
531
- class Offer
532
- attr_reader :image_url, :description, :validity
533
- attr_reader :valid_from, :valid_until
534
-
535
- # No point in making these by hand!
536
- def initialize(image_url,descr,validity) # :nodoc:
537
- raise ArgumentError, "Not a valid offer" if descr.nil? or descr.empty?
538
- @description = descr
539
- @image_url = image_url
540
- @validity = validity
541
- @valid_from, @valid_until = Time.utc($3,$2,$1), Time.utc($6,$5,$4) if validity =~ /^valid from (\d{1,2})\/(\d{1,2})\/(\d{4}) until (\d{1,2})\/(\d{1,2})\/(\d{4})$/
542
- end
543
- end
544
-
545
- private
546
- class PaginationError < IndexError; end
547
- class TescoApiError < RuntimeError
548
- def to_s; "An unspecified error has occured on the server side."; end
549
- end
550
- class NoSessionKeyError < TescoApiError
551
- def to_s; "The session key has been declined, try logging in again."; end
552
- end
553
- class NotAuthenticatedError < TescoApiError
554
- def to_s; "You must be an authenticated non-anonymous user."; end
555
- end
556
- end
557
-
558
- class Barcode
559
- def initialize(code)
560
- @barcode = code.to_s
561
- end
562
-
563
- def to_s
564
- @barcode
565
- end
566
- end
1
+ # encoding: UTF-8
2
+
3
+ # == Tesco API
4
+ # See the readme for examples and such.
5
+ #
6
+ # = TODO
7
+ # * Evaluate usefulness of the Department/Aisle/Shelf class division currently used
8
+ # * Substitution with the basket?
9
+ # * Add some useful barcode features, maybe allow lookup of products by barcode?
10
+
11
+ require 'net/http'
12
+ require 'uri'
13
+ require 'json'
14
+ require 'time'
15
+ require 'digest/md5'
16
+ require 'delegate'
17
+
18
+ module Tesco
19
+
20
+ # Unobtrusive modifications to the Class class.
21
+ class Class
22
+ # Pass a block to attr_reader and the block will be evaluated in the context of the class instance before
23
+ # the instance variable is returned.
24
+ def attr_reader(*params,&block)
25
+ if block_given?
26
+ params.each do |sym|
27
+ # Create the reader method
28
+ define_method(sym) do
29
+ # Force the block to execute before we…
30
+ self.instance_eval(&block)
31
+ # … return the instance variable
32
+ self.instance_variable_get("@#{sym}")
33
+ end
34
+ end
35
+ else # Keep the original function of attr_reader
36
+ params.each do |sym|
37
+ attr sym
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ # You'll need an API and Developer key from https://secure.techfortesco.com/tescoapiweb/
44
+ class Groceries
45
+ attr_accessor :endpoint
46
+ attr_reader(:customer_name,:customer_forename, :customer_id, :branch_number) { raise NotAuthenticatedError if @anonymous_mode }
47
+
48
+ # Instantiates a tesco object with your developer and application keys
49
+ def initialize(developer_key,application_key)
50
+ @endpoint = URI.parse('http://www.techfortesco.com/groceryapi_b1/RESTService.aspx')
51
+ @developer_key = developer_key
52
+ @application_key = application_key
53
+ end
54
+
55
+ # Sets the api endpoint as a URI object
56
+ def endpoint=(uri)
57
+ @endpoint = URI.parse(uri)
58
+ end
59
+
60
+ # Search Tesco's grocery product listing. Returns a list of Products in a special object that acts like a read-only array.
61
+ def search(q)
62
+ Products.new(self,api_request(:productsearch,:searchtext => q))
63
+ end
64
+
65
+ # List all products currently on offer
66
+ def on_offer
67
+ Products.new(self,api_request(:listproductoffers))
68
+ end
69
+
70
+ # List all favourite grocery items (requires a non-anonymous login)
71
+ def favourites
72
+ raise NotAuthenticatedError if @anonymous_mode
73
+ Products.new(self,api_request(:listfavourites))
74
+ end
75
+
76
+ def departments
77
+ @@shelves = []
78
+ api_request(:listproductcategories)['Departments'].collect {|dept|
79
+ dept['Aisles'].each do |aisle|
80
+ aisle['Shelves'].each do |shelf|
81
+ @@shelves.push(Shelf.new(self,shelf))
82
+ end
83
+ end
84
+ Department.new(self,dept)
85
+ }
86
+ end
87
+
88
+ # Returns a Basket instance, Tesco API keeps track of the items in your basket in between sessions (TODO: i think!)
89
+ def basket
90
+ Basket.new(self)
91
+ end
92
+
93
+ # Lists all the products in the given category, as determined from the shelf id. You're probably better off using #departments, then
94
+ # #Department#aisles, then #Aisle#shelves then Shelf#products which is an alias for this method.
95
+ #
96
+ # ie. tesco.departments[0].aisles[0].shelves[0].products
97
+ def products_by_category(shelf_id)
98
+ raise ArgumentError, "#{shelf_id} is not a valid Shelf ID" if not shelf_id.to_i > 0
99
+ Products.new(self,api_request(:listproductsbycategory,:category => shelf_id))
100
+ end
101
+
102
+ # A convenience method, this will search all the shelves by name and return an array of Shelf objects that match.
103
+ #
104
+ # You'll probably want to send a regexp with the case insensitive tag: /kitchen/i
105
+ def search_shelves(q)
106
+ raise ArgumentError, "The argument needs to be a Regular Expression." if not q.is_a? Regexp
107
+ departments if not @@shelves.is_a? Array
108
+ @@shelves.select {|shelf| shelf.name =~ q }
109
+ end
110
+
111
+ # Authenticates as the given user or as anonymous with the default parameters. Anonymous login will occur automatically
112
+ # upon any request if a login hasn't already occured
113
+ def login(email = '',password = '')
114
+ res = api_request(:login,:email => email, :password => password)
115
+ @anonymous_mode = (res['ChosenDeliverySlotInfo'] == "Not applicable with anonymous login")
116
+ # TODO:InAmendOrderMode
117
+
118
+ # The Tesco API returns "Mrs. Test-Farquharson-Symthe" for CustomerName in anonymous mode, for now I'll not include this in the Ruby library
119
+ if !@anonymous_mode # Not for anonymous mode
120
+ @customer_forename = res['CustomerForename']
121
+ @customer_name = res['CustomerName']
122
+ @customer_id = res['CustomerId']
123
+ @branch_number = res['BranchNumber']
124
+ end
125
+ @session_key = res['SessionKey']
126
+ return true
127
+ end
128
+
129
+ # Are we in anonymous mode?
130
+ def anonymous?
131
+ !@anonymous_mode
132
+ end
133
+
134
+ # Send a command to the Tesco API directly using the keys set up already. It will return a parsed version
135
+ # of the direct output from the RESTful service. Status codes other than 0 will still raise errors as usual.
136
+ #
137
+ # Useful if you want a little more control over the the results, shouldn't be necessary.
138
+ def api_request(command,params = {})
139
+ login if @session_key.nil? and command != :login # Do an anonymous login if we're not authenticated
140
+ params.merge!({:sessionkey => @session_key}) if not @session_key.nil?
141
+ params = {
142
+ :command => command,
143
+ :applicationkey => @application_key,
144
+ :developerkey => @developer_key,
145
+ :page => 1 # Will be overwritten by a page in params
146
+ }.merge(params)
147
+
148
+ json = Net::HTTP.get(@endpoint.host,@endpoint.path+"?"+params.collect { |k,v| "#{k}=#{URI::escape(v.to_s)}" }.join('&'))
149
+
150
+ res = JSON::load(json)
151
+ res[:requestparameters] = params
152
+
153
+ case res['StatusCode']
154
+ when 0
155
+ # Everything went well
156
+ return res
157
+ when 200
158
+ raise NotAuthenticatedError
159
+ # TODO: Other status codes
160
+ else
161
+ p res
162
+ raise TescoApiError, "Unknown status code! Something went wrong - sorry"
163
+ end
164
+ end
165
+
166
+ # If there are any other (ie. new) Tesco API calls this will make them available directly:
167
+ #
168
+ # An api command 'SEARCHELECTRONICS' (if one existed) would be available as
169
+ # #search_electronics(:searchtext => 'computer',:parameter1 => 'an option')
170
+ def method_missing(method,params = {})
171
+ api_request(method.to_s.gsub("_",""),params)
172
+ end
173
+
174
+ # Represents an individual grocery item, #healthier_alternative, #cheaper_alternative and #base_product are
175
+ # populated as detailess Products. Requesting any information from these will retrieve full information from
176
+ # the API.
177
+ class Product
178
+ attr_reader(:image_url, :name, :max_quantity, :offer) { details if @name.nil? }
179
+ attr_reader :healthier_alternative, :cheaper_alternative, :base_product
180
+ attr_reader :product_id
181
+
182
+ # Don't use this yourself!
183
+ #
184
+ # The unusual initialization here is so that there is only ever one instance of each product.
185
+ # This means that using #Products as keys in a hash will always work, and (as they're identical)
186
+ # it'll also save memory.
187
+ def self.new(api,product_id,more = nil) # :nodoc:
188
+ raise ArgumentError, "Not a product id" if not product_id =~ /^\d+$/
189
+ @@instances ||= {}
190
+
191
+ # Make sure we only ever have on instance of each product
192
+ # If we have an instance then we should just return that
193
+ if @@instances[product_id]
194
+ # If we've been passed more then set it as it'll be more up-to-date
195
+ @@instances[product_id].instance_variable_set(:@more,more) if !more.nil?
196
+ return @@instances[product_id]
197
+ end
198
+
199
+ # We don't have an instance of this product yet, go ahead and make one
200
+ new_product = self.allocate
201
+ new_product.instance_variable_set(:@product_id,product_id)
202
+ new_product.instance_variable_set(:@api,api)
203
+ new_product.instance_variable_set(:@more,more)
204
+ new_product.details if !more.nil?
205
+ @@instances[product_id] = new_product
206
+ end
207
+
208
+ def inspect
209
+ name
210
+ end
211
+
212
+ # Will refresh the details of the product.
213
+ def details
214
+ # If we had some free 'more' data from the instanciation we should use it!
215
+ if @more
216
+ more = @more
217
+ remove_instance_variable(:@more)
218
+ end
219
+
220
+ # If we have no data then we should get it from the ProductId
221
+ if more.nil?
222
+ # TODO: check to see if there are more than one
223
+ more = @api.api_request('productsearch',:searchtext => @product_id)['Products'][0]
224
+ end
225
+
226
+ @healthier_alternative = Product.new(@api,more['HealthierAlthernativeProductId']) rescue nil
227
+ @cheaper_alternative = Product.new(@api,more['CheaperAlthernativeProductId']) rescue nil
228
+ @base_product = Product.new(@api,more['BaseProductId']) rescue nil
229
+ @image_url = more['ImagePath']
230
+ # ProducyType?
231
+ # OfferValidity?
232
+ @name = more['Name']
233
+ @max_quantity = more['MaximumPurchaseQuantity']
234
+ @barcode = Barcode.new(more['EANBarcode'])
235
+ @offer = Offer.new(more['OfferLabelImagePath'],more['OfferPromotion'],more['OfferValidity']) rescue nil
236
+ end
237
+ end
238
+
239
+ # Represents a shopping basket.
240
+ class Basket < Hash
241
+ attr_reader :basket_id, :quantity, :customer_id
242
+ attr_reader(:price, :multi_buy_savings, :clubcard_points) { sync } # These are calculated on the server, so we need to sync before returning them
243
+
244
+ # You can initialize your own basket with Basket.new(tesco_api_instance), but I'd recommend using
245
+ # #Tesco#basket.
246
+ #
247
+ # Because this object will sync with the Tesco server there can only ever be one instance. It will
248
+ # keep track of different users' baskets. (Wipe this memory with #Basket#flush)
249
+ def self.new(api)
250
+ raise ArgumentError, "The argument needs to be a Tesco instance" if not api.is_a? Tesco::Groceries
251
+ begin
252
+ return @@basket[api.customer_id]
253
+ rescue
254
+ (@@basket ||= {})[api.customer_id] = self.allocate
255
+ @@basket[api.customer_id].instance_variable_set(:@api,api)
256
+ @@basket[api.customer_id].instance_variable_set(:@customer_id,api.customer_id)
257
+ @@basket[api.customer_id].sync
258
+ @@basket[api.customer_id]
259
+ end
260
+ end
261
+
262
+ # This class keeps track of all the baskets for each user that's been logged in, to save on API calls.
263
+ # If you need to remove this information from memory this method will destroy the class variable that holds
264
+ # it, without affecting anything on the Tesco servers.
265
+ def self.flush
266
+ @@basket.clean
267
+ end
268
+
269
+ # Makes sure this object reflects the basket on Tesco online.
270
+ def sync
271
+ authtest
272
+ res = @api.api_request(:listbasket)
273
+ @basket_id = res['BasketId'].to_i
274
+ @price = res['BasketGuidePrice'].to_f
275
+ @multi_buy_savings = res['BasketGuideMultiBuySavings'].to_f
276
+ @clubcard_points = res['BasketTotalClubcardPoints'].to_i
277
+ @quantity = res['BasketQuantity'] # TODO: Is this just length?
278
+
279
+ res['BasketLines'].each do |more|
280
+ self[Product.new(@api,more['ProductId'],more)] = BasketItem.new(self,more)
281
+ end
282
+
283
+ return true
284
+ end
285
+
286
+ # Change the note for the shopper on a product in your basket
287
+ def note(product,note) # TODO: should work with multiple products
288
+ authtest
289
+ raise IndexError, "That item is not in the basket" if not self[product]
290
+ @api.api_request(:changebasket,:productid => product.product_id,:changequantity => 0,:noteforshopper => note)
291
+ self[product].instance_variable_set(:@note,note)
292
+ end
293
+
294
+ # Adds the given product(s) to the basket. It increments that item's quantity if it's already present in the basket. Leave a note for the shopper against these items with note.
295
+ def <(products)
296
+ authtest
297
+ [products].flatten.each do |product|
298
+ raise ArgumentError, "That is not a Tesco Product object" if !product.is_a?(Tesco::Groceries::Product)
299
+ (self[product] ||= BasketItem.new(self,{'ProductId' => product.product_id})).add(1)
300
+ end
301
+ end
302
+ alias_method :add, :<
303
+
304
+ # Removes the given product(s) completely from the basket.
305
+ def >(products)
306
+ authtest
307
+ [products].flatten.each do |product|
308
+ raise ArgumentError, "That is not a Tesco Product object" if !product.is_a?(Tesco::Groceries::Product)
309
+ delete(product).remove # Removes the product from the basket, then deletes it from the API
310
+ end
311
+ end
312
+ alias_method :remove, :>
313
+
314
+ # Empties the basket completely — this may take a while for large baskets
315
+ def clean
316
+ authtest
317
+ self.each_pair do |product,basket_item|
318
+ delete(product).remove # Removes the product from the basket, then deletes it from the API
319
+ end
320
+ end
321
+
322
+ # tests to make sure you are authenticated as this basket's owner
323
+ def authtest
324
+ raise NotAuthenticatedError, "Please reauthenticate as this basket's owner before attempting to modify it" if @customer_id != @api.customer_id
325
+ end
326
+ end
327
+
328
+ # Assists in the modification of basketed products
329
+ #
330
+ # TODO: Correct basket auth?
331
+ class BasketItem < DelegateClass(Product)
332
+ attr_accessor :note, :quantity, :error_message, :promo_message
333
+ # I wouldn't mess around with this from your code, its essentially internal
334
+ def self.new(basket,more) # :nodoc:
335
+ @basket = basket
336
+
337
+ # With a little hackiness because Product initializes with self.new, not initialize
338
+ basket_item = super(Product.new(basket.instance_variable_get(:@api),more['ProductId'],more))
339
+ # Set it's instance variables
340
+ basket_item.instance_variable_set(:@quantity,(more['BasketLineQuantity'].to_i rescue 0))
341
+ basket_item.instance_variable_set(:@error_message,(more['BasketLineErrorMessage'] rescue "")) # TODO: Parse this
342
+ basket_item.instance_variable_set(:@promo_message,(more['BasketLinePromoMessage'] rescue ""))
343
+ basket_item.instance_variable_set(:@note,(more['NoteForPersonalShopper'] rescue ""))
344
+
345
+ basket_item
346
+ end
347
+
348
+ # Update the server if the NoteForShopper is changed
349
+
350
+ # TODO: set shopper note
351
+ def note=(note)
352
+ @basket.authtest
353
+ end
354
+
355
+ # Add a certain number of items to the basket
356
+ def add(val = 1)
357
+ @basket.authtest
358
+ return remove if (@quantity + val) <= 0
359
+ __getobj__.instance_variable_get(:@api).api_request(:changebasket,:productid => self.product_id,:changequantity => val,:noteforshopper => @note)
360
+ @quantity
361
+ end
362
+
363
+ # Remove a certain number of items from the basket
364
+ def drop(val = 1)
365
+ @basket.authtest
366
+ add(val * -1)
367
+ end
368
+
369
+ # Alter the quantity to a specific amount
370
+ def quantity=(amount)
371
+ @basket.authtest
372
+ return @quantity if @quantity == amount # No need to do anything if they're the same
373
+ raise ArgumentError, "amount must be >= 0 and <= #{self.max_quantity}" if (not amount.is_a? Integer) or amount < 0 or amount > self.max_quantity
374
+ __getobj__.instance_variable_get(:@api).api_request(:changebasket,:productid => self.product_id,:changequantity => amount - @quantity,:noteforshopper => @note)
375
+ @quantity -= amount
376
+ end
377
+
378
+ # Remove this item from the basket completely
379
+ def remove
380
+ @basket.authtest
381
+ @basket.remove(__getobj__)
382
+ end
383
+
384
+ def inspect
385
+ @basket.authtest
386
+ "#{@quantity} item"<<((@quantity == 1) ? "" : "s")
387
+ end
388
+ end
389
+
390
+ class Department
391
+ attr_reader :id, :name
392
+ # No point in creating these by hand
393
+ def initialize(api,details) # :nodoc:
394
+ @id = details['Id']
395
+ @name = details['Name']
396
+ @aisles = details['Aisles'].collect { |aisle|
397
+ Aisle.new(api,aisle)
398
+ }
399
+ end
400
+
401
+ # Lists all aisles in this department. Each item is an Aisle object
402
+ def aisles
403
+ @aisles
404
+ end
405
+
406
+ def inspect
407
+ "#{@name} Department"
408
+ end
409
+ end
410
+
411
+ class Aisle
412
+ attr_reader :aisle_id, :name
413
+ # No point in creating these by hand.
414
+ def initialize(api,details) # :nodoc:
415
+ @id = details['Id']
416
+ @name = details['Name']
417
+ @shelves = details['Shelves'].collect { |shelf|
418
+ Shelf.new(api,shelf)
419
+ }
420
+ end
421
+
422
+ # Lists all shelves in this aisle. Each item is a Shelf object
423
+ def shelves
424
+ @shelves
425
+ end
426
+
427
+ def inspect
428
+ "#{@name} Aisle"
429
+ end
430
+ end
431
+
432
+ class Shelf
433
+ attr_reader :department,:aisle, :aisle_id, :name
434
+ # No point in creating these by hand.
435
+ def initialize(api,details) # :nodoc:
436
+ @api = api
437
+ @id = details['Id']
438
+ @name = details['Name']
439
+ end
440
+
441
+ def products
442
+ @api.products_by_category(@id)
443
+ end
444
+
445
+ def inspect
446
+ "#{@name} Shelf"
447
+ end
448
+ end
449
+
450
+ # A special class that takes care of product pagination automatically. It'll work like a read-only array for the most part
451
+ # but you can request a specific page with #page and requesting a specific item with #[] will request the required page automatically
452
+ # if it hasn't already been retrieved and stored within the instance's cache.
453
+ class Paginated
454
+ attr_reader :length, :pages
455
+ # Don't use this yourself!
456
+ def initialize(api,res) # :nodoc:
457
+ @cached_pages = {}
458
+ @api = api
459
+ # Do the page we've been given (usually the first)
460
+ @cached_pages[res['PageNumber'] || 1] = parse_items(res)
461
+ @pages = res['TotalPageCount'] || 1
462
+ @perpage = res['PageProductCount']
463
+ @length = res['TotalProductCount'] || @perpage
464
+ @params = res[:requestparameters]
465
+ end
466
+
467
+ # Will return the item at the requested index, even if that page hasn't yet been retreived
468
+ def [](n)
469
+ raise TypeError, "That isn't a valid array reference" if not (n.is_a? Integer and n >= 0)
470
+ raise PaginationError, "That index exceeds the number of items" if n >= @length
471
+ page_num = (n / @perpage).floor + 1
472
+ page(page_num)[n - page_num * @perpage]
473
+ end
474
+
475
+ # Will return all the items on the requested page (indeces will be relative to the page).
476
+ # Specifying page = 0 or page = :all will give an array of all items, retrieving all details first
477
+ # This could take a very, vey long time!
478
+ def page(page)
479
+ page = 0 if page == :all
480
+ raise PaginationError, "That isn't a valid page reference" if not (page.is_a? Integer and page >= 0 and page <= @pages)
481
+ if !@cached_pages.keys.include?(page)
482
+ @cached_pages[res['PageNumber'] || 1] = parse_items(@api.api_request(nil,@params.merge({:page => page})))
483
+ end
484
+ @cached_pages[page]
485
+ end
486
+
487
+ # Akin to Array#each, except you must specify which page, or range of pages, of products you wish to iterate over.
488
+ #
489
+ # Specifying page = 0 or page = :all will iterate over every item on every page
490
+ #
491
+ # The items on each page will be passed to your block as they're retrieved, so you'll get spurts of output.
492
+ #
493
+ # NB. This method won't check your enumberable for each item being a valid page until it's processed all prior pages.
494
+ def each(pages = :all)
495
+ pages = (1..@pages) if (pages == :all or pages == 0)
496
+ pages = [pages] if not pages.is_a? Enumerable
497
+ pages.each do |page|
498
+ raise PaginationError, "#{page.inspect} isn't a valid page reference" if not (page.is_a? Integer and page >= 0 and page <= @pages)
499
+ page(page).each do |item|
500
+ yield item
501
+ end
502
+ end
503
+ end
504
+
505
+ def inspect
506
+ output = ""
507
+ previous = 0
508
+ @cached_pages.each_pair do |page,content|
509
+ output << ", … #{(page - previous) * @perpage} more …" if (previous + 1) != page
510
+ output << ", " << content.inspect[1..-2]
511
+ previous = page
512
+ end
513
+ output << ", … #{@length - previous * @perpage} more" if previous != @pages
514
+ "[#{output[2..-1]}]"
515
+ end
516
+
517
+ private
518
+ # There's no parsing for the default pagination class, make a subclass and write your own parse method
519
+ # Take a look at #Products if you want to see how it's done.
520
+ def parse_items(res); res; end
521
+ end
522
+
523
+ # Deals with the specifics of paginating products.
524
+ class Products < Paginated
525
+ private
526
+ def parse_items(res)
527
+ res['Products'].collect{|json|
528
+ Product.new(@api,json['ProductId'],json)
529
+ }
530
+ end
531
+ end
532
+
533
+ class Offer
534
+ attr_reader :image_url, :description, :validity
535
+ attr_reader :valid_from, :valid_until
536
+
537
+ # No point in making these by hand!
538
+ def initialize(image_url,descr,validity) # :nodoc:
539
+ raise ArgumentError, "Not a valid offer" if descr.nil? or descr.empty?
540
+ @description = descr
541
+ @image_url = image_url
542
+ @validity = validity
543
+ @valid_from, @valid_until = Time.utc($3,$2,$1), Time.utc($6,$5,$4) if validity =~ /^valid from (\d{1,2})\/(\d{1,2})\/(\d{4}) until (\d{1,2})\/(\d{1,2})\/(\d{4})$/
544
+ end
545
+ end
546
+
547
+ private
548
+ class PaginationError < IndexError; end
549
+ class TescoApiError < RuntimeError
550
+ def to_s; "An unspecified error has occured on the server side."; end
551
+ end
552
+ class NoSessionKeyError < TescoApiError
553
+ def to_s; "The session key has been declined, try logging in again."; end
554
+ end
555
+ class NotAuthenticatedError < TescoApiError
556
+ def to_s; "You must be an authenticated non-anonymous user."; end
557
+ end
558
+ end
559
+
560
+ class Barcode
561
+ def initialize(code)
562
+ @barcode = code.to_s
563
+ end
564
+
565
+ def to_s
566
+ @barcode
567
+ end
568
+ end
567
569
  end