tesco 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (6) hide show
  1. data/README.md +67 -0
  2. data/Rakefile +20 -0
  3. data/VERSION +1 -0
  4. data/lib/tesco.rb +567 -0
  5. data/readme.md +67 -0
  6. metadata +71 -0
@@ -0,0 +1,67 @@
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
+
67
+ 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.
@@ -0,0 +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
+
20
+ task :default => :build
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.4.1
@@ -0,0 +1,567 @@
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
567
+ end
@@ -0,0 +1,67 @@
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
+
67
+ 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.
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tesco
3
+ version: !ruby/object:Gem::Version
4
+ hash: 13
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 4
9
+ - 1
10
+ version: 0.4.1
11
+ platform: ruby
12
+ authors:
13
+ - JP Hastings-Spital
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2010-07-22 00:00:00 +01:00
19
+ default_executable:
20
+ dependencies: []
21
+
22
+ description: Search the Tesco Groceries API, through a very object oriented library
23
+ email: jphastings@gmail.com
24
+ executables: []
25
+
26
+ extensions: []
27
+
28
+ extra_rdoc_files:
29
+ - README.md
30
+ files:
31
+ - Rakefile
32
+ - VERSION
33
+ - lib/tesco.rb
34
+ - readme.md
35
+ - README.md
36
+ has_rdoc: true
37
+ homepage: http://github.com/jphastings/TescoGroceries
38
+ licenses: []
39
+
40
+ post_install_message:
41
+ rdoc_options:
42
+ - --charset=UTF-8
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ none: false
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ hash: 3
51
+ segments:
52
+ - 0
53
+ version: "0"
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ hash: 3
60
+ segments:
61
+ - 0
62
+ version: "0"
63
+ requirements: []
64
+
65
+ rubyforge_project:
66
+ rubygems_version: 1.3.7
67
+ signing_key:
68
+ specification_version: 3
69
+ summary: An extremely straightforward library for the Tesco Grocery API
70
+ test_files: []
71
+