mad_cart 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ vendor
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
19
+ .ruby-version
20
+ .ruby-gemset
21
+ .DS_Store
22
+ *.swp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in mad_cart.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 MadMimi
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # MadCart
2
+
3
+ Provides a unified API to various CRMs and online stores.
4
+ Simple configuration allows you to specify the properties you're interested in and what they're called.
5
+ A flexible DSL allows easy CRM and store integration.
6
+
7
+ Currently supports the following stores:
8
+ **-Etsy**
9
+ **-Bigcommerce**
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ gem 'mad_cart'
16
+
17
+ And then execute:
18
+
19
+ $ bundle
20
+
21
+ Or install it yourself as:
22
+
23
+ $ gem install mad_cart
24
+
25
+ ## Usage
26
+
27
+ #### Credentials
28
+
29
+ Store/CRM credentials can be configured:
30
+
31
+ ```ruby
32
+ MadCart.configure do |config|
33
+ config.add_store :etsy, api_key: 'my-api-key', store_url: 'http://path.to/store'
34
+ end
35
+
36
+ store = MadCart::Store::Etsy.new
37
+ ```
38
+ ...or passed to the store initializer:
39
+
40
+ ```ruby
41
+ store = MadCart::Store::Etsy.new(api_key: 'my-api-key', store_url: 'http://path.to/store')
42
+ ```
43
+
44
+ ### Products
45
+
46
+ ```ruby
47
+ store = MadCart::Store::Etsy.new
48
+
49
+ store.products
50
+ #=> an array of MadCart::Model::Product objects
51
+ ```
52
+
53
+ ### Customers
54
+ ```ruby
55
+ store = MadCart::Store::BigCommerce.new
56
+
57
+ store.customers
58
+ # => returns an array of MadCart::Model::Customer objects
59
+ ```
60
+
61
+ ### Attributes
62
+
63
+ Each model object has a property called attributes, which returns a hash. By default the following properties are returned:
64
+
65
+ **Customers:** *first_name*, *last_name*, and *email*
66
+ **Products:** *name*, *description*, and *image_url*
67
+
68
+ #### Additional Attributes
69
+
70
+ Any additional attributes returned by the CRM or store API will be stored in the *additional_attributes* property of the object.
71
+ MadCart allows you to include any of these additional attributes in the #attributes property of the model objects:
72
+
73
+ ```ruby
74
+ store = MadCart::Store::Etsy.new
75
+
76
+ store.products.first.attributes
77
+ #=> {name: 'product name', description: 'product description', image_url 'http://path.to/image'}
78
+
79
+ MadCart.configure do |config|
80
+ config.include_attributes products: [:external_id, :url]
81
+ end
82
+
83
+ store.products.first.attributes
84
+ #=> {name: 'product name', description: 'product description', image_url 'http://path.to/image', external_id: 42, url: 'http://path.to/store/products/42'}
85
+ ```
86
+
87
+ #### Attribute Names
88
+
89
+ MadCart allows you to change the names of these attributes to match your existing field names:
90
+
91
+ ```ruby
92
+ MadCart.configure do |config|
93
+ config.attribute_map :products, {"name" => "title"}
94
+ end
95
+
96
+ store = MadCart::Store::Etsy.new
97
+
98
+ store.products.first.attributes
99
+ #=> {title: 'product name', description: 'product description', image_url 'http://path.to/image'}
100
+ ```
101
+
102
+ This, in combination with declaring additional attributes, allows for very thin integration points without sacrificing customizability:
103
+
104
+ ```ruby
105
+ store = MadCart::Store::Etsy.new
106
+ store.products.each{|p| MyProduct.create!(p.attributes) }
107
+ ```
108
+
109
+ ## Contributing
110
+
111
+ See the [Contributor's Guide](https://github.com/madmimi/mad_cart/wiki/Contributor's-Guide) for info on the store integration API.
112
+
113
+ 1. Fork it
114
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
115
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
116
+ 4. Push to the branch (`git push origin my-new-feature`)
117
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require "bundler/gem_tasks"
2
+ require 'yaml'
3
+ load 'vcr/tasks/vcr.rake'
4
+
5
+ require 'rspec/core/rake_task'
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task :default => :spec
@@ -0,0 +1,35 @@
1
+ module MadCart
2
+ module AttributeMapper
3
+
4
+ # def attributes
5
+ # Hash[initial_attributes.map {|k, v| [(mapping_hash[k] || mapping_hash[k.to_sym] || k).to_s, v] }]
6
+ # end
7
+
8
+ def self.included(base)
9
+ base.extend ClassMethods
10
+ end
11
+
12
+ def attributes
13
+ Hash[self.class.exposed_attributes.map{|a| [a.to_s, self.send(a)]}]
14
+ end
15
+
16
+ module ClassMethods
17
+ def map_attribute_name(name)
18
+ mapping_hash[name.to_sym] || mapping_hash[name.to_s] || name
19
+ end
20
+
21
+ def mapping_hash
22
+ MadCart.config.attribute_maps[self.to_s.demodulize.underscore.pluralize] || {}
23
+ end
24
+
25
+ def mapped_attributes
26
+ mapping_hash.values
27
+ end
28
+
29
+ def unmapped_attributes
30
+ mapping_hash.keys
31
+ end
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,59 @@
1
+ require 'ostruct'
2
+
3
+ module MadCart
4
+ class << self
5
+ def configure
6
+ raise(ArgumentError, "MadCart.configure requires a block argument.") unless block_given?
7
+ yield(MadCart::Configuration.instance)
8
+ end
9
+
10
+ def config
11
+ raise(ArgumentError, "MadCart.config does not support blocks. Use MadCart.configure to set config values.") if block_given?
12
+ return MadCart::Configuration.instance.data
13
+ end
14
+ end
15
+
16
+ class Configuration
17
+ include Singleton
18
+
19
+ def add_store(store_name, args={})
20
+ setup_data
21
+
22
+ @data[:stores] << store_name
23
+ @data[store_name.to_s] = args
24
+ end
25
+
26
+ def attribute_map(data_model, attributes)
27
+ setup_data
28
+
29
+ @data[:attribute_maps][data_model.to_s] = attributes
30
+ end
31
+
32
+ def include_attributes(args={})
33
+ setup_data
34
+
35
+ @data[:included_attributes].merge!(args)
36
+ end
37
+
38
+ def data
39
+ setup_data
40
+ Data.new(@data)
41
+ end
42
+
43
+ private
44
+ def setup_data
45
+ @data ||= {:stores => []}
46
+ @data[:attribute_maps] ||= {}
47
+ @data[:included_attributes] ||= {}
48
+ end
49
+
50
+ class Data < OpenStruct
51
+ class ConfigurationError < NoMethodError; end
52
+
53
+ def method_missing(meth, *args)
54
+ return nil
55
+ end
56
+
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,27 @@
1
+ module MadCart
2
+ module InheritableAttributes
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def inheritable_attributes(*args)
9
+ @madcart_inheritable_attributes ||= [:inheritable_attributes]
10
+ @madcart_inheritable_attributes += args
11
+ args.each do |arg|
12
+ class_eval %(
13
+ class << self; attr_accessor :#{arg} end
14
+ )
15
+ end
16
+ @madcart_inheritable_attributes
17
+ end
18
+
19
+ def inherited(subclass)
20
+ @madcart_inheritable_attributes.each do |inheritable_attribute|
21
+ instance_var = "@#{inheritable_attribute}"
22
+ subclass.instance_variable_set(instance_var, instance_variable_get(instance_var))
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,68 @@
1
+ module MadCart
2
+ module Model
3
+ module Base
4
+ def initialize(args={})
5
+ self.additional_attributes = {}
6
+ check_required_attributes(args)
7
+ args.each { |k,v| set_attribute(k, v) }
8
+ end
9
+
10
+ def self.included(base)
11
+ base.extend ClassMethods
12
+ base.class_eval do
13
+ include AttributeMapper
14
+ include InheritableAttributes
15
+ attr_accessor :additional_attributes
16
+ inheritable_attributes :required_attrs
17
+ attr_accessor *exposed_attributes
18
+ end
19
+ end
20
+
21
+ def define_attribute_accessors
22
+ klass.class_eval do
23
+ attr_accessor(*exposed_attributes)
24
+ end
25
+ end
26
+
27
+ def check_required_attributes(args)
28
+ keys = args.keys.map{|a| a.to_s }
29
+ klass.required_attrs.each do |attr|
30
+ raise(ArgumentError, "missing argument: #{attr}") if !keys.include?(attr)
31
+ end
32
+ end
33
+ private :check_required_attributes
34
+
35
+ def set_attribute(key, value)
36
+ attr_name = klass.map_attribute_name(key)
37
+
38
+ if klass.exposed_attributes.include? attr_name.to_s
39
+ define_attribute_accessors unless self.respond_to?(attr_name)
40
+ self.send("#{attr_name}=", value) unless value.nil?
41
+ else
42
+ self.additional_attributes[attr_name.to_s] = value unless value.nil?
43
+ end
44
+ end
45
+ private :set_attribute
46
+
47
+ def klass
48
+ self.class
49
+ end
50
+ private :klass
51
+
52
+ module ClassMethods
53
+ def required_attributes(*args)
54
+ @required_attrs = args.map{|a| a.to_s }
55
+ attr_accessor *args
56
+ end
57
+
58
+ def exposed_attributes
59
+ ((self.required_attrs || []) + included_attributes + mapped_attributes).uniq.map{|a| a.to_s } - unmapped_attributes.map{|a| a.to_s }
60
+ end
61
+
62
+ def included_attributes
63
+ MadCart.config.included_attributes[self.to_s.demodulize.underscore.pluralize.to_sym] || []
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,9 @@
1
+ module MadCart
2
+ module Model
3
+ class Customer
4
+ include MadCart::Model::Base
5
+
6
+ required_attributes :first_name, :last_name, :email
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module MadCart
2
+ module Model
3
+ class Product
4
+ include MadCart::Model::Base
5
+
6
+ required_attributes :name, :description, :image_url
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,151 @@
1
+ module MadCart
2
+ module Store
3
+ class SetupError < StandardError
4
+ def self.message
5
+ "It appears MyStore has overrided the default "\
6
+ "MadCart::Base initialize method. That's fine, but please store "\
7
+ "any required connection arguments as @init_args for the "\
8
+ "#connection method to use later. Remember to call #after_initialize "\
9
+ "in your initialize method should you require it."
10
+ end
11
+ end
12
+ class InvalidStore < StandardError; end
13
+
14
+ module Base
15
+ def self.included(base)
16
+ base.extend ClassMethods
17
+ base.class_eval do
18
+ include InheritableAttributes
19
+ inheritable_attributes :connection_delegate, :required_connection_args,
20
+ :fetch_delegates, :format_delegates, :after_init_delegate
21
+ end
22
+ end
23
+
24
+ def initialize(*args)
25
+ set_init_args(*args)
26
+ after_initialize(*args)
27
+ end
28
+
29
+ def connection
30
+ validate_connection_args!
31
+ return init_connection
32
+ end
33
+
34
+ def init_connection
35
+ @connection ||= execute_delegate(klass.connection_delegate, @init_args)
36
+ end
37
+
38
+ def klass
39
+ self.class
40
+ end
41
+ private :klass
42
+
43
+ def execute_delegate(delegate, *args)
44
+ return self.send(delegate, *args) if delegate.is_a?(Symbol)
45
+ return delegate.call(*args) if delegate.is_a?(Proc)
46
+
47
+ raise ArgumentError, "Invalid delegate" # if not returned by now
48
+ end
49
+ private :execute_delegate
50
+
51
+ def after_initialize(*args)
52
+ return nil unless klass.after_init_delegate
53
+ execute_delegate(klass.after_init_delegate, *args)
54
+ end
55
+ private :after_initialize
56
+
57
+ def validate_connection_args!
58
+ return true if klass.required_connection_args.empty?
59
+
60
+ raise(SetupError, SetupError.message) if @init_args.nil?
61
+ raise(ArgumentError,"Missing connection arguments: "\
62
+ "#{missing_args.join(', ')}") if missing_args.present?
63
+ end
64
+ private :validate_connection_args!
65
+
66
+ def missing_args
67
+ klass.required_connection_args - @init_args.keys
68
+ end
69
+ private :missing_args
70
+
71
+ def set_init_args(*args)
72
+ @init_args ||= configured_connection_args.merge(args.last || {})
73
+ end
74
+ private :set_init_args
75
+
76
+ def configured_connection_args
77
+ MadCart.config.send(klass.to_s.demodulize.underscore) || {}
78
+ end
79
+ private :configured_connection_args
80
+
81
+ def ensure_model_format(model, results)
82
+ if results.first.is_a?(MadCart::Model::Base)
83
+ results
84
+ else
85
+ map_to_madcart_model(model, results)
86
+ end
87
+ end
88
+ private :ensure_model_format
89
+
90
+ def map_to_madcart_model(model, results)
91
+ results.map do |args|
92
+ "MadCart::Model::#{model.to_s.classify}".constantize.new(args)
93
+ end
94
+ end
95
+ private :map_to_madcart_model
96
+
97
+ module ClassMethods
98
+ def create_connection_with(*args)
99
+ @connection_delegate = parse_delegate(args.first, :create_connection_with)
100
+ opts = args[1] || {}
101
+ @required_connection_args = opts[:requires] || []
102
+ end
103
+
104
+ def fetch(model, options={})
105
+ @fetch_delegates ||= {}
106
+ @format_delegates ||= {}
107
+ @fetch_delegates[model] = parse_delegate(options, :fetch)
108
+
109
+ define_method_for(model)
110
+ end
111
+
112
+ def format(model, options={})
113
+ if @fetch_delegates[model].nil?
114
+ raise ArgumentError, "Cannot define 'format' for a model that has not defined 'fetch'"
115
+ end
116
+
117
+ @format_delegates[model] = parse_delegate(options, :format)
118
+ end
119
+
120
+ def after_initialize(*args)
121
+ @after_init_delegate = parse_delegate(args.first, :after_initialize)
122
+ end
123
+
124
+ def parse_delegate(arg, method)
125
+ return arg if (arg.is_a?(Symbol) || arg.is_a?(Proc))
126
+ return arg[:with] if arg.is_a?(Hash) && arg[:with]
127
+
128
+ raise ArgumentError, "Invalid delegate for #{method}: "\
129
+ "#{arg.first.class}. Use Proc or Symbol. "
130
+ end
131
+ private :parse_delegate
132
+
133
+ def define_method_for(model)
134
+ define_method model do |*args|
135
+ fetch_result = execute_delegate(self.class.fetch_delegates[model], *args)
136
+ formatted_result = if self.class.format_delegates[model]
137
+ formatter = self.class.format_delegates[model]
138
+ fetch_result.map{|r| execute_delegate(formatter, r)}
139
+ else
140
+ fetch_result
141
+ end
142
+
143
+ return ensure_model_format(model, formatted_result)
144
+ end
145
+ end
146
+ private :define_method_for
147
+
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,118 @@
1
+ module MadCart
2
+ module Store
3
+ class BigCommerce
4
+ class InvalidStore < StandardError; end;
5
+ class ServerError < StandardError; end;
6
+ class InvalidCredentials < StandardError; end;
7
+
8
+ include MadCart::Store::Base
9
+
10
+ create_connection_with :create_connection, :requires => [:api_key, :store_url, :username]
11
+ fetch :customers, :with => :get_customer_hashes
12
+ fetch :products, :with => :get_products
13
+
14
+ def valid?
15
+ check_for_errors do
16
+ connection.get('time.json')
17
+ end
18
+ return true
19
+
20
+ rescue InvalidCredentials => e
21
+ return false
22
+ end
23
+
24
+ def products_count
25
+ (parse_response { connection.get('products/count.json') })["count"]
26
+ end
27
+
28
+ private
29
+
30
+ def make_customer_request(params={:min_id => 1})
31
+ parse_response { connection.get('customers.json', params) }
32
+ end
33
+
34
+ def get_products(options={})
35
+ product_hashes = connection.get('products.json', options).try(:body)
36
+ return [] unless product_hashes
37
+
38
+ threads, images = [], []
39
+ product_hashes.each do |product|
40
+ threads << Thread.new do
41
+ if product["images"]
42
+ url = "#{product["images"]["resource"][1..-1]}.json"
43
+ images << parse_response { connection.get(url) }
44
+ end
45
+ end
46
+ end
47
+ threads.each { |t| t.join }
48
+
49
+ product_hashes.map do |p|
50
+
51
+ product_images = images.find { |i| i.first['product_id'] == p['id'] }
52
+ thumbnail = product_images.find { |i| i["is_thumbnail"] }
53
+ image = product_images.sort_by{|i| i["sort_order"] }.find { |i| i["is_thumbnail"] }
54
+
55
+ p.merge({
56
+ :image_square_url => connection.build_url("/product_images/#{thumbnail['image_file']}").to_s,
57
+ :image_url => connection.build_url("/product_images/#{image['image_file']}").to_s,
58
+ })
59
+ end
60
+ end
61
+
62
+ def get_customer_hashes
63
+ result = []
64
+ loop(:make_customer_request) {|c| result << c }
65
+ return result
66
+ end
67
+
68
+ def loop(source, &block)
69
+
70
+ items = send(source, :min_id => 1)
71
+
72
+ while true
73
+ items.each &block
74
+ break if items.count < 200
75
+ items = send(source, :min_id => items.max_id + 1 )
76
+ end
77
+
78
+ end
79
+
80
+ def parse_response(&block)
81
+ response = check_for_errors &block
82
+ return [] if empty_body?(response)
83
+ return response.body
84
+ end
85
+
86
+ def check_for_errors(&block)
87
+ response = yield
88
+
89
+ case response.status
90
+ when 401
91
+ raise InvalidCredentials
92
+ when 500
93
+ raise ServerError
94
+ end
95
+
96
+ response
97
+ rescue Faraday::Error::ConnectionFailed => e
98
+ raise InvalidStore
99
+ end
100
+
101
+ def api_url_for(store_domain)
102
+ "https://#{store_domain}/api/v2/"
103
+ end
104
+
105
+ def empty_body?(response)
106
+ true if response.status == 204 || response.body.nil?
107
+ end
108
+
109
+ def create_connection(args={})
110
+ @connection = Faraday.new(:url => api_url_for(args[:store_url]))
111
+ @connection.basic_auth(args[:username], args[:api_key])
112
+ @connection.response :json
113
+ @connection.adapter Faraday.default_adapter
114
+ @connection
115
+ end
116
+ end
117
+ end
118
+ end