marketo-api-ruby 0.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -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