Browse Source

Update to latest ShopifyAPI from Shopify

Cody Fauser 15 years ago
parent
commit
9c70077445
2 changed files with 164 additions and 40 deletions
  1. 3 1
      CHANGELOG
  2. 161 39
      lib/shopify_api.rb

+ 3 - 1
CHANGELOG

@@ -1,2 +1,4 @@
+  - Add latest changes from Shopify including asset support, token validation and a common base class
+
 1.0.0
-  - extracting ShopifyAPI from Shopify into Gem
+  - extracting ShopifyAPI from Shopify into Gem

+ 161 - 39
lib/shopify_api.rb

@@ -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