|
@@ -2,13 +2,20 @@ require 'active_resource'
|
|
|
require 'digest/md5'
|
|
|
|
|
|
module ShopifyAPI
|
|
|
+
|
|
|
+ module Countable
|
|
|
+ def count(options = {})
|
|
|
+ Integer(get(:count, options))
|
|
|
+ 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 (assuming the ):
|
|
|
+ # 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
|
|
@@ -17,7 +24,7 @@ module ShopifyAPI
|
|
|
# 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?app=<API key>
|
|
|
+ # 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
|
|
@@ -33,7 +40,7 @@ module ShopifyAPI
|
|
|
# (API calls can now authenticate through HTTP using the API key, and
|
|
|
# computed password)
|
|
|
#
|
|
|
- # LoginController and ShopifyLoginProtection use the Session class to set ActiveResource::Base.site
|
|
|
+ # 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
|
|
@@ -94,12 +101,16 @@ module ShopifyAPI
|
|
|
params.each { |k,value| send("#{k}=", value) }
|
|
|
end
|
|
|
|
|
|
- def initialize(url, token = nil)
|
|
|
- raise ArgumentError.new("You must provide at least a URL to a Shopify store!") if url.blank?
|
|
|
- url.gsub!(/https?:\/\//, '') # remove http:// or https://
|
|
|
- url = "#{url}.myshopify.com" unless url.include?('.') # extend url to myshopify.com if no host is given
|
|
|
-
|
|
|
+ def initialize(url, token = nil, params = nil)
|
|
|
self.url, self.token = url, token
|
|
|
+
|
|
|
+ if params && params[:signature]
|
|
|
+ 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
|
|
@@ -112,7 +123,7 @@ module ShopifyAPI
|
|
|
|
|
|
# Used by ActiveResource::Base to make all non-authentication API calls
|
|
|
#
|
|
|
- # (ActiveResource::Base.site set in ShopifyLoginProtection#shopify_session)
|
|
|
+ # (ShopifyAPI::Base.site set in ShopifyLoginProtection#shopify_session)
|
|
|
def site
|
|
|
"#{protocol}://#{api_key}:#{computed_password}@#{url}/admin"
|
|
|
end
|
|
@@ -129,21 +140,35 @@ module ShopifyAPI
|
|
|
def computed_password
|
|
|
Digest::MD5.hexdigest(secret + token.to_s)
|
|
|
end
|
|
|
+
|
|
|
+ def self.prepare_url(url)
|
|
|
+ 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. Since you can only ever reference your own
|
|
|
- # shop this model does not have a .find method.
|
|
|
- #
|
|
|
- class Shop
|
|
|
+ # the shop.
|
|
|
+ class Shop < Base
|
|
|
def self.current
|
|
|
- ActiveResource::Base.find(:one, :from => "/admin/shop.xml")
|
|
|
+ find(:one, :from => "/admin/shop.xml")
|
|
|
end
|
|
|
end
|
|
|
|
|
|
# Custom collection
|
|
|
#
|
|
|
- class CustomCollection < ActiveResource::Base
|
|
|
+ class CustomCollection < Base
|
|
|
def products
|
|
|
Product.find(:all, :params => {:collection_id => self.id})
|
|
|
end
|
|
@@ -158,34 +183,32 @@ module ShopifyAPI
|
|
|
end
|
|
|
end
|
|
|
|
|
|
- class SmartCollection < ActiveResource::Base
|
|
|
+ class SmartCollection < Base
|
|
|
def products
|
|
|
Product.find(:all, :params => {:collection_id => self.id})
|
|
|
end
|
|
|
end
|
|
|
|
|
|
# For adding/removing products from custom collections
|
|
|
- class Collect < ActiveResource::Base
|
|
|
+ class Collect < Base
|
|
|
end
|
|
|
|
|
|
- class ShippingAddress < ActiveResource::Base
|
|
|
+ class ShippingAddress < Base
|
|
|
end
|
|
|
|
|
|
- class BillingAddress < ActiveResource::Base
|
|
|
+ class BillingAddress < Base
|
|
|
end
|
|
|
|
|
|
- class LineItem < ActiveResource::Base
|
|
|
+ class LineItem < Base
|
|
|
end
|
|
|
|
|
|
- class ShippingLine < ActiveResource::Base
|
|
|
+ class ShippingLine < Base
|
|
|
end
|
|
|
|
|
|
- class NoteAttribute < ActiveResource::Base
|
|
|
+ class NoteAttribute < Base
|
|
|
end
|
|
|
|
|
|
- class Order < ActiveResource::Base
|
|
|
-
|
|
|
-
|
|
|
+ class Order < Base
|
|
|
def close; load_attributes_from_response(post(:close)); end
|
|
|
|
|
|
def open; load_attributes_from_response(post(:open)); end
|
|
@@ -199,7 +222,7 @@ module ShopifyAPI
|
|
|
end
|
|
|
end
|
|
|
|
|
|
- class Product < ActiveResource::Base
|
|
|
+ class Product < Base
|
|
|
|
|
|
# Share all items of this store with the
|
|
|
# shopify marketplace
|
|
@@ -234,11 +257,11 @@ module ShopifyAPI
|
|
|
end
|
|
|
end
|
|
|
|
|
|
- class Variant < ActiveResource::Base
|
|
|
+ class Variant < Base
|
|
|
self.prefix = "/admin/products/:product_id/"
|
|
|
end
|
|
|
|
|
|
- class Image < ActiveResource::Base
|
|
|
+ class Image < Base
|
|
|
self.prefix = "/admin/products/:product_id/"
|
|
|
|
|
|
# generate a method for each possible image variant
|
|
@@ -248,46 +271,145 @@ module ShopifyAPI
|
|
|
end
|
|
|
|
|
|
def attach_image(data, filename = nil)
|
|
|
- attributes[:attachment] = Base64.encode64(data)
|
|
|
- attributes[:filename] = filename unless filename.nil?
|
|
|
+ attributes['attachment'] = Base64.encode64(data)
|
|
|
+ attributes['filename'] = filename unless filename.nil?
|
|
|
end
|
|
|
end
|
|
|
|
|
|
- class Transaction < ActiveResource::Base
|
|
|
+ class Transaction < Base
|
|
|
self.prefix = "/admin/orders/:order_id/"
|
|
|
end
|
|
|
|
|
|
- class Fulfillment < ActiveResource::Base
|
|
|
+ class Fulfillment < Base
|
|
|
self.prefix = "/admin/orders/:order_id/"
|
|
|
end
|
|
|
|
|
|
- class Country < ActiveResource::Base
|
|
|
+ class Country < Base
|
|
|
end
|
|
|
|
|
|
- class Page < ActiveResource::Base
|
|
|
+ class Page < Base
|
|
|
end
|
|
|
|
|
|
- class Blog < ActiveResource::Base
|
|
|
+ class Blog < Base
|
|
|
def articles
|
|
|
Article.find(:all, :params => { :blog_id => id })
|
|
|
end
|
|
|
end
|
|
|
|
|
|
- class Article < ActiveResource::Base
|
|
|
+ class Article < Base
|
|
|
self.prefix = "/admin/blogs/:blog_id/"
|
|
|
end
|
|
|
|
|
|
- class Comment < ActiveResource::Base
|
|
|
+ class Comment < Base
|
|
|
def remove; load_attributes_from_response(post(:remove)); end
|
|
|
def ham; load_attributes_from_response(post(:ham)); end
|
|
|
def spam; load_attributes_from_response(post(:spam)); end
|
|
|
def approve; load_attributes_from_response(post(:approve)); end
|
|
|
end
|
|
|
|
|
|
- class Province < ActiveResource::Base
|
|
|
+ class Province < Base
|
|
|
self.prefix = "/admin/countries/:country_id/"
|
|
|
end
|
|
|
|
|
|
- class Redirect < ActiveResource::Base
|
|
|
+ class Redirect < 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
|
|
|
+ find(:one, :from => "/admin/assets.xml", :params => {:asset => {:key => args[0]}})
|
|
|
+ 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
|
|
|
+ def self.current
|
|
|
+ find(:all).find{|charge| charge.status == 'active'}
|
|
|
+ end
|
|
|
+
|
|
|
+ def cancel
|
|
|
+ load_attributes_from_response(self.destroy)
|
|
|
+ end
|
|
|
+ end
|
|
|
+
|
|
|
+ class ApplicationCharge < Base
|
|
|
end
|
|
|
end
|