marketo-api-ruby 0.8

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.
@@ -0,0 +1,169 @@
1
+ require 'savon'
2
+ require 'openssl'
3
+
4
+ ##
5
+ # The client to the Marketo SOAP API.
6
+ class MarketoAPI::Client
7
+ DEFAULT_CONFIG = {
8
+ api_subdomain: '123-ABC-456',
9
+ api_version: '2_3',
10
+ user_id: nil,
11
+ encryption_key: nil,
12
+ read_timeout: 90,
13
+ open_timeout: 90,
14
+ headers: { 'Connection' => 'Keep-Alive' },
15
+ env_namespace: 'SOAP-ENV',
16
+ namespaces: { 'xmlns:ns1' => 'http://www.marketo.com/mktows/' },
17
+ pretty_print_xml: true,
18
+ ssl_verify_mode: :none,
19
+ }.freeze
20
+ DEFAULT_CONFIG.values.each(&:freeze)
21
+ private_constant :DEFAULT_CONFIG
22
+
23
+ # Sets the logger.
24
+ attr_writer :logger
25
+
26
+ # The targeted Marketo SOAP API version.
27
+ attr_reader :api_version
28
+ # The subdomain for interacting with Marketo.
29
+ attr_reader :subdomain
30
+ # The WSDL used for interacting with Marketo.
31
+ attr_reader :wsdl
32
+ # The computed endpoint for Marketo.
33
+ attr_reader :endpoint
34
+ # If the most recent call resulted in an exception, it will be captured
35
+ # here.
36
+ attr_reader :error
37
+
38
+ # Creates a client to talk to the Marketo SOAP API.
39
+ #
40
+ # === Required Configuration Parameters
41
+ #
42
+ # The required configuration parameters can be found in your Marketo
43
+ # dashboard, under Admin / Integration / SOAP API.
44
+ #
45
+ # +api_subdomain+:: The endpoint subdomain.
46
+ # +api_version+:: The endpoint version.
47
+ # +user_id+:: The user iD for SOAP integration.
48
+ # +encryption_key+:: The encryption key for SOAP integration.
49
+ #
50
+ # Version 1.0 will make these values defaultable through environment
51
+ # variables.
52
+ #
53
+ # === Savon Configuration Parameters
54
+ #
55
+ # These affect how Savon interacts with the HTTP server.
56
+ #
57
+ # +read_timeout+:: The timeout for reading from the server. Defaults
58
+ # to 90.
59
+ # +open_timeout+:: The timeout for opening the connection. Defaults to
60
+ # 90.
61
+ # +pretty_print_xml+:: How the SOAP XML should be written. Defaults to
62
+ # +true+.
63
+ # +ssl_verify_mode+:: How to verify SSL keys. This version defaults to
64
+ # +none+. Version 1.0 will default to normal
65
+ # verification.
66
+ # +headers+:: Headers to use. Defaults to Connection: Keep-Alive.
67
+ # Version 1.0 will enforce at least this value.
68
+ #
69
+ # Version 1.0 will require that these options be provided under a +savon+
70
+ # key.
71
+ def initialize(config = {})
72
+ config = DEFAULT_CONFIG.merge(config)
73
+ @api_version = config.delete(:api_version).freeze
74
+ @subdomain = config.delete(:api_subdomain).freeze
75
+
76
+ @logger = config.delete(:logger)
77
+
78
+ user_id = config.delete(:user_id)
79
+ encryption_key = config.delete(:encryption_key)
80
+ @auth = AuthHeader.new(user_id, encryption_key)
81
+
82
+ @wsdl = "http://app.marketo.com/soap/mktows/#{api_version}?WSDL".freeze
83
+ @endpoint = "https://#{subdomain}.mktoapi.com/soap/mktows/#{api_version}".freeze
84
+ @savon = Savon.client(config.merge(wsdl: wsdl, endpoint: endpoint))
85
+ end
86
+
87
+ # Indicates the presence of an error from the last call.
88
+ def error?
89
+ !!@error
90
+ end
91
+
92
+ ##
93
+ # :attr_reader: campaigns
94
+ # The MarketoAPI::Campaigns instance using this Client.
95
+
96
+ ##
97
+ # :attr_reader: leads
98
+ # The MarketoAPI::Leads instance using this Client.
99
+
100
+ ##
101
+ # :attr_reader: lists
102
+ # The MarketoAPI::Lists instance using this Client.
103
+
104
+ ##
105
+ # :attr_reader: mobjects
106
+ # The MarketoAPI::MObjects instance using this Client.
107
+
108
+ # Perform a SOAP API request with a properly formatted params message
109
+ # object.
110
+ #
111
+ # *Warning*: This method is for internal use by descendants of
112
+ # MarketoAPI::ClientProxy. It should not be called by external users.
113
+ def call(web_method, params) #:nodoc:
114
+ @error = nil
115
+ @savon.call(
116
+ web_method,
117
+ message: params,
118
+ soap_header: { 'ns1:AuthenticationHeader' => @auth.signature }
119
+ ).to_hash
120
+ rescue Exception => e
121
+ @error = e
122
+ @logger.log(e) if @logger
123
+ nil
124
+ end
125
+
126
+ private
127
+ # Implements the Marketo
128
+ # {Authentication Signature}[http://developers.marketo.com/documentation/soap/signature-algorithm/].
129
+ class AuthHeader #:nodoc:
130
+ DIGEST = OpenSSL::Digest.new('sha1')
131
+ private_constant :DIGEST
132
+
133
+ def initialize(user_id, encryption_key)
134
+ if user_id.nil? || encryption_key.nil?
135
+ raise ArgumentError, ":user_id and :encryption_key required"
136
+ end
137
+
138
+ @user_id = user_id
139
+ @encryption_key = encryption_key
140
+ end
141
+
142
+ attr_reader :user_id
143
+
144
+ # Compute the HMAC signature and return it.
145
+ def signature
146
+ time = Time.now
147
+ {
148
+ mktowsUserId: user_id,
149
+ requestTimestamp: time.to_s,
150
+ requestSignature: hmac(time),
151
+ }
152
+ end
153
+
154
+ private
155
+ def hmac(time)
156
+ OpenSSL::HMAC.hexdigest(
157
+ DIGEST,
158
+ @encryption_key,
159
+ "#{time}#{user_id}"
160
+ )
161
+ end
162
+ end
163
+ private_constant :AuthHeader
164
+ end
165
+
166
+ require_relative 'campaigns'
167
+ require_relative 'leads'
168
+ require_relative 'lists'
169
+ require_relative 'mobjects'
@@ -0,0 +1,104 @@
1
+ require 'forwardable'
2
+
3
+ # The ClientProxy is the base class for implementing Marketo APIs in a
4
+ # fluent manner. When a descendant class is implemented, a method will be
5
+ # added to MarketoAPI::Client based on the descendant class name that will
6
+ # provide an initialized instance of the descendant class using the
7
+ # MarketoAPI::Client instance.
8
+ #
9
+ # This base class does not provide any useful functionality for consumers of
10
+ # MarketoAPI, and is primarily provided for common functionality.
11
+ #
12
+ # As an example, MarketoAPI::Client#campaigns was generated when
13
+ # MarketoAPI::Campaigns was inherited from MarketoAPI::ClientProxy. It
14
+ # returns an instance of MarketoAPI::Campaigns intialized with the
15
+ # MarketoAPI::Client that generated it.
16
+ class MarketoAPI::ClientProxy
17
+ extend Forwardable
18
+
19
+ class << self
20
+ # Generates a new method on MarketoAPI::Client based on the inherited
21
+ # class name.
22
+ def inherited(klass)
23
+ name = klass.name.split(/::/).last.downcase.to_sym
24
+
25
+ MarketoAPI::Client.class_eval <<-EOS
26
+ def #{name}
27
+ @#{name} ||= #{klass}.new(self)
28
+ end
29
+ EOS
30
+ end
31
+ end
32
+
33
+ def initialize(client)
34
+ @client = client
35
+ end
36
+
37
+ ##
38
+ # :attr_reader: error
39
+ #
40
+ # Reads the error attribute from the proxied client.
41
+
42
+ ##
43
+ # :method: error?
44
+ #
45
+ # Reads the presence of an error from the proxied client.
46
+
47
+ def_delegators :@client, :error, :error?
48
+
49
+ private
50
+ # Performs a SOAP call and extracts the result from a successful result.
51
+ #
52
+ # The Marketo SOAP API always returns results in a fairly deep structure.
53
+ # For example, the SOAP response for requestCampaign looks something like:
54
+ #
55
+ # &lt;successRequestCampaign&gt;
56
+ # &lt;result&gt;
57
+ # &lt;/result&gt;
58
+ # &lt;/successRequestCampaign&gt;
59
+ def call(method, param, &block)
60
+ extract_from_response(
61
+ @client.call(method, param),
62
+ :"success_#{method}",
63
+ :result,
64
+ &block
65
+ )
66
+ end
67
+
68
+ # Takes a parameter list and calls #transform_param on each parameter.
69
+ def transform_param_list(method, param_list)
70
+ method = :"params_for_#{method}"
71
+ param_list.map { |param|
72
+ transform_param(nil, param, method)
73
+ }.compact
74
+ end
75
+
76
+ # Takes a provided a parameter and transforms it. If the parameter is a
77
+ # Hash or +nil+, there is no transformation performed; if it responds to a
78
+ # method <tt>params_for_#{method}</tt>, that method is called to transform
79
+ # the object.
80
+ #
81
+ # If neither of these is true, an ArgumentError is raised.
82
+ def transform_param(method, param, override = nil)
83
+ method = if override
84
+ override
85
+ else
86
+ :"params_for_#{method}"
87
+ end
88
+ if param.kind_of? Hash or param.nil?
89
+ param
90
+ elsif param.respond_to? method
91
+ param.send(method)
92
+ else
93
+ raise ArgumentError, "Invalid parameter: #{param.inspect}"
94
+ end
95
+ end
96
+
97
+ # Given a response hash (which is deeply nested), follows the key path
98
+ # down.
99
+ def extract_from_response(response, *paths)
100
+ paths.each { |path| response &&= response[path] }
101
+ response = yield response if response and block_given?
102
+ response
103
+ end
104
+ end
@@ -0,0 +1,277 @@
1
+ require 'forwardable'
2
+
3
+ # An object representing a Marketo Lead record.
4
+ class MarketoAPI::Lead
5
+ extend Forwardable
6
+ include Enumerable
7
+
8
+ NAMED_KEYS = { #:nodoc:
9
+ id: :IDNUM,
10
+ cookie: :COOKIE,
11
+ email: :EMAIL,
12
+ lead_owner_email: :LEADOWNEREMAIL,
13
+ salesforce_account_id: :SFDCACCOUNTID,
14
+ salesforce_contact_id: :SFDCCONTACTID,
15
+ salesforce_lead_id: :SFDCLEADID,
16
+ salesforce_lead_owner_id: :SFDCLEADOWNERID,
17
+ salesforce_opportunity_id: :SFDCOPPTYID
18
+ }.freeze
19
+
20
+ KEY_TYPES = MarketoAPI.freeze(*NAMED_KEYS.values) #:nodoc:
21
+ private_constant :KEY_TYPES
22
+
23
+ # The Marketo ID. This value cannot be set by consumers.
24
+ attr_reader :id
25
+ # The Marketo tracking cookie. Optional.
26
+ attr_accessor :cookie
27
+
28
+ # The attributes for the Lead.
29
+ attr_reader :attributes
30
+ # The types for the Lead attributes.
31
+ attr_reader :types
32
+ # The proxy object for this class.
33
+ attr_reader :proxy
34
+
35
+ def_delegators :@attributes, :[], :each, :each_pair, :each_key,
36
+ :each_value, :keys, :values
37
+
38
+ ##
39
+ # :method: [](hash)
40
+ # :call-seq:
41
+ # lead[attribute_key]
42
+ #
43
+ # Looks up the provided attribute.
44
+
45
+ ##
46
+ # :method: each
47
+ # :call-seq:
48
+ # each { |key, value| block }
49
+ #
50
+ # Iterates over the attributes.
51
+
52
+ ##
53
+ # :method: each_pair
54
+ # :call-seq:
55
+ # each_pair { |key, value| block }
56
+ #
57
+ # Iterates over the attributes.
58
+
59
+ ##
60
+ # :method: each_key
61
+ # :call-seq:
62
+ # each_key { |key| block }
63
+ #
64
+ # Iterates over the attribute keys.
65
+
66
+ ##
67
+ # :method: each_value
68
+ # :call-seq:
69
+ # each_value { |value| block }
70
+ #
71
+ # Iterates over the attribute values.
72
+
73
+ ##
74
+ # :method: keys
75
+ # :call-seq:
76
+ # keys() -> array
77
+ #
78
+ # Returns the attribute keys.
79
+
80
+ ##
81
+ # :method: values
82
+ # :call-seq:
83
+ # values() -> array
84
+ #
85
+ # Returns the attribute values.
86
+
87
+ def initialize(options = {})
88
+ @id = options[:id]
89
+ @attributes = {}
90
+ @types = {}
91
+ @foreign = {}
92
+ self[:Email] = options[:email]
93
+ self.proxy = options[:proxy]
94
+ yield self if block_given?
95
+ end
96
+
97
+ ##
98
+ # :method: []=(hash, value)
99
+ # :call-seq:
100
+ # lead[key] = value -> value
101
+ #
102
+ # Looks up the provided attribute.
103
+ def []=(key, value)
104
+ @attributes[key] = value
105
+ @types[key] ||= infer_value_type(value)
106
+ end
107
+
108
+ # :attr_writer:
109
+ # :call-seq:
110
+ # lead.proxy = proxy -> proxy
111
+ #
112
+ # Assign a proxy object. Once set, the proxy cannot be unset, but it can be
113
+ # changed.
114
+ def proxy=(value)
115
+ @proxy = case value
116
+ when nil
117
+ defined?(@proxy) && @proxy
118
+ when MarketoAPI::Leads
119
+ value
120
+ when MarketoAPI::ClientProxy
121
+ value.instance_variable_get(:@client).leads
122
+ when MarketoAPI::Client
123
+ value.leads
124
+ else
125
+ raise ArgumentError, "Invalid proxy type"
126
+ end
127
+ end
128
+
129
+ # :call-seq:
130
+ # lead.foreign -> nil
131
+ # lead.foreign(type, id) -> { type: type, id: id }
132
+ # lead.foreign -> { type: type, id: id }
133
+ #
134
+ # Sets or returns the foreign system type and person ID.
135
+ def foreign(type = nil, id = nil)
136
+ @foreign = { type: type.to_sym, id: id } if type and id
137
+ @foreign
138
+ end
139
+
140
+ # :attr_reader: email
141
+ def email
142
+ self[:Email]
143
+ end
144
+
145
+ # :attr_writer: email
146
+ def email=(value)
147
+ self[:Email] = value
148
+ end
149
+
150
+ # Performs a Lead sync and returns the new Lead object, or +nil+ if the
151
+ # sync failed.
152
+ #
153
+ # Raises an ArgumentError if a proxy has not been configured with
154
+ # Lead#proxy=.
155
+ def sync
156
+ raise ArgumentError, "No proxy configured" unless proxy
157
+ proxy.sync(self)
158
+ end
159
+
160
+ # Performs a Lead sync and updates this Lead object in-place, or +nil+ if
161
+ # the sync failed.
162
+ #
163
+ # Raises an ArgumentError if a proxy has not been configured with
164
+ # Lead#proxy=.
165
+ def sync!
166
+ if lead = sync
167
+ @id = lead.id
168
+ @cookie = lead.cookie
169
+ @foreign = lead.foreign
170
+ @proxy = lead.proxy
171
+ removed = self.keys - lead.keys
172
+
173
+ lead.each_pair { |k, v|
174
+ @attributes[k] = v
175
+ @types[k] = lead.types[k]
176
+ }
177
+
178
+ removed.each { |k|
179
+ @attributes.delete(k)
180
+ @types.delete(k)
181
+ }
182
+ self
183
+ end
184
+ end
185
+
186
+ # Returns a lead key structure suitable for use with
187
+ # MarketoAPI::Leads#get.
188
+ def params_for_get
189
+ self.class.key(:IDNUM, id)
190
+ end
191
+
192
+ # Returns the parameters required for use with MarketoAPI::Leads#sync.
193
+ def params_for_sync
194
+ {
195
+ return_lead: true,
196
+ marketo_cookie: cookie,
197
+ lead_record: {
198
+ email: email,
199
+ id: id,
200
+ foreign_sys_person_id: foreign[:id],
201
+ foreign_sys_type: foreign[:type],
202
+ lead_attribute_list: {
203
+ attribute: attributes.map { |key, value|
204
+ {
205
+ attr_name: key.to_s,
206
+ attr_type: types[key],
207
+ attr_value: value
208
+ }
209
+ }
210
+ }
211
+ }.delete_if(&MarketoAPI::MINIMIZE_HASH)
212
+ }.delete_if(&MarketoAPI::MINIMIZE_HASH)
213
+ end
214
+
215
+ class << self
216
+ # Creates a new Lead from a SOAP response hash (from Leads#get,
217
+ # Leads#get_multiple, Leads#sync, or Leads#sync_multiple).
218
+ def from_soap_hash(hash) #:nodoc:
219
+ lead = new(id: hash[:id].to_i, email: hash[:email]) do |lr|
220
+ if type = hash[:foreign_sys_type]
221
+ lr.foreign(type, hash[:foreign_sys_person_id])
222
+ end
223
+ hash[:lead_attribute_list][:attribute].each do |attribute|
224
+ name = attribute[:attr_name].to_sym
225
+ lr.attributes[name] = attribute[:attr_value]
226
+ lr.types[name] = attribute[:attr_type]
227
+ end
228
+ end
229
+ yield lead if block_given?
230
+ lead
231
+ end
232
+
233
+ # Creates a new Lead key hash suitable for use in a number of Marketo
234
+ # API calls.
235
+ def key(key, value)
236
+ {
237
+ lead_key: {
238
+ key_type: key_type(key),
239
+ key_value: value
240
+ }
241
+ }
242
+ end
243
+
244
+ private
245
+ def key_type(key)
246
+ key = key.to_sym
247
+ res = if KEY_TYPES.include? key
248
+ key
249
+ else
250
+ NAMED_KEYS[key]
251
+ end
252
+ raise ArgumentError, "Invalid key #{key}" unless res
253
+ res
254
+ end
255
+ end
256
+
257
+ def ==(other)
258
+ id == other.id && cookie == other.cookie && foreign == other.foreign &&
259
+ attributes == other.attributes && types == other.types
260
+ end
261
+
262
+ def inspect
263
+ "#<#{self.class} id=#{id} cookie=#{cookie} foreign=#{foreign.inspect} attributes=#{attributes.inspect} types=#{types.inspect}>"
264
+ end
265
+
266
+ private
267
+ def infer_value_type(value)
268
+ case value
269
+ when Integer
270
+ 'integer'
271
+ when Time, DateTime
272
+ 'datetime'
273
+ else
274
+ 'string'
275
+ end
276
+ end
277
+ end