forked from Shopify/shopify-api-ruby
/
shopify_api.rb
540 lines (447 loc) · 15.5 KB
/
shopify_api.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
require 'active_resource'
require 'active_support/core_ext/class/attribute_accessors'
require 'digest/md5'
require 'base64'
module ShopifyAPI
METAFIELD_ENABLED_CLASSES = %w( Order Product CustomCollection SmartCollection Page Blog Article Variant)
EVENT_ENABLED_CLASSES = %w( Order Product CustomCollection SmartCollection Page Blog Article )
module Countable
def count(options = {})
Integer(get(:count, options))
end
end
module Metafields
def metafields
Metafield.find(:all, :params => {:resource => self.class.collection_name, :resource_id => id})
end
def add_metafield(metafield)
raise ArgumentError, "You can only add metafields to resource that has been saved" if new?
metafield.prefix_options = {
:resource => self.class.collection_name,
:resource_id => id
}
metafield.save
metafield
end
end
module Events
def events
Event.find(:all, :params => {:resource => self.class.collection_name, :resource_id => id})
end
end
#
# The Shopify API authenticates each call via HTTP Authentication, using
# * the application's API key as the username, and
# * a hex digest of the application's shared secret and an
# authentication token as the password.
#
# Generation & acquisition of the beforementioned looks like this:
#
# 0. Developer (that's you) registers Application (and provides a
# callback url) and receives an API key and a shared secret
#
# 1. User visits Application and are told they need to authenticate the
# application first for read/write permission to their data (needs to
# happen only once). User is asked for their shop url.
#
# 2. Application redirects to Shopify : GET <user's shop url>/admin/api/auth?api_key=<API key>
# (See Session#create_permission_url)
#
# 3. User logs-in to Shopify, approves application permission request
#
# 4. Shopify redirects to the Application's callback url (provided during
# registration), including the shop's name, and an authentication token in the parameters:
# GET client.com/customers?shop=snake-oil.myshopify.com&t=a94a110d86d2452eb3e2af4cfb8a3828
#
# 5. Authentication password computed using the shared secret and the
# authentication token (see Session#computed_password)
#
# 6. Profit!
# (API calls can now authenticate through HTTP using the API key, and
# computed password)
#
# LoginController and ShopifyLoginProtection use the Session class to set Shopify::Base.site
# so that all API calls are authorized transparently and end up just looking like this:
#
# # get 3 products
# @products = ShopifyAPI::Product.find(:all, :params => {:limit => 3})
#
# # get latest 3 orders
# @orders = ShopifyAPI::Order.find(:all, :params => {:limit => 3, :order => "created_at DESC" })
#
# As an example of what your LoginController should look like, take a look
# at the following:
#
# class LoginController < ApplicationController
# def index
# # Ask user for their #{shop}.myshopify.com address
# end
#
# def authenticate
# redirect_to ShopifyAPI::Session.new(params[:shop]).create_permission_url
# end
#
# # Shopify redirects the logged-in user back to this action along with
# # the authorization token t.
# #
# # This token is later combined with the developer's shared secret to form
# # the password used to call API methods.
# def finalize
# shopify_session = ShopifyAPI::Session.new(params[:shop], params[:t])
# if shopify_session.valid?
# session[:shopify] = shopify_session
# flash[:notice] = "Logged in to shopify store."
#
# return_address = session[:return_to] || '/home'
# session[:return_to] = nil
# redirect_to return_address
# else
# flash[:error] = "Could not log in to Shopify store."
# redirect_to :action => 'index'
# end
# end
#
# def logout
# session[:shopify] = nil
# flash[:notice] = "Successfully logged out."
#
# redirect_to :action => 'index'
# end
# end
#
class Session
cattr_accessor :api_key
cattr_accessor :secret
cattr_accessor :protocol
self.protocol = 'https'
attr_accessor :url, :token, :name
def self.setup(params)
params.each { |k,value| send("#{k}=", value) }
end
def initialize(url, token = nil, params = nil)
self.url, self.token = url, token
if params
unless self.class.validate_signature(params) && params[:timestamp].to_i > 24.hours.ago.utc.to_i
raise "Invalid Signature: Possible malicious login"
end
end
self.class.prepare_url(self.url)
end
def shop
Shop.current
end
def create_permission_url
return nil if url.blank? || api_key.blank?
"http://#{url}/admin/api/auth?api_key=#{api_key}"
end
# Used by ActiveResource::Base to make all non-authentication API calls
#
# (ShopifyAPI::Base.site set in ShopifyLoginProtection#shopify_session)
def site
"#{protocol}://#{api_key}:#{computed_password}@#{url}/admin"
end
def valid?
url.present? && token.present?
end
private
# The secret is computed by taking the shared_secret which we got when
# registring this third party application and concating the request_to it,
# and then calculating a MD5 hexdigest.
def computed_password
Digest::MD5.hexdigest(secret + token.to_s)
end
def self.prepare_url(url)
return nil if url.blank?
url.gsub!(/https?:\/\//, '') # remove http:// or https://
url.concat(".myshopify.com") unless url.include?('.') # extend url to myshopify.com if no host is given
end
def self.validate_signature(params)
return false unless signature = params[:signature]
sorted_params = params.except(:signature, :action, :controller).collect{|k,v|"#{k}=#{v}"}.sort.join
Digest::MD5.hexdigest(secret + sorted_params) == signature
end
end
class Base < ActiveResource::Base
extend Countable
end
# Shop object. Use Shop.current to receive
# the shop.
class Shop < Base
def self.current
find(:one, :from => "/admin/shop.#{format.extension}")
end
def metafields
Metafield.find(:all)
end
def add_metafield(metafield)
raise ArgumentError, "You can only add metafields to resource that has been saved" if new?
metafield.save
metafield
end
def events
Event.find(:all)
end
end
# Custom collection
#
class CustomCollection < Base
def products
Product.find(:all, :params => {:collection_id => self.id})
end
def add_product(product)
Collect.create(:collection_id => self.id, :product_id => product.id)
end
def remove_product(product)
collect = Collect.find(:first, :params => {:collection_id => self.id, :product_id => product.id})
collect.destroy if collect
end
end
class SmartCollection < Base
def products
Product.find(:all, :params => {:collection_id => self.id})
end
end
# For adding/removing products from custom collections
class Collect < Base
end
class ShippingAddress < Base
end
class BillingAddress < Base
end
class LineItem < Base
end
class ShippingLine < Base
end
class NoteAttribute < Base
end
class Order < Base
def close; load_attributes_from_response(post(:close, {}, only_id)); end
def open; load_attributes_from_response(post(:open, {}, only_id)); end
def cancel(options = {})
load_attributes_from_response(post(:cancel, options, only_id))
end
def transactions
Transaction.find(:all, :params => { :order_id => id })
end
def capture(amount = "")
Transaction.create(:amount => amount, :kind => "capture", :order_id => id)
end
def only_id
encode(:only => :id, :include => [], :methods => [], :fields => [])
end
end
class Product < Base
# Share all items of this store with the
# shopify marketplace
def self.share; post :share; end
def self.unshare; delete :share; end
# compute the price range
def price_range
prices = variants.collect(&:price)
format = "%0.2f"
if prices.min != prices.max
"#{format % prices.min} - #{format % prices.max}"
else
format % prices.min
end
end
def collections
CustomCollection.find(:all, :params => {:product_id => self.id})
end
def smart_collections
SmartCollection.find(:all, :params => {:product_id => self.id})
end
def add_to_collection(collection)
collection.add_product(self)
end
def remove_from_collection(collection)
collection.remove_product(self)
end
end
class Variant < Base
self.prefix = "/admin/products/:product_id/"
def self.prefix(options={})
options[:product_id].nil? ? "/admin/" : "/admin/products/#{options[:product_id]}/"
end
end
class Image < Base
self.prefix = "/admin/products/:product_id/"
# generate a method for each possible image variant
[:pico, :icon, :thumb, :small, :compact, :medium, :large, :grande, :original].each do |m|
reg_exp_match = "/\\1_#{m}.\\2"
define_method(m) { src.gsub(/\/(.*)\.(\w{2,4})/, reg_exp_match) }
end
def attach_image(data, filename = nil)
attributes['attachment'] = Base64.encode64(data)
attributes['filename'] = filename unless filename.nil?
end
end
class Transaction < Base
self.prefix = "/admin/orders/:order_id/"
end
class Fulfillment < Base
self.prefix = "/admin/orders/:order_id/"
end
class Country < Base
end
class Page < Base
end
class Blog < Base
def articles
Article.find(:all, :params => { :blog_id => id })
end
end
class Article < Base
self.prefix = "/admin/blogs/:blog_id/"
def comments
Comment.find(:all, :params => { :article_id => id })
end
end
class Metafield < Base
self.prefix = "/admin/:resource/:resource_id/"
# Hack to allow both Shop and other Metafields in through the same AR class
def self.prefix(options={})
options[:resource].nil? ? "/admin/" : "/admin/#{options[:resource]}/#{options[:resource_id]}/"
end
def value
return if attributes["value"].nil?
attributes["value_type"] == "integer" ? attributes["value"].to_i : attributes["value"]
end
end
class Comment < Base
def remove; load_attributes_from_response(post(:remove, {}, only_id)); end
def ham; load_attributes_from_response(post(:ham, {}, only_id)); end
def spam; load_attributes_from_response(post(:spam, {}, only_id)); end
def approve; load_attributes_from_response(post(:approve, {}, only_id)); end
def restore; load_attributes_from_response(post(:restore, {}, only_id)); end
def not_spam; load_attributes_from_response(post(:not_spam, {}, only_id)); end
def only_id
encode(:only => :id)
end
end
class Province < Base
self.prefix = "/admin/countries/:country_id/"
end
class Redirect < Base
end
class Webhook < Base
end
class Event < Base
self.prefix = "/admin/:resource/:resource_id/"
# Hack to allow both Shop and other Events in through the same AR class
def self.prefix(options={})
options[:resource].nil? ? "/admin/" : "/admin/#{options[:resource]}/#{options[:resource_id]}/"
end
end
class Customer < Base
end
class CustomerGroup < Base
end
# Assets represent the files that comprise your theme.
# There are different buckets which hold different kinds
# of assets, each corresponding to one of the folders
# within a theme's zip file: layout, templates, and
# assets. The full key of an asset always starts with the
# bucket name, and the path separator is a forward slash,
# like layout/theme.liquid or assets/bg-body.gif.
#
# Initialize with a key:
# asset = ShopifyAPI::Asset.new(:key => 'assets/special.css')
#
# Find by key:
# asset = ShopifyAPI::Asset.find('assets/image.png')
#
# Get the text or binary value:
# asset.value # decodes from attachment attribute if necessary
#
# You can provide new data for assets in a few different ways:
#
# * assign text data for the value directly:
# asset.value = "div.special {color:red;}"
#
# * provide binary data for the value:
# asset.attach(File.read('image.png'))
#
# * set a URL from which Shopify will fetch the value:
# asset.src = "http://mysite.com/image.png"
#
# * set a source key of another of your assets from which
# the value will be copied:
# asset.source_key = "assets/another_image.png"
class Asset < Base
self.primary_key = 'key'
# find an asset by key:
# ShopifyAPI::Asset.find('layout/theme.liquid')
def self.find(*args)
if args[0].is_a?(Symbol)
super
else
params = {:asset => {:key => args[0]}}
params = params.merge(args[1][:params]) if args[1] && args[1][:params]
find(:one, :from => "/admin/assets.#{format.extension}", :params => params)
end
end
# For text assets, Shopify returns the data in the 'value' attribute.
# For binary assets, the data is base-64-encoded and returned in the
# 'attachment' attribute. This accessor returns the data in both cases.
def value
attributes['value'] ||
(attributes['attachment'] ? Base64.decode64(attributes['attachment']) : nil)
end
def attach(data)
self.attachment = Base64.encode64(data)
end
def destroy #:nodoc:
connection.delete(element_path(:asset => {:key => key}), self.class.headers)
end
def new? #:nodoc:
false
end
def self.element_path(id, prefix_options = {}, query_options = nil) #:nodoc:
prefix_options, query_options = split_options(prefix_options) if query_options.nil?
"#{prefix(prefix_options)}#{collection_name}.#{format.extension}#{query_string(query_options)}"
end
def method_missing(method_symbol, *arguments) #:nodoc:
if %w{value= attachment= src= source_key=}.include?(method_symbol)
wipe_value_attributes
end
super
end
private
def wipe_value_attributes
%w{value attachment src source_key}.each do |attr|
attributes.delete(attr)
end
end
end
class RecurringApplicationCharge < Base
undef_method :test
def self.current
find(:all).find{|charge| charge.status == 'active'}
end
def cancel
load_attributes_from_response(self.destroy)
end
def activate
load_attributes_from_response(post(:activate))
end
end
class ApplicationCharge < Base
undef_method :test
def activate
load_attributes_from_response(post(:activate))
end
end
class ProductSearchEngine < Base
end
class ScriptTag < Base
end
# Include Metafields module in all enabled classes
METAFIELD_ENABLED_CLASSES.each do |klass|
"ShopifyAPI::#{klass}".constantize.send(:include, Metafields)
end
# Include Events module in all enabled classes
EVENT_ENABLED_CLASSES.each do |klass|
"ShopifyAPI::#{klass}".constantize.send(:include, Events)
end
end