shopify_api.rb 14 KB

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