shopify_api.rb 16 KB

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