multiwoven-integrations 0.38.1 → 0.39.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 828f206c7576ddd3e84774cc75175213d1bef9a78df725564c67ac37d5e474d8
4
- data.tar.gz: 98b0930cef5d402c99ba5272a97cb918bb301573de8202cfaafe54d825f817e4
3
+ metadata.gz: 6ec4e8509d2c6cf3e2e3ab4ecab5198b748956f0d8b8108b6374855b8f866531
4
+ data.tar.gz: 374946a9e09d51b4bc82a0f72693991472659e11a7651f39b2aa1f678a06adda
5
5
  SHA512:
6
- metadata.gz: afd08217ce63822cf47858221c70cda09b9755b17ce2e08e6fe7156b49b74a9c6c3525e52c2f07b6946ef27d7eccd0fe113846dd51bfa48467f3c8be1641c341
7
- data.tar.gz: f0c1d17204b01e640d49ea0cb8747baead11ecbea7c4dd4e1c7a3aa9a938e8c60ca0cbb48044fa4a3b1aaed6cca51546928be77c206c4a263e965fca77cf74fa
6
+ metadata.gz: 8e3a05154a4092ea28e4d66584da10e120ecc1043607337453ead51585e7cfad4ff88cf5e1516832c48ad3f12845a996d810bce2088df3a98b7a30b1cb77ea4b
7
+ data.tar.gz: d6de430fa20d12ecb00d2ae65898f40108ffc5c8b8762874fa2766dc9dad9b687692516651da6e9674f2322497c04c44c350a5fc465b00b19c3b0b3b4b872378
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Multiwoven
4
4
  module Integrations
5
- VERSION = "0.38.1"
5
+ VERSION = "0.39.1"
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>
@@ -91,6 +91,7 @@ module Multiwoven::Integrations::Source
91
91
  raise ArgumentError, "User Name or Share URL is required" unless connection_config[:share_url].present? || connection_config[:user_name].present?
92
92
 
93
93
  load_connection_config(connection_config)
94
+ @sync_id ||= "preview"
94
95
 
95
96
  if @share_url.present?
96
97
  @drive_id = shared_folder_reference[:drive_id]
@@ -310,7 +311,7 @@ module Multiwoven::Integrations::Source
310
311
  local_file = nil
311
312
  file_name = extract_file_name_from_query(query)
312
313
  local_basename = File.basename(file_name)
313
- local_file = download_file_to_local(file_name, @sync_id)
314
+ local_file = download_spreadsheet_for_query(file_name)
314
315
 
315
316
  file = read_local_file(connection, local_basename, local_file)
316
317
  query = apply_local_file_to_query(query, file)
@@ -321,6 +322,34 @@ module Multiwoven::Integrations::Source
321
322
  cleanup_ephemeral_download(local_file)
322
323
  end
323
324
 
325
+ # Prefer Graph item IDs (same as discover) so nested / shared-folder files
326
+ # resolve during model preview. Resolve id with one path metadata GET —
327
+ # never a full folder listing / recursive BFS. Fall back to path-based download.
328
+ def download_spreadsheet_for_query(file_name)
329
+ file_item = fetch_drive_item_by_path(file_name)
330
+ if file_item&.[]("id")
331
+ download_file_to_local(
332
+ file_name,
333
+ @sync_id,
334
+ item_id: file_item["id"],
335
+ drive_id: file_item.dig("parentReference", "driveId")
336
+ )
337
+ else
338
+ download_file_to_local(file_name, @sync_id)
339
+ end
340
+ end
341
+
342
+ def fetch_drive_item_by_path(file_name)
343
+ return fetch_shared_item_metadata if @share_url.present? && shared_folder_reference[:is_file]
344
+
345
+ response = microsoft_graph_request(single_file_item_url(file_name))
346
+ return unless success?(response)
347
+
348
+ JSON.parse(response.body)
349
+ rescue StandardError
350
+ nil
351
+ end
352
+
324
353
  def extract_file_name_from_query(sql_query)
325
354
  match = sql_query.match(
326
355
  /\bFROM\s+(?:[`"]([^`"]+)[`"]|'([^']+)'|([^\s;]+))/i
@@ -379,6 +408,7 @@ module Multiwoven::Integrations::Source
379
408
  end
380
409
 
381
410
  def local_download_path(file_name, sync_id)
411
+ sync_id = sync_id.presence || "preview"
382
412
  download_path = ENV["FILE_DOWNLOAD_PATH"]
383
413
  if download_path
384
414
  File.join(download_path, "syncs", sync_id, File.basename(file_name))
@@ -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.38.1
4
+ version: 0.39.1
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-30 00:00:00.000000000 Z
11
+ date: 2026-08-10 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