|
@@ -10,536 +10,20 @@ 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
|
|
|
-
|
|
|
- class Theme < 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",
|
|
|
- # "snippets", "assets", and "config". 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', :theme_id => 12345)
|
|
|
- #
|
|
|
- # Find by key:
|
|
|
- # asset = ShopifyAPI::Asset.find('assets/image.png', :params => {:theme_id => 12345})
|
|
|
- #
|
|
|
- # 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'
|
|
|
- self.prefix = "/admin/themes/:theme_id/"
|
|
|
-
|
|
|
- def self.prefix(options={})
|
|
|
- options[:theme_id].nil? ? "/admin/" : "/admin/themes/#{options[:theme_id]}/"
|
|
|
- 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
|
|
|
-
|
|
|
- # find an asset by key:
|
|
|
- # ShopifyAPI::Asset.find('layout/theme.liquid', :params => {:theme_id => 99})
|
|
|
- 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]
|
|
|
- path_prefix = params[:theme_id] ? "/admin/themes/#{params[:theme_id]}" : "/admin"
|
|
|
- find(:one, :from => "#{path_prefix}/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
|
|
|
- connection.delete(element_path(prefix_options.merge(:asset => {:key => key})), self.class.headers)
|
|
|
- end
|
|
|
-
|
|
|
- def new?
|
|
|
- false
|
|
|
- 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
|
|
|
+ ignore_files = ['cli.rb']
|
|
|
+ Dir[File.join(File.dirname(__FILE__), 'shopify_api', '**/*.rb')].each do |file|
|
|
|
+ require file unless ignore_files.include?(File.basename(file))
|
|
|
end
|
|
|
|
|
|
# Include Metafields module in all enabled classes
|