multiwoven-integrations 0.38.1 → 0.39.0
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.
- checksums.yaml +4 -4
- data/lib/multiwoven/integrations/rollout.rb +2 -1
- data/lib/multiwoven/integrations/source/microsoft_dynamics/client.rb +327 -0
- data/lib/multiwoven/integrations/source/microsoft_dynamics/config/meta.json +16 -0
- data/lib/multiwoven/integrations/source/microsoft_dynamics/config/spec.json +35 -0
- data/lib/multiwoven/integrations/source/microsoft_dynamics/icon.svg +2 -0
- data/lib/multiwoven/integrations.rb +1 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8bea6c5689d8543401961ecd7840b04b2e2db88d3401d9d8c59ba09646149da7
|
|
4
|
+
data.tar.gz: e2a9a283a5718016f63ee7a0de2725d017fd5dc4543cd8e05bf1b07f4877a239
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9df4fb01b5a0834fc5e1475d910da9327e842b104dd0acfb84fb6c33ad394ce90ae57d0fe634d29ef4fab3229313347ff681b46157e0a6313eb1d4677cdf669
|
|
7
|
+
data.tar.gz: 718c8dde52b98bb30bb90d809309edc620a5ff8ff78b7a88f8de7bd219076baf5bf02d97e55b21674602a5349a834243c2796b50dd6b863e5412091981c12380
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Multiwoven
|
|
4
4
|
module Integrations
|
|
5
|
-
VERSION = "0.
|
|
5
|
+
VERSION = "0.39.0"
|
|
6
6
|
|
|
7
7
|
ENABLED_SOURCES = %w[
|
|
8
8
|
Snowflake
|
|
@@ -36,6 +36,7 @@ module Multiwoven
|
|
|
36
36
|
Http
|
|
37
37
|
Aisquared
|
|
38
38
|
OneDrive
|
|
39
|
+
MicrosoftDynamics
|
|
39
40
|
].freeze
|
|
40
41
|
|
|
41
42
|
ENABLED_DESTINATIONS = %w[
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Multiwoven::Integrations::Source
|
|
4
|
+
module MicrosoftDynamics
|
|
5
|
+
include Multiwoven::Integrations::Core
|
|
6
|
+
|
|
7
|
+
API_VERSION = "9.2"
|
|
8
|
+
EXPIRED_ACCESS_TOKEN_ERROR_CODE = "InvalidAuthenticationToken"
|
|
9
|
+
# Dynamics Web API max page size; $skip is not supported, so paging uses @odata.nextLink.
|
|
10
|
+
PAGE_SIZE = 5000
|
|
11
|
+
DYNAMICS_OBJECTS = %w[accounts contacts opportunities leads].freeze
|
|
12
|
+
ENTITY_ORDER_BY = {
|
|
13
|
+
"accounts" => "accountid",
|
|
14
|
+
"contacts" => "contactid",
|
|
15
|
+
"opportunities" => "opportunityid",
|
|
16
|
+
"leads" => "leadid"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
class Client < SourceConnector
|
|
20
|
+
def check_connection(connection_config)
|
|
21
|
+
connection_config = connection_config.with_indifferent_access
|
|
22
|
+
create_connection(connection_config)
|
|
23
|
+
response = dynamics_request(whoami_url)
|
|
24
|
+
response_body = JSON.parse(response.body)
|
|
25
|
+
|
|
26
|
+
if success?(response) && response_body.key?("UserId")
|
|
27
|
+
success_status
|
|
28
|
+
else
|
|
29
|
+
failure_status(nil)
|
|
30
|
+
end
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
handle_exception(e, {
|
|
33
|
+
context: "MICROSOFT:DYNAMICS:CHECK_CONNECTION:EXCEPTION",
|
|
34
|
+
type: "error"
|
|
35
|
+
})
|
|
36
|
+
failure_status(e)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def discover(connection_config)
|
|
40
|
+
connection_config = connection_config.with_indifferent_access
|
|
41
|
+
create_connection(connection_config)
|
|
42
|
+
|
|
43
|
+
streams = DYNAMICS_OBJECTS.filter_map do |entity|
|
|
44
|
+
create_stream_for_entity(entity)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
handle_exception(e, {
|
|
47
|
+
context: "MICROSOFT:DYNAMICS:DISCOVER:LOOP_EXCEPTION",
|
|
48
|
+
type: "error"
|
|
49
|
+
})
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Catalog.new(streams: streams).to_multiwoven_message
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
handle_exception(e, {
|
|
56
|
+
context: "MICROSOFT:DYNAMICS:DISCOVER:EXCEPTION",
|
|
57
|
+
type: "error"
|
|
58
|
+
})
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read(sync_config)
|
|
62
|
+
connection_config = sync_config.source.connection_specification.with_indifferent_access
|
|
63
|
+
@connector_instance = sync_config&.source&.connector_instance
|
|
64
|
+
create_connection(connection_config)
|
|
65
|
+
|
|
66
|
+
query = sync_config.model.query
|
|
67
|
+
query = batched_query(query, sync_config.limit, sync_config.offset) unless sync_config.limit.nil? && sync_config.offset.nil?
|
|
68
|
+
query(nil, query)
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
handle_exception(e, {
|
|
71
|
+
context: "MICROSOFT:DYNAMICS:READ:EXCEPTION",
|
|
72
|
+
type: "error",
|
|
73
|
+
sync_id: sync_config.sync_id,
|
|
74
|
+
sync_run_id: sync_config.sync_run_id
|
|
75
|
+
})
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def create_connection(connection_config)
|
|
81
|
+
load_connection_config(connection_config)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def load_connection_config(connection_config)
|
|
85
|
+
@tenant_id = connection_config[:tenant_id]
|
|
86
|
+
@client_id = connection_config[:application_id]
|
|
87
|
+
@instance_url = connection_config[:instance_url]
|
|
88
|
+
@client_secret = connection_config[:client_secret]
|
|
89
|
+
stored_token = @connector_instance&.configuration&.dig("access_token")
|
|
90
|
+
@access_token = stored_token.presence || refresh_access_token
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def refresh_access_token
|
|
94
|
+
@access_token = fetch_access_token
|
|
95
|
+
persist_access_token(@access_token)
|
|
96
|
+
@access_token
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def persist_access_token(token)
|
|
100
|
+
return unless @connector_instance&.configuration
|
|
101
|
+
|
|
102
|
+
config = @connector_instance.configuration
|
|
103
|
+
config = {} unless config.is_a?(Hash)
|
|
104
|
+
@connector_instance.update!(configuration: config.merge("access_token" => token))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fetch_access_token
|
|
108
|
+
response = Multiwoven::Integrations::Core::HttpClient.request(
|
|
109
|
+
format(MICROSOFT_GRAPH_TOKEN_URL, tenant_id: @tenant_id),
|
|
110
|
+
HTTP_POST,
|
|
111
|
+
payload: form_urlencoded_payload(
|
|
112
|
+
client_id: @client_id,
|
|
113
|
+
client_secret: @client_secret,
|
|
114
|
+
scope: "https://#{@instance_url}.crm.dynamics.com/.default",
|
|
115
|
+
grant_type: "client_credentials"
|
|
116
|
+
),
|
|
117
|
+
headers: {
|
|
118
|
+
"Content-Type" => "application/x-www-form-urlencoded"
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
raise dynamics_api_error(response.body) unless success?(response)
|
|
122
|
+
|
|
123
|
+
JSON.parse(response.body)["access_token"]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def query(_connection, sql_query)
|
|
127
|
+
entity, select_fields, limit, offset = parse_sql_query(sql_query)
|
|
128
|
+
records = fetch_entity_records(entity, select_fields: select_fields, limit: limit, offset: offset)
|
|
129
|
+
records.map do |row|
|
|
130
|
+
RecordMessage.new(data: sanitize_record(row), emitted_at: Time.now.to_i).to_multiwoven_message
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def create_stream_for_entity(entity)
|
|
135
|
+
records = fetch_entity_records(entity, limit: 1)
|
|
136
|
+
raise StandardError, "No records found for #{entity}" if records.empty?
|
|
137
|
+
|
|
138
|
+
columns = records.first.keys.reject { |key| odata_annotation?(key) }.map do |key|
|
|
139
|
+
{ column_name: key, type: "string" }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
Multiwoven::Integrations::Protocol::Stream.new(
|
|
143
|
+
name: entity,
|
|
144
|
+
action: StreamAction["fetch"],
|
|
145
|
+
json_schema: convert_to_json_schema(columns),
|
|
146
|
+
supported_sync_modes: %w[incremental]
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def fetch_entity_records(entity, select_fields: nil, limit: nil, offset: nil)
|
|
151
|
+
offset = offset.to_i
|
|
152
|
+
limit = limit&.to_i
|
|
153
|
+
records = []
|
|
154
|
+
skipped = 0
|
|
155
|
+
url = entity_url(
|
|
156
|
+
entity,
|
|
157
|
+
select_fields: select_fields,
|
|
158
|
+
top: page_size_for(limit: limit, offset: offset)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
while url.present?
|
|
162
|
+
page_records, next_url = fetch_entity_page(url)
|
|
163
|
+
break if page_records.empty?
|
|
164
|
+
|
|
165
|
+
skipped = collect_page_records(
|
|
166
|
+
records,
|
|
167
|
+
page_records,
|
|
168
|
+
offset: offset,
|
|
169
|
+
limit: limit,
|
|
170
|
+
skipped: skipped
|
|
171
|
+
)
|
|
172
|
+
break if limit_reached?(records, limit)
|
|
173
|
+
|
|
174
|
+
url = next_url
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
records
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def fetch_entity_page(url)
|
|
181
|
+
response = dynamics_request(url)
|
|
182
|
+
raise dynamics_api_error(response.body) unless success?(response)
|
|
183
|
+
|
|
184
|
+
body = JSON.parse(response.body)
|
|
185
|
+
[body["value"] || [], body["@odata.nextLink"]]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def collect_page_records(records, page_records, offset:, limit:, skipped:)
|
|
189
|
+
page_records.each do |record|
|
|
190
|
+
if skipped < offset
|
|
191
|
+
skipped += 1
|
|
192
|
+
next
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
records << record
|
|
196
|
+
break if limit_reached?(records, limit)
|
|
197
|
+
end
|
|
198
|
+
skipped
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def limit_reached?(records, limit)
|
|
202
|
+
limit.present? && limit.positive? && records.size >= limit
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_sql_query(sql_query)
|
|
206
|
+
query = sql_query.to_s.strip.chomp(";")
|
|
207
|
+
entity = query[/FROM\s+([^\s;]+)/i, 1]
|
|
208
|
+
raise ArgumentError, "Could not extract entity name from query" if entity.blank?
|
|
209
|
+
|
|
210
|
+
select_clause = query[/SELECT\s+(.+?)\s+FROM/i, 1]
|
|
211
|
+
select_fields = if select_clause.nil? || select_clause.strip == "*"
|
|
212
|
+
nil
|
|
213
|
+
else
|
|
214
|
+
select_clause.split(",").map(&:strip)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
limit = query[/LIMIT\s+(\d+)/i, 1]&.to_i
|
|
218
|
+
offset = query[/OFFSET\s+(\d+)/i, 1]&.to_i
|
|
219
|
+
|
|
220
|
+
[entity, select_fields, limit, offset]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# CRM rejects OData $skip ("Skip Clause is not supported").
|
|
224
|
+
# Batching uses $top + @odata.nextLink, applying OFFSET in-process.
|
|
225
|
+
def entity_url(entity, select_fields: nil, top: nil)
|
|
226
|
+
entity = entity.to_s
|
|
227
|
+
raise ArgumentError, "Invalid entity name: #{entity}" unless entity.match?(/\A\w+\z/)
|
|
228
|
+
|
|
229
|
+
base = format(MS_DYNAMICS_REST_API, instance_url: @instance_url, api_version: API_VERSION, entity: entity)
|
|
230
|
+
query_parts = []
|
|
231
|
+
query_parts << "$select=#{URI.encode_www_form_component(select_fields.join(","))}" if select_fields.present?
|
|
232
|
+
order_by = ENTITY_ORDER_BY[entity]
|
|
233
|
+
query_parts << "$orderby=#{URI.encode_www_form_component(order_by)}" if order_by.present?
|
|
234
|
+
query_parts << "$top=#{top.to_i}" if top.present? && top.to_i.positive?
|
|
235
|
+
query_parts.empty? ? base : "#{base}?#{query_parts.join("&")}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def page_size_for(limit:, offset:)
|
|
239
|
+
offset = offset.to_i
|
|
240
|
+
limit = limit&.to_i
|
|
241
|
+
return [PAGE_SIZE, offset + limit].min if offset.positive? && limit.present? && limit.positive?
|
|
242
|
+
return limit if limit.present? && limit.positive?
|
|
243
|
+
|
|
244
|
+
PAGE_SIZE
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def whoami_url
|
|
248
|
+
format(MS_DYNAMICS_WHOAMI_API, instance_url: @instance_url, api_version: API_VERSION)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def dynamics_request(url)
|
|
252
|
+
response = dynamics_http_get(url)
|
|
253
|
+
return response unless expired_access_token_error?(response)
|
|
254
|
+
|
|
255
|
+
refresh_access_token
|
|
256
|
+
dynamics_http_get(url)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def dynamics_http_get(url)
|
|
260
|
+
Multiwoven::Integrations::Core::HttpClient.request(
|
|
261
|
+
url,
|
|
262
|
+
HTTP_GET,
|
|
263
|
+
headers: {
|
|
264
|
+
"Accept" => "application/json",
|
|
265
|
+
"Authorization" => "Bearer #{@access_token}",
|
|
266
|
+
"Content-Type" => "application/json"
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def sanitize_record(record)
|
|
272
|
+
record.each_with_object({}) do |(key, value), result|
|
|
273
|
+
next if odata_annotation?(key)
|
|
274
|
+
next if value.is_a?(Hash) || value.is_a?(Array)
|
|
275
|
+
|
|
276
|
+
result[key] = value
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def odata_annotation?(key)
|
|
281
|
+
key.to_s.start_with?("@") || key.to_s.include?("@odata")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def dynamics_api_error(response_body)
|
|
285
|
+
parsed = JSON.parse(response_body)
|
|
286
|
+
error = parsed["error"]
|
|
287
|
+
|
|
288
|
+
message = if error.is_a?(Hash)
|
|
289
|
+
"#{error["code"]}: #{error["message"]}"
|
|
290
|
+
elsif error.is_a?(String)
|
|
291
|
+
description = parsed["error_description"]
|
|
292
|
+
description.present? ? "#{error}: #{description}" : error
|
|
293
|
+
else
|
|
294
|
+
response_body
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
StandardError.new(message)
|
|
298
|
+
rescue JSON::ParserError, TypeError
|
|
299
|
+
StandardError.new(response_body.to_s)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def expired_access_token_error?(response)
|
|
303
|
+
return true if response.code.to_s == "401"
|
|
304
|
+
|
|
305
|
+
error = JSON.parse(response.body)["error"]
|
|
306
|
+
return false unless error.is_a?(Hash)
|
|
307
|
+
|
|
308
|
+
error["code"] == EXPIRED_ACCESS_TOKEN_ERROR_CODE
|
|
309
|
+
rescue JSON::ParserError, TypeError
|
|
310
|
+
false
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# HttpClient.request always calls payload.to_json.
|
|
314
|
+
# Microsoft OAuth token endpoints require
|
|
315
|
+
# application/x-www-form-urlencoded bodies instead of JSON.
|
|
316
|
+
# This wrapper overrides to_json so HttpClient sends a
|
|
317
|
+
# form-encoded string rather than a JSON document.
|
|
318
|
+
def form_urlencoded_payload(fields)
|
|
319
|
+
payload = Object.new
|
|
320
|
+
payload.define_singleton_method(:to_json) do |*_args|
|
|
321
|
+
URI.encode_www_form(fields)
|
|
322
|
+
end
|
|
323
|
+
payload
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": {
|
|
3
|
+
"name": "MicrosoftDynamics",
|
|
4
|
+
"title": "Microsoft Dynamics",
|
|
5
|
+
"connector_type": "source",
|
|
6
|
+
"category": "Data Warehouse",
|
|
7
|
+
"sub_category": "Relational Database",
|
|
8
|
+
"documentation_url": "https://docs.squared.ai/guides/sources/data-sources/microsoft_dynamics",
|
|
9
|
+
"github_issue_label": "source-microsoft-dynamics",
|
|
10
|
+
"icon": "icon.svg",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"release_stage": "alpha",
|
|
13
|
+
"support_level": "community",
|
|
14
|
+
"tags": ["language:ruby", "multiwoven"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"documentation_url": "https://docs.squared.ai/guides/sources/data-sources/microsoft_dynamics",
|
|
3
|
+
"stream_type": "dynamic",
|
|
4
|
+
"connector_query_type": "raw_sql",
|
|
5
|
+
"connection_specification": {
|
|
6
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
7
|
+
"title": "Microsoft Dynamics",
|
|
8
|
+
"type": "object",
|
|
9
|
+
"required": ["instance_url", "tenant_id", "application_id", "client_secret"],
|
|
10
|
+
"properties": {
|
|
11
|
+
"instance_url": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"title": "Organization Name",
|
|
14
|
+
"order": 0
|
|
15
|
+
},
|
|
16
|
+
"tenant_id": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"multiwoven_secret": true,
|
|
19
|
+
"title": "Tenant ID",
|
|
20
|
+
"order": 1
|
|
21
|
+
},
|
|
22
|
+
"application_id": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"title": "Application ID",
|
|
25
|
+
"order": 2
|
|
26
|
+
},
|
|
27
|
+
"client_secret": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"multiwoven_secret": true,
|
|
30
|
+
"title": "Client Secret",
|
|
31
|
+
"order": 3
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
2
|
+
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>Dynamics 365 icon</title><path d="M4.59 7.41l4.94 3.54L4.59 24zm0-7.41v6.36l9.53 5.29 4.59-3.52zm0 24l14.82-8.47v-6.7Z"/></svg>
|
|
@@ -102,6 +102,7 @@ require_relative "integrations/source/google_drive/client"
|
|
|
102
102
|
require_relative "integrations/source/http/client"
|
|
103
103
|
require_relative "integrations/source/aisquared/client"
|
|
104
104
|
require_relative "integrations/source/one_drive/client"
|
|
105
|
+
require_relative "integrations/source/microsoft_dynamics/client"
|
|
105
106
|
|
|
106
107
|
# Destination
|
|
107
108
|
require_relative "integrations/destination/klaviyo/client"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: multiwoven-integrations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.39.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Subin T P
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07
|
|
11
|
+
date: 2026-08-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -802,6 +802,10 @@ files:
|
|
|
802
802
|
- lib/multiwoven/integrations/source/maria_db/config/meta.json
|
|
803
803
|
- lib/multiwoven/integrations/source/maria_db/config/spec.json
|
|
804
804
|
- lib/multiwoven/integrations/source/maria_db/icon.svg
|
|
805
|
+
- lib/multiwoven/integrations/source/microsoft_dynamics/client.rb
|
|
806
|
+
- lib/multiwoven/integrations/source/microsoft_dynamics/config/meta.json
|
|
807
|
+
- lib/multiwoven/integrations/source/microsoft_dynamics/config/spec.json
|
|
808
|
+
- lib/multiwoven/integrations/source/microsoft_dynamics/icon.svg
|
|
805
809
|
- lib/multiwoven/integrations/source/odoo/client.rb
|
|
806
810
|
- lib/multiwoven/integrations/source/odoo/config/meta.json
|
|
807
811
|
- lib/multiwoven/integrations/source/odoo/config/spec.json
|