shopify_api.rb 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. require 'active_resource'
  2. require 'digest/md5'
  3. module ShopifyAPI
  4. module Countable
  5. def count(options = {})
  6. Integer(get(:count, options))
  7. end
  8. end
  9. #
  10. # The Shopify API authenticates each call via HTTP Authentication, using
  11. # * the application's API key as the username, and
  12. # * a hex digest of the application's shared secret and an
  13. # authentication token as the password.
  14. #
  15. # Generation & acquisition of the beforementioned looks like this:
  16. #
  17. # 0. Developer (that's you) registers Application (and provides a
  18. # callback url) and receives an API key and a shared secret
  19. #
  20. # 1. User visits Application and are told they need to authenticate the
  21. # application first for read/write permission to their data (needs to
  22. # happen only once). User is asked for their shop url.
  23. #
  24. # 2. Application redirects to Shopify : GET <user's shop url>/admin/api/auth?api_key=<API key>
  25. # (See Session#create_permission_url)
  26. #
  27. # 3. User logs-in to Shopify, approves application permission request
  28. #
  29. # 4. Shopify redirects to the Application's callback url (provided during
  30. # registration), including the shop's name, and an authentication token in the parameters:
  31. # GET client.com/customers?shop=snake-oil.myshopify.com&t=a94a110d86d2452eb3e2af4cfb8a3828
  32. #
  33. # 5. Authentication password computed using the shared secret and the
  34. # authentication token (see Session#computed_password)
  35. #
  36. # 6. Profit!
  37. # (API calls can now authenticate through HTTP using the API key, and
  38. # computed password)
  39. #
  40. # LoginController and ShopifyLoginProtection use the Session class to set Shopify::Base.site
  41. # so that all API calls are authorized transparently and end up just looking like this:
  42. #
  43. # # get 3 products
  44. # @products = ShopifyAPI::Product.find(:all, :params => {:limit => 3})
  45. #
  46. # # get latest 3 orders
  47. # @orders = ShopifyAPI::Order.find(:all, :params => {:limit => 3, :order => "created_at DESC" })
  48. #
  49. # As an example of what your LoginController should look like, take a look
  50. # at the following:
  51. #
  52. # class LoginController < ApplicationController
  53. # def index
  54. # # Ask user for their #{shop}.myshopify.com address
  55. # end
  56. #
  57. # def authenticate
  58. # redirect_to ShopifyAPI::Session.new(params[:shop]).create_permission_url
  59. # end
  60. #
  61. # # Shopify redirects the logged-in user back to this action along with
  62. # # the authorization token t.
  63. # #
  64. # # This token is later combined with the developer's shared secret to form
  65. # # the password used to call API methods.
  66. # def finalize
  67. # shopify_session = ShopifyAPI::Session.new(params[:shop], params[:t])
  68. # if shopify_session.valid?
  69. # session[:shopify] = shopify_session
  70. # flash[:notice] = "Logged in to shopify store."
  71. #
  72. # return_address = session[:return_to] || '/home'
  73. # session[:return_to] = nil
  74. # redirect_to return_address
  75. # else
  76. # flash[:error] = "Could not log in to Shopify store."
  77. # redirect_to :action => 'index'
  78. # end
  79. # end
  80. #
  81. # def logout
  82. # session[:shopify] = nil
  83. # flash[:notice] = "Successfully logged out."
  84. #
  85. # redirect_to :action => 'index'
  86. # end
  87. # end
  88. #
  89. class Session
  90. cattr_accessor :api_key
  91. cattr_accessor :secret
  92. cattr_accessor :protocol
  93. self.protocol = 'https'
  94. attr_accessor :url, :token, :name
  95. def self.setup(params)
  96. params.each { |k,value| send("#{k}=", value) }
  97. end
  98. def initialize(url, token = nil, params = nil)
  99. self.url, self.token = url, token
  100. if params && params[:signature]
  101. unless self.class.validate_signature(params) && params[:timestamp].to_i > 24.hours.ago.utc.to_i
  102. raise "Invalid Signature: Possible malicious login"
  103. end
  104. end
  105. self.class.prepare_url(self.url)
  106. end
  107. def shop
  108. Shop.current
  109. end
  110. def create_permission_url
  111. "http://#{url}/admin/api/auth?api_key=#{api_key}"
  112. end
  113. # Used by ActiveResource::Base to make all non-authentication API calls
  114. #
  115. # (ShopifyAPI::Base.site set in ShopifyLoginProtection#shopify_session)
  116. def site
  117. "#{protocol}://#{api_key}:#{computed_password}@#{url}/admin"
  118. end
  119. def valid?
  120. [url, token].all?
  121. end
  122. private
  123. # The secret is computed by taking the shared_secret which we got when
  124. # registring this third party application and concating the request_to it,
  125. # and then calculating a MD5 hexdigest.
  126. def computed_password
  127. Digest::MD5.hexdigest(secret + token.to_s)
  128. end
  129. def self.prepare_url(url)
  130. url.gsub!(/https?:\/\//, '') # remove http:// or https://
  131. url.concat(".myshopify.com") unless url.include?('.') # extend url to myshopify.com if no host is given
  132. end
  133. def self.validate_signature(params)
  134. return false unless signature = params[:signature]
  135. sorted_params = params.except(:signature, :action, :controller).collect{|k,v|"#{k}=#{v}"}.sort.join
  136. Digest::MD5.hexdigest(secret + sorted_params) == signature
  137. end
  138. end
  139. class Base < ActiveResource::Base
  140. extend Countable
  141. end
  142. # Shop object. Use Shop.current to receive
  143. # the shop.
  144. class Shop < Base
  145. def self.current
  146. find(:one, :from => "/admin/shop.xml")
  147. end
  148. end
  149. # Custom collection
  150. #
  151. class CustomCollection < Base
  152. def products
  153. Product.find(:all, :params => {:collection_id => self.id})
  154. end
  155. def add_product(product)
  156. Collect.create(:collection_id => self.id, :product_id => product.id)
  157. end
  158. def remove_product(product)
  159. collect = Collect.find(:first, :params => {:collection_id => self.id, :product_id => product.id})
  160. collect.destroy if collect
  161. end
  162. end
  163. class SmartCollection < Base
  164. def products
  165. Product.find(:all, :params => {:collection_id => self.id})
  166. end
  167. end
  168. # For adding/removing products from custom collections
  169. class Collect < Base
  170. end
  171. class ShippingAddress < Base
  172. end
  173. class BillingAddress < Base
  174. end
  175. class LineItem < Base
  176. end
  177. class ShippingLine < Base
  178. end
  179. class NoteAttribute < Base
  180. end
  181. class Order < Base
  182. def close; load_attributes_from_response(post(:close)); end
  183. def open; load_attributes_from_response(post(:open)); end
  184. def transactions
  185. Transaction.find(:all, :params => { :order_id => id })
  186. end
  187. def capture(amount = "")
  188. Transaction.create(:amount => amount, :kind => "capture", :order_id => id)
  189. end
  190. end
  191. class Product < Base
  192. # Share all items of this store with the
  193. # shopify marketplace
  194. def self.share; post :share; end
  195. def self.unshare; delete :share; end
  196. # compute the price range
  197. def price_range
  198. prices = variants.collect(&:price)
  199. format = "%0.2f"
  200. if prices.min != prices.max
  201. "#{format % prices.min} - #{format % prices.max}"
  202. else
  203. format % prices.min
  204. end
  205. end
  206. def collections
  207. CustomCollection.find(:all, :params => {:product_id => self.id})
  208. end
  209. def smart_collections
  210. SmartCollection.find(:all, :params => {:product_id => self.id})
  211. end
  212. def add_to_collection(collection)
  213. collection.add_product(self)
  214. end
  215. def remove_from_collection(collection)
  216. collection.remove_product(self)
  217. end
  218. end
  219. class Variant < Base
  220. self.prefix = "/admin/products/:product_id/"
  221. end
  222. class Image < Base
  223. self.prefix = "/admin/products/:product_id/"
  224. # generate a method for each possible image variant
  225. [:pico, :icon, :thumb, :small, :medium, :large, :original].each do |m|
  226. reg_exp_match = "/\\1_#{m}.\\2"
  227. define_method(m) { src.gsub(/\/(.*)\.(\w{2,4})/, reg_exp_match) }
  228. end
  229. def attach_image(data, filename = nil)
  230. attributes['attachment'] = Base64.encode64(data)
  231. attributes['filename'] = filename unless filename.nil?
  232. end
  233. end
  234. class Transaction < Base
  235. self.prefix = "/admin/orders/:order_id/"
  236. end
  237. class Fulfillment < Base
  238. self.prefix = "/admin/orders/:order_id/"
  239. end
  240. class Country < Base
  241. end
  242. class Page < Base
  243. end
  244. class Blog < Base
  245. def articles
  246. Article.find(:all, :params => { :blog_id => id })
  247. end
  248. end
  249. class Article < Base
  250. self.prefix = "/admin/blogs/:blog_id/"
  251. end
  252. class Comment < Base
  253. def remove; load_attributes_from_response(post(:remove)); end
  254. def ham; load_attributes_from_response(post(:ham)); end
  255. def spam; load_attributes_from_response(post(:spam)); end
  256. def approve; load_attributes_from_response(post(:approve)); end
  257. end
  258. class Province < Base
  259. self.prefix = "/admin/countries/:country_id/"
  260. end
  261. class Redirect < Base
  262. end
  263. # Assets represent the files that comprise your theme.
  264. # There are different buckets which hold different kinds
  265. # of assets, each corresponding to one of the folders
  266. # within a theme's zip file: layout, templates, and
  267. # assets. The full key of an asset always starts with the
  268. # bucket name, and the path separator is a forward slash,
  269. # like layout/theme.liquid or assets/bg-body.gif.
  270. #
  271. # Initialize with a key:
  272. # asset = ShopifyAPI::Asset.new(:key => 'assets/special.css')
  273. #
  274. # Find by key:
  275. # asset = ShopifyAPI::Asset.find('assets/image.png')
  276. #
  277. # Get the text or binary value:
  278. # asset.value # decodes from attachment attribute if necessary
  279. #
  280. # You can provide new data for assets in a few different ways:
  281. #
  282. # * assign text data for the value directly:
  283. # asset.value = "div.special {color:red;}"
  284. #
  285. # * provide binary data for the value:
  286. # asset.attach(File.read('image.png'))
  287. #
  288. # * set a URL from which Shopify will fetch the value:
  289. # asset.src = "http://mysite.com/image.png"
  290. #
  291. # * set a source key of another of your assets from which
  292. # the value will be copied:
  293. # asset.source_key = "assets/another_image.png"
  294. class Asset < Base
  295. self.primary_key = 'key'
  296. # find an asset by key:
  297. # ShopifyAPI::Asset.find('layout/theme.liquid')
  298. def self.find(*args)
  299. if args[0].is_a?(Symbol)
  300. super
  301. else
  302. find(:one, :from => "/admin/assets.xml", :params => {:asset => {:key => args[0]}})
  303. end
  304. end
  305. # For text assets, Shopify returns the data in the 'value' attribute.
  306. # For binary assets, the data is base-64-encoded and returned in the
  307. # 'attachment' attribute. This accessor returns the data in both cases.
  308. def value
  309. attributes['value'] ||
  310. (attributes['attachment'] ? Base64.decode64(attributes['attachment']) : nil)
  311. end
  312. def attach(data)
  313. self.attachment = Base64.encode64(data)
  314. end
  315. def destroy #:nodoc:
  316. connection.delete(element_path(:asset => {:key => key}), self.class.headers)
  317. end
  318. def new? #:nodoc:
  319. false
  320. end
  321. def self.element_path(id, prefix_options = {}, query_options = nil) #:nodoc:
  322. prefix_options, query_options = split_options(prefix_options) if query_options.nil?
  323. "#{prefix(prefix_options)}#{collection_name}.#{format.extension}#{query_string(query_options)}"
  324. end
  325. def method_missing(method_symbol, *arguments) #:nodoc:
  326. if %w{value= attachment= src= source_key=}.include?(method_symbol)
  327. wipe_value_attributes
  328. end
  329. super
  330. end
  331. private
  332. def wipe_value_attributes
  333. %w{value attachment src source_key}.each do |attr|
  334. attributes.delete(attr)
  335. end
  336. end
  337. end
  338. class RecurringApplicationCharge < Base
  339. def self.current
  340. find(:all).find{|charge| charge.status == 'active'}
  341. end
  342. def cancel
  343. load_attributes_from_response(self.destroy)
  344. end
  345. end
  346. class ApplicationCharge < Base
  347. end
  348. end