multiwoven-integrations 0.1.33 → 0.1.35

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a66cfeeb953e2701791198684f366777de89f4705352170541a1b0d8d53b7480
4
- data.tar.gz: b72930a210e79eff11377e0b3f4f7b6a21e647efea3e043eb54cf59bb760eba0
3
+ metadata.gz: 154bbe9f4e36d95f4cd7fdb8094077e2ff724a3eef95ec76264950747d619fe2
4
+ data.tar.gz: b3da4210a997a0143b637a32d4d4b0dc6ba741742e5a1f73a3a465c8187479de
5
5
  SHA512:
6
- metadata.gz: c9862d1309777e818009d27518cbf80371d7a05158749959eebdd7614b32adf31e49b624d864846a50e1377c1e4bf8b9fbe561ca002be934ecb42e3b2ad9c921
7
- data.tar.gz: b8faac5a076b51a73dae8f522e6481777441591e48ecd1dbb4e4d74292f8ce418aad7cb5e44fc160256c5712e98fa15a98382285486e5d5217e27a55a4370bdd
6
+ metadata.gz: '052295ce22be1a08561f2d907bb1974c465302f2ec12cd6ae50afa976354bc13eb9ac0d178cd1b9eca86945cb18c4cb316ef05a44e6deac14ccc49f03fb57029'
7
+ data.tar.gz: 35e541a5b3ea70f9761ff0c54b527a6334fcc91d5321018d4ce926163f8611eb4a4db714a2b36ad01c8e869854544ec63142bef2e2e99bd7cc0d3d2e022ebe2d
@@ -57,6 +57,14 @@ module Multiwoven
57
57
  file_contents = File.read(file_path)
58
58
  JSON.parse(file_contents)
59
59
  end
60
+
61
+ def success_status
62
+ ConnectionStatus.new(status: ConnectionStatusType["succeeded"]).to_multiwoven_message
63
+ end
64
+
65
+ def failure_status(error)
66
+ ConnectionStatus.new(status: ConnectionStatusType["failed"], message: error.message).to_multiwoven_message
67
+ end
60
68
  end
61
69
  end
62
70
  end
@@ -18,6 +18,8 @@ module Multiwoven
18
18
  SNOWFLAKE_DRIVER_PATH = ENV["SNOWFLAKE_DRIVER_PATH"] || SNOWFLAKE_MAC_DRIVER_PATH
19
19
  DATABRICKS_DRIVER_PATH = ENV["DATABRICKS_DRIVER_PATH"] || DATABRICKS_MAC_DRIVER_PATH
20
20
 
21
+ JSON_SCHEMA_URL = "https://json-schema.org/draft-07/schema#"
22
+
21
23
  # CONNECTORS
22
24
  KLAVIYO_AUTH_ENDPOINT = "https://a.klaviyo.com/api/lists/"
23
25
  KLAVIYO_AUTH_PAYLOAD = {
@@ -31,11 +33,19 @@ module Multiwoven
31
33
 
32
34
  FACEBOOK_AUDIENCE_GET_ALL_ACCOUNTS = "https://graph.facebook.com/v18.0/me/adaccounts?fields=id,name"
33
35
 
36
+ AIRTABLE_URL_BASE = "https://api.airtable.com/v0/"
37
+ AIRTABLE_BASES_ENDPOINT = "https://api.airtable.com/v0/meta/bases"
38
+ AIRTABLE_GET_BASE_SCHEMA_ENDPOINT = "https://api.airtable.com/v0/meta/bases/{baseId}/tables"
39
+
34
40
  # HTTP
35
41
  HTTP_GET = "GET"
36
42
  HTTP_POST = "POST"
37
43
  HTTP_PUT = "PUT"
38
44
  HTTP_DELETE = "DELETE"
45
+
46
+ # google sheets
47
+ GOOGLE_SHEETS_SCOPE = "https://www.googleapis.com/auth/drive"
48
+ GOOGLE_SPREADSHEET_ID_REGEX = %r{/d/([-\w]{20,})/}.freeze
39
49
  end
40
50
  end
41
51
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema_helper"
4
+ module Multiwoven
5
+ module Integrations
6
+ module Destination
7
+ module Airtable
8
+ include Multiwoven::Integrations::Core
9
+ class Client < DestinationConnector # rubocop:disable Metrics/ClassLength
10
+ MAX_CHUNK_SIZE = 10
11
+ def check_connection(connection_config)
12
+ connection_config = connection_config.with_indifferent_access
13
+ bases = Multiwoven::Integrations::Core::HttpClient.request(
14
+ AIRTABLE_BASES_ENDPOINT,
15
+ HTTP_GET,
16
+ headers: auth_headers(connection_config[:api_key])
17
+ )
18
+ if success?(bases)
19
+ base_id_exists?(bases, connection_config[:base_id])
20
+ success_status
21
+ else
22
+ failure_status(nil)
23
+ end
24
+ rescue StandardError => e
25
+ failure_status(e)
26
+ end
27
+
28
+ def discover(connection_config)
29
+ connection_config = connection_config.with_indifferent_access
30
+ base_id = connection_config[:base_id]
31
+ api_key = connection_config[:api_key]
32
+
33
+ bases = Multiwoven::Integrations::Core::HttpClient.request(
34
+ AIRTABLE_BASES_ENDPOINT,
35
+ HTTP_GET,
36
+ headers: auth_headers(api_key)
37
+ )
38
+
39
+ base = extract_bases(bases).find { |b| b["id"] == base_id }
40
+ base_name = base["name"]
41
+
42
+ schema = Multiwoven::Integrations::Core::HttpClient.request(
43
+ AIRTABLE_GET_BASE_SCHEMA_ENDPOINT.gsub("{baseId}", base_id),
44
+ HTTP_GET,
45
+ headers: auth_headers(api_key)
46
+ )
47
+
48
+ catalog = build_catalog_from_schema(extract_body(schema), base_id, base_name)
49
+ catalog.to_multiwoven_message
50
+ rescue StandardError => e
51
+ handle_exception("AIRTABLE:DISCOVER:EXCEPTION", "error", e)
52
+ end
53
+
54
+ def write(sync_config, records, _action = "create")
55
+ connection_config = sync_config.destination.connection_specification.with_indifferent_access
56
+ api_key = connection_config[:api_key]
57
+ url = sync_config.stream.url
58
+ write_success = 0
59
+ write_failure = 0
60
+ records.each_slice(MAX_CHUNK_SIZE) do |chunk|
61
+ payload = create_payload(chunk)
62
+ response = Multiwoven::Integrations::Core::HttpClient.request(
63
+ url,
64
+ sync_config.stream.request_method,
65
+ payload: payload,
66
+ headers: auth_headers(api_key)
67
+ )
68
+ if success?(response)
69
+ write_success += chunk.size
70
+ else
71
+ write_failure += chunk.size
72
+ end
73
+ rescue StandardError => e
74
+ handle_exception("AIRTABLE:RECORD:WRITE:EXCEPTION", "error", e)
75
+ write_failure += chunk.size
76
+ end
77
+
78
+ tracker = Multiwoven::Integrations::Protocol::TrackingMessage.new(
79
+ success: write_success,
80
+ failed: write_failure
81
+ )
82
+ tracker.to_multiwoven_message
83
+ rescue StandardError => e
84
+ handle_exception("AIRTABLE:WRITE:EXCEPTION", "error", e)
85
+ end
86
+
87
+ private
88
+
89
+ def create_payload(records)
90
+ {
91
+ "records" => records.map do |record|
92
+ {
93
+ "fields" => record
94
+ }
95
+ end
96
+ }
97
+ end
98
+
99
+ def auth_headers(access_token)
100
+ {
101
+ "Accept" => "application/json",
102
+ "Authorization" => "Bearer #{access_token}",
103
+ "Content-Type" => "application/json"
104
+ }
105
+ end
106
+
107
+ def base_id_exists?(bases, base_id)
108
+ return if extract_data(bases).any? { |base| base["id"] == base_id }
109
+
110
+ raise ArgumentError, "base_id not found"
111
+ end
112
+
113
+ def extract_bases(response)
114
+ response_body = extract_body(response)
115
+ response_body["bases"] if response_body
116
+ end
117
+
118
+ def extract_body(response)
119
+ response_body = response.body
120
+ JSON.parse(response_body) if response_body
121
+ end
122
+
123
+ def load_catalog
124
+ read_json(CATALOG_SPEC_PATH)
125
+ end
126
+
127
+ def create_stream(table, base_id, base_name)
128
+ {
129
+ name: "#{base_name}/#{SchemaHelper.clean_name(table["name"])}",
130
+ action: "create",
131
+ method: HTTP_POST,
132
+ url: "#{AIRTABLE_URL_BASE}#{base_id}/#{table["id"]}",
133
+ json_schema: SchemaHelper.get_json_schema(table),
134
+ supported_sync_modes: %w[incremental],
135
+ batch_support: true,
136
+ batch_size: 10
137
+
138
+ }.with_indifferent_access
139
+ end
140
+
141
+ def build_catalog_from_schema(schema, base_id, base_name)
142
+ catalog = build_catalog(load_catalog)
143
+ schema["tables"].each do |table|
144
+ catalog.streams << build_stream(create_stream(table, base_id, base_name))
145
+ end
146
+ catalog
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,6 @@
1
+ {
2
+ "request_rate_limit": 300,
3
+ "request_rate_limit_unit": "minute",
4
+ "request_rate_concurrency": 10,
5
+ "streams": []
6
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "data": {
3
+ "name": "Airtable",
4
+ "title": "airtable",
5
+ "connector_type": "destination",
6
+ "category": "Productivity Tools",
7
+ "documentation_url": "https://docs.multiwoven.com/destinations/productivity-tools/airtable",
8
+ "github_issue_label": "destination-airtable",
9
+ "icon": "icon.svg",
10
+ "license": "MIT",
11
+ "release_stage": "alpha",
12
+ "support_level": "community",
13
+ "tags": ["language:ruby", "multiwoven"]
14
+ }
15
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "documentation_url": "https://docs.multiwoven.com/integrations/destination/airtable",
3
+ "stream_type": "dynamic",
4
+ "connection_specification": {
5
+ "$schema": "http://json-schema.org/draft-07/schema#",
6
+ "title": "Airtable",
7
+ "type": "object",
8
+ "required": ["api_key", "base_id"],
9
+ "properties": {
10
+ "api_key": {
11
+ "type": "string",
12
+ "title": "Personal access token",
13
+ "order": 0
14
+ },
15
+ "base_id": {
16
+ "type": "string",
17
+ "title": "Airtable base id",
18
+ "order": 1
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="27" shape-rendering="geometricPrecision" viewBox="0 0 200 170">
2
+ <path fill="#FCB400" d="M90.039 12.367L24.079 39.66c-3.667 1.519-3.63 6.729.062 8.192l66.235 26.266a24.575 24.575 0 0018.12 0l66.236-26.266c3.69-1.463 3.729-6.673.06-8.191l-65.958-27.294a24.578 24.578 0 00-18.795 0"></path>
3
+ <path fill="#18BFFF" d="M105.312 88.46v65.617c0 3.12 3.147 5.258 6.048 4.108l73.806-28.648a4.418 4.418 0 002.79-4.108V59.813c0-3.121-3.147-5.258-6.048-4.108l-73.806 28.648a4.42 4.42 0 00-2.79 4.108"></path>
4
+ <path fill="#F82B60" d="M88.078 91.846l-21.904 10.576-2.224 1.075-46.238 22.155c-2.93 1.414-6.672-.722-6.672-3.978V60.088c0-1.178.604-2.195 1.414-2.96a5.024 5.024 0 011.12-.84c1.104-.663 2.68-.84 4.02-.31L87.71 83.76c3.564 1.414 3.844 6.408.368 8.087"></path>
5
+ <path fill="rgba(0, 0, 0, 0.25)" d="M88.078 91.846l-21.904 10.576-53.72-45.295a5.024 5.024 0 011.12-.839c1.104-.663 2.68-.84 4.02-.31L87.71 83.76c3.564 1.414 3.844 6.408.368 8.087"></path>
6
+ </svg>
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multiwoven
4
+ module Integrations
5
+ module Destination
6
+ module Airtable
7
+ module SchemaHelper
8
+ include Core::Constants
9
+
10
+ module_function
11
+
12
+ def clean_name(name_str)
13
+ name_str.strip.gsub(" ", "_")
14
+ end
15
+
16
+ def get_json_schema(table)
17
+ fields = table["fields"] || {}
18
+ properties = fields.each_with_object({}) do |field, props|
19
+ name, schema = process_field(field)
20
+ props[name] = schema
21
+ end
22
+
23
+ build_schema(properties)
24
+ end
25
+
26
+ def process_field(field)
27
+ name = clean_name(field.fetch("name", ""))
28
+ original_type = field.fetch("type", "")
29
+ options = field.fetch("options", {})
30
+
31
+ schema = determine_schema(original_type, options)
32
+ [name, schema]
33
+ end
34
+
35
+ def determine_schema(original_type, options)
36
+ if COMPLEX_AIRTABLE_TYPES.keys.include?(original_type)
37
+ complex_type = deep_copy(COMPLEX_AIRTABLE_TYPES[original_type])
38
+ adjust_complex_type(original_type, complex_type, options)
39
+ elsif SIMPLE_AIRTABLE_TYPES.keys.include?(original_type)
40
+ simple_type_schema(original_type, options)
41
+ else
42
+ SCHEMA_TYPES[:STRING]
43
+ end
44
+ end
45
+
46
+ def adjust_complex_type(original_type, complex_type, options)
47
+ exec_type = options.dig("result", "type") || "simpleText"
48
+ if complex_type == SCHEMA_TYPES[:ARRAY_WITH_ANY]
49
+ adjust_array_with_any(original_type, complex_type, exec_type, options)
50
+ else
51
+ complex_type
52
+ end
53
+ end
54
+
55
+ def adjust_array_with_any(original_type, complex_type, exec_type, options)
56
+ if original_type == "formula" && %w[number currency percent duration].include?(exec_type)
57
+ complex_type = SCHEMA_TYPES[:NUMBER]
58
+ elsif original_type == "formula" && ARRAY_FORMULAS.none? { |x| options.fetch("formula", "").start_with?(x) }
59
+ complex_type = SCHEMA_TYPES[:STRING]
60
+ elsif SIMPLE_AIRTABLE_TYPES.keys.include?(exec_type)
61
+ complex_type["items"] = deep_copy(SIMPLE_AIRTABLE_TYPES[exec_type])
62
+ else
63
+ complex_type["items"] = SCHEMA_TYPES[:STRING]
64
+ end
65
+ complex_type
66
+ end
67
+
68
+ def simple_type_schema(original_type, options)
69
+ exec_type = options.dig("result", "type") || original_type
70
+ deep_copy(SIMPLE_AIRTABLE_TYPES[exec_type])
71
+ end
72
+
73
+ def build_schema(properties)
74
+ {
75
+ "$schema" => JSON_SCHEMA_URL,
76
+ "type" => "object",
77
+ "additionalProperties" => true,
78
+ "properties" => properties
79
+ }
80
+ end
81
+
82
+ def deep_copy(object)
83
+ Marshal.load(Marshal.dump(object))
84
+ end
85
+
86
+ SCHEMA_TYPES = {
87
+ STRING: { "type": %w[null string] },
88
+ NUMBER: { "type": %w[null number] },
89
+ BOOLEAN: { "type": %w[null boolean] },
90
+ DATE: { "type": %w[null string], "format": "date" },
91
+ DATETIME: { "type": %w[null string], "format": "date-time" },
92
+ ARRAY_WITH_STRINGS: { "type": %w[null array], "items": { "type": %w[null string] } },
93
+ ARRAY_WITH_ANY: { "type": %w[null array], "items": {} }
94
+ }.freeze.with_indifferent_access
95
+
96
+ SIMPLE_AIRTABLE_TYPES = {
97
+ "multipleAttachments" => SCHEMA_TYPES[:STRING],
98
+ "autoNumber" => SCHEMA_TYPES[:NUMBER],
99
+ "barcode" => SCHEMA_TYPES[:STRING],
100
+ "button" => SCHEMA_TYPES[:STRING],
101
+ "checkbox" => :BOOLEAN,
102
+ "singleCollaborator" => SCHEMA_TYPES[:STRING],
103
+ "count" => SCHEMA_TYPES[:NUMBER],
104
+ "createdBy" => SCHEMA_TYPES[:STRING],
105
+ "createdTime" => SCHEMA_TYPES[:DATETIME],
106
+ "currency" => SCHEMA_TYPES[:NUMBER],
107
+ "email" => SCHEMA_TYPES[:STRING],
108
+ "date" => SCHEMA_TYPES[:DATE],
109
+ "dateTime" => SCHEMA_TYPES[:DATETIME],
110
+ "duration" => SCHEMA_TYPES[:NUMBER],
111
+ "lastModifiedBy" => SCHEMA_TYPES[:STRING],
112
+ "lastModifiedTime" => SCHEMA_TYPES[:DATETIME],
113
+ "multipleRecordLinks" => SCHEMA_TYPES[:ARRAY_WITH_STRINGS],
114
+ "multilineText" => SCHEMA_TYPES[:STRING],
115
+ "multipleCollaborators" => SCHEMA_TYPES[:ARRAY_WITH_STRINGS],
116
+ "multipleSelects" => SCHEMA_TYPES[:ARRAY_WITH_STRINGS],
117
+ "number" => SCHEMA_TYPES[:NUMBER],
118
+ "percent" => SCHEMA_TYPES[:NUMBER],
119
+ "phoneNumber" => SCHEMA_TYPES[:STRING],
120
+ "rating" => SCHEMA_TYPES[:NUMBER],
121
+ "richText" => SCHEMA_TYPES[:STRING],
122
+ "singleLineText" => SCHEMA_TYPES[:STRING],
123
+ "singleSelect" => SCHEMA_TYPES[:STRING],
124
+ "externalSyncSource" => SCHEMA_TYPES[:STRING],
125
+ "url" => SCHEMA_TYPES[:STRING],
126
+ "simpleText" => SCHEMA_TYPES[:STRING]
127
+ }.freeze
128
+
129
+ COMPLEX_AIRTABLE_TYPES = {
130
+ "formula" => SCHEMA_TYPES[:ARRAY_WITH_ANY],
131
+ "lookup" => SCHEMA_TYPES[:ARRAY_WITH_ANY],
132
+ "multipleLookupValues" => SCHEMA_TYPES[:ARRAY_WITH_ANY],
133
+ "rollup" => SCHEMA_TYPES[:ARRAY_WITH_ANY]
134
+ }.freeze.with_indifferent_access
135
+
136
+ ARRAY_FORMULAS = %w[ARRAYCOMPACT ARRAYFLATTEN ARRAYUNIQUE ARRAYSLICE].freeze
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multiwoven
4
+ module Integrations
5
+ module Destination
6
+ module GoogleSheets
7
+ include Multiwoven::Integrations::Core
8
+
9
+ class Client < DestinationConnector # rubocop:disable Metrics/ClassLength
10
+ MAX_CHUNK_SIZE = 10_000
11
+
12
+ def check_connection(connection_config)
13
+ authorize_client(connection_config)
14
+ fetch_google_spread_sheets(connection_config)
15
+ success_status
16
+ rescue StandardError => e
17
+ handle_exception("GOOGLE_SHEETS:CRM:DISCOVER:EXCEPTION", "error", e)
18
+ failure_status(e)
19
+ end
20
+
21
+ def discover(connection_config)
22
+ authorize_client(connection_config)
23
+ spreadsheets = fetch_google_spread_sheets(connection_config)
24
+ catalog = build_catalog_from_spreadsheets(spreadsheets, connection_config)
25
+ catalog.to_multiwoven_message
26
+ rescue StandardError => e
27
+ handle_exception("GOOGLE_SHEETS:CRM:DISCOVER:EXCEPTION", "error", e)
28
+ end
29
+
30
+ def write(sync_config, records, action = "create")
31
+ setup_write_environment(sync_config, action)
32
+ process_record_chunks(records, sync_config)
33
+ rescue StandardError => e
34
+ handle_exception("GOOGLE_SHEETS:CRM:WRITE:EXCEPTION", "error", e)
35
+ end
36
+
37
+ private
38
+
39
+ # To define the level of access granted to your app, you need to identify and declare authorization scopes which is provided by google scopse https://developers.google.com/sheets/api/scopes
40
+ def authorize_client(config)
41
+ credentials = config[:credentials_json]
42
+ @client = Google::Apis::SheetsV4::SheetsService.new
43
+ @client.authorization = Google::Auth::ServiceAccountCredentials.make_creds(
44
+ json_key_io: StringIO.new(credentials.to_json),
45
+ scope: GOOGLE_SHEETS_SCOPE
46
+ )
47
+ end
48
+
49
+ # Extract spreadsheet id from the spreadsheet link and return the metadata for all the sheets
50
+ def fetch_google_spread_sheets(connection_config)
51
+ spreadsheet_id = extract_spreadsheet_id(connection_config[:spreadsheet_link])
52
+ @client.get_spreadsheet(spreadsheet_id)
53
+ end
54
+
55
+ # dynamically builds catalog based on spreadsheet metadata
56
+ def build_catalog_from_spreadsheets(spreadsheet, connection_config)
57
+ catalog = build_catalog(load_catalog)
58
+ @spreadsheet_id = extract_spreadsheet_id(connection_config[:spreadsheet_link])
59
+
60
+ spreadsheet.sheets.each do |sheet|
61
+ process_sheet_for_catalog(sheet, catalog)
62
+ end
63
+
64
+ catalog
65
+ end
66
+
67
+ # Builds catalog for the single spreadsheet based on column name
68
+ def process_sheet_for_catalog(sheet, catalog)
69
+ sheet_name, last_column_index = extract_sheet_properties(sheet)
70
+ column_names = fetch_column_names(sheet_name, last_column_index)
71
+ catalog.streams << generate_json_schema(column_names, sheet_name) if column_names
72
+ end
73
+
74
+ def extract_sheet_properties(sheet)
75
+ [sheet.properties.title, sheet.properties.grid_properties.column_count]
76
+ end
77
+
78
+ def fetch_column_names(sheet_name, last_column_index)
79
+ header_range = generate_header_range(sheet_name, last_column_index)
80
+ spread_sheet_value(header_range)&.flatten
81
+ end
82
+
83
+ def spread_sheet_value(header_range)
84
+ @spread_sheet_value ||= @client.get_spreadsheet_values(@spreadsheet_id, header_range).values
85
+ end
86
+
87
+ def generate_header_range(sheet_name, last_column_index)
88
+ "#{sheet_name}!A1:#{column_index_to_letter(last_column_index)}1"
89
+ end
90
+
91
+ def column_index_to_letter(index)
92
+ ("A".."ZZZ").to_a[index - 1]
93
+ end
94
+
95
+ def generate_json_schema(column_names, sheet_name)
96
+ {
97
+ name: sheet_name,
98
+ action: "create",
99
+ batch_support: true,
100
+ batch_size: 10_000,
101
+ json_schema: generate_properties_schema(column_names),
102
+ supported_sync_modes: %w[incremental]
103
+ }.with_indifferent_access
104
+ end
105
+
106
+ def generate_properties_schema(column_names)
107
+ properties = column_names.each_with_object({}) do |field, props|
108
+ props[field] = { "type" => "string" }
109
+ end
110
+
111
+ { "$schema" => JSON_SCHEMA_URL, "type" => "object", "properties" => properties }
112
+ end
113
+
114
+ def setup_write_environment(sync_config, action)
115
+ @action = sync_config.stream.action || action
116
+ @spreadsheet_id = extract_spreadsheet_id(sync_config.destination.connection_specification[:spreadsheet_link])
117
+ authorize_client(sync_config.destination.connection_specification)
118
+ end
119
+
120
+ def extract_spreadsheet_id(link)
121
+ link[GOOGLE_SPREADSHEET_ID_REGEX, 1] || link
122
+ end
123
+
124
+ # Batch has a limit of sending 2MB data. So creating a chunk of records to meet that limit
125
+ def process_record_chunks(records, sync_config)
126
+ write_success = 0
127
+ write_failure = 0
128
+
129
+ records.each_slice(MAX_CHUNK_SIZE) do |chunk|
130
+ values = prepare_chunk_values(chunk, sync_config.stream)
131
+ update_sheet_values(values, sync_config.stream.name)
132
+ write_success += values.size
133
+ rescue StandardError => e
134
+ handle_exception("GOOGLE_SHEETS:RECORD:WRITE:EXCEPTION", "error", e)
135
+ write_failure += chunk.size
136
+ end
137
+
138
+ tracking_message(write_success, write_failure)
139
+ end
140
+
141
+ # We need to format the data to adhere to google sheets API format. This converts the sync mapped data to 2D array format expected by google sheets API
142
+ def prepare_chunk_values(chunk, stream)
143
+ last_column_index = spread_sheet_value(stream.name).count
144
+ fields = fetch_column_names(stream.name, last_column_index)
145
+
146
+ chunk.map do |row|
147
+ row_values = Array.new(fields.size, nil)
148
+ row.each do |key, value|
149
+ index = fields.index(key.to_s)
150
+ row_values[index] = value if index
151
+ end
152
+ row_values
153
+ end
154
+ end
155
+
156
+ def update_sheet_values(values, stream_name)
157
+ row_count = spread_sheet_value(stream_name).count
158
+ range = "#{stream_name}!A#{row_count + 1}"
159
+ value_range = Google::Apis::SheetsV4::ValueRange.new(range: range, values: values)
160
+
161
+ batch_update_request = Google::Apis::SheetsV4::BatchUpdateValuesRequest.new(
162
+ value_input_option: "RAW",
163
+ data: [value_range]
164
+ )
165
+
166
+ # TODO: Remove & this is added for the test to pass we need
167
+ @client&.batch_update_values(@spreadsheet_id, batch_update_request)
168
+ end
169
+
170
+ def load_catalog
171
+ read_json(CATALOG_SPEC_PATH)
172
+ end
173
+
174
+ def tracking_message(success, failure)
175
+ Multiwoven::Integrations::Protocol::TrackingMessage.new(
176
+ success: success, failed: failure
177
+ ).to_multiwoven_message
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,6 @@
1
+ {
2
+ "request_rate_limit": 300,
3
+ "request_rate_limit_unit": "minute",
4
+ "request_rate_concurrency": 10,
5
+ "streams": []
6
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "data": {
3
+ "name": "GoogleSheets",
4
+ "title": "Google Sheets",
5
+ "connector_type": "destination",
6
+ "category": "Productivity Tools",
7
+ "documentation_url": "https://docs.multiwoven.com/destinations/productivity-tools/google-sheets-service-account",
8
+ "github_issue_label": "destination-google-sheets",
9
+ "icon": "icon.svg",
10
+ "license": "MIT",
11
+ "release_stage": "alpha",
12
+ "support_level": "community",
13
+ "tags": ["language:ruby", "multiwoven"]
14
+ }
15
+ }
@@ -0,0 +1,74 @@
1
+ {
2
+ "documentation_url": "https://docs.multiwoven.com/destinations/productivity-tools/google-sheets-service-account",
3
+ "stream_type": "dynamic",
4
+ "connection_specification": {
5
+ "$schema": "http://json-schema.org/draft-07/schema#",
6
+ "title": "Google Sheets",
7
+ "type": "object",
8
+ "required": ["credentials_json"],
9
+ "properties": {
10
+ "spreadsheet_link": {
11
+ "type": "string"
12
+ },
13
+ "credentials_json": {
14
+ "type": "object",
15
+ "description": "You can get the keys from the Google Cloud web console. First, go to the IAM page and select Service Accounts from the left menu. Next, locate your service account in the list, click on its Keys tab, and then click Add Key. Lastly, click Create new key and select JSON.",
16
+ "title": "",
17
+ "properties": {
18
+ "type": {
19
+ "type": "string",
20
+ "enum": ["service_account"]
21
+ },
22
+ "project_id": {
23
+ "type": "string"
24
+ },
25
+ "private_key_id": {
26
+ "type": "string"
27
+ },
28
+ "private_key": {
29
+ "type": "string"
30
+ },
31
+ "client_email": {
32
+ "type": "string",
33
+ "format": "email"
34
+ },
35
+ "client_id": {
36
+ "type": "string"
37
+ },
38
+ "auth_uri": {
39
+ "type": "string",
40
+ "format": "uri"
41
+ },
42
+ "token_uri": {
43
+ "type": "string",
44
+ "format": "uri"
45
+ },
46
+ "auth_provider_x509_cert_url": {
47
+ "type": "string",
48
+ "format": "uri"
49
+ },
50
+ "client_x509_cert_url": {
51
+ "type": "string",
52
+ "format": "uri"
53
+ },
54
+ "universe_domain": {
55
+ "type": "string"
56
+ }
57
+ },
58
+ "required": [
59
+ "type",
60
+ "project_id",
61
+ "private_key_id",
62
+ "private_key",
63
+ "client_email",
64
+ "client_id",
65
+ "auth_uri",
66
+ "token_uri",
67
+ "auth_provider_x509_cert_url",
68
+ "client_x509_cert_url",
69
+ "universe_domain"
70
+ ]
71
+ }
72
+ }
73
+ }
74
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 242423 333333" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd"><defs><mask id="c"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="200294" y1="91174.8" x2="200294" y2="176113"><stop offset="0" stop-opacity=".02" stop-color="#fff"/><stop offset="1" stop-opacity=".2" stop-color="#fff"/></linearGradient><path fill="url(#a)" d="M158015 84111h84558v99065h-84558z"/></mask><mask id="e"><radialGradient id="b" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="0" fx="0" fy="0"><stop offset="0" stop-opacity="0" stop-color="#fff"/><stop offset="1" stop-opacity=".098" stop-color="#fff"/></radialGradient><path fill="url(#b)" d="M-150-150h242723v333633H-150z"/></mask><radialGradient id="f" gradientUnits="userSpaceOnUse" cx="9696.85" cy="10000.4" r="166667" fx="9696.85" fy="10000.4"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff"/></radialGradient><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="200294" y1="95125.2" x2="200294" y2="172162"><stop offset="0" stop-color="#263138"/><stop offset="1" stop-color="#263138"/></linearGradient></defs><g fill-rule="nonzero"><path d="M151513 0H22729C10227 0 1 10227 1 22728v287877c0 12505 10227 22728 22728 22728h196966c12505 0 22728-10224 22728-22728V90911l-53028-37880L151513 0z" fill="#0f9c57"/><path d="M60606 162880v109853h121216V162880H60606zm53032 94698H75757v-18938h37881v18938zm0-30301H75757v-18946h37881v18946zm0-30310H75757v-18936h37881v18936zm53030 60611h-37884v-18938h37884v18938zm0-30301h-37884v-18946h37884v18946zm0-30310h-37884v-18936h37884v18936z" fill="#f0f0f0"/><path mask="url(#c)" fill="url(#d)" d="M158165 84261l84258 84245V90911z"/><path d="M151513 0v68184c0 12557 10173 22727 22727 22727h68183L151513 0z" fill="#87cdac"/><path d="M22728 0C10226 0 0 10227 0 22729v1893C0 12123 10227 1894 22728 1894h128784V1H22728z" fill="#fff" fill-opacity=".2"/><path d="M219694 331443H22728C10226 331443 0 321213 0 308715v1890c0 12505 10227 22728 22728 22728h196966c12505 0 22728-10224 22728-22728v-1890c0 12499-10224 22728-22728 22728z" fill="#263138" fill-opacity=".2"/><path d="M174239 90911c-12554 0-22727-10170-22727-22727v1893c0 12557 10173 22727 22727 22727h68183v-1893h-68183z" fill="#263138" fill-opacity=".102"/><path d="M151513 0H22729C10227 0 1 10227 1 22729v287876c0 12505 10227 22728 22728 22728h196966c12505 0 22728-10224 22728-22728V90911L151513 0z" mask="url(#e)" fill="url(#f)"/></g></svg>
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Multiwoven
4
4
  module Integrations
5
- VERSION = "0.1.33"
5
+ VERSION = "0.1.35"
6
6
 
7
7
  ENABLED_SOURCES = %w[
8
8
  Snowflake
@@ -18,6 +18,8 @@ module Multiwoven
18
18
  FacebookCustomAudience
19
19
  Slack
20
20
  Hubspot
21
+ GoogleSheets
22
+ Airtable
21
23
  ].freeze
22
24
  end
23
25
  end
@@ -16,6 +16,8 @@ require "slack-ruby-client"
16
16
  require "git"
17
17
  require "ruby-limiter"
18
18
  require "hubspot-api-client"
19
+ require "google/apis/sheets_v4"
20
+ require "stringio"
19
21
 
20
22
  # Service
21
23
  require_relative "integrations/config"
@@ -45,6 +47,8 @@ require_relative "integrations/destination/salesforce_crm/client"
45
47
  require_relative "integrations/destination/facebook_custom_audience/client"
46
48
  require_relative "integrations/destination/slack/client"
47
49
  require_relative "integrations/destination/hubspot/client"
50
+ require_relative "integrations/destination/google_sheets/client"
51
+ require_relative "integrations/destination/airtable/client"
48
52
 
49
53
  module Multiwoven
50
54
  module Integrations
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.1.33
4
+ version: 0.1.35
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: 2024-03-07 00:00:00.000000000 Z
11
+ date: 2024-03-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -330,11 +330,22 @@ files:
330
330
  - lib/multiwoven/integrations/core/rate_limiter.rb
331
331
  - lib/multiwoven/integrations/core/source_connector.rb
332
332
  - lib/multiwoven/integrations/core/utils.rb
333
+ - lib/multiwoven/integrations/destination/airtable/client.rb
334
+ - lib/multiwoven/integrations/destination/airtable/config/catalog.json
335
+ - lib/multiwoven/integrations/destination/airtable/config/meta.json
336
+ - lib/multiwoven/integrations/destination/airtable/config/spec.json
337
+ - lib/multiwoven/integrations/destination/airtable/icon.svg
338
+ - lib/multiwoven/integrations/destination/airtable/schema_helper.rb
333
339
  - lib/multiwoven/integrations/destination/facebook_custom_audience/client.rb
334
340
  - lib/multiwoven/integrations/destination/facebook_custom_audience/config/catalog.json
335
341
  - lib/multiwoven/integrations/destination/facebook_custom_audience/config/meta.json
336
342
  - lib/multiwoven/integrations/destination/facebook_custom_audience/config/spec.json
337
343
  - lib/multiwoven/integrations/destination/facebook_custom_audience/icon.svg
344
+ - lib/multiwoven/integrations/destination/google_sheets/client.rb
345
+ - lib/multiwoven/integrations/destination/google_sheets/config/catalog.json
346
+ - lib/multiwoven/integrations/destination/google_sheets/config/meta.json
347
+ - lib/multiwoven/integrations/destination/google_sheets/config/spec.json
348
+ - lib/multiwoven/integrations/destination/google_sheets/icon.svg
338
349
  - lib/multiwoven/integrations/destination/hubspot/client.rb
339
350
  - lib/multiwoven/integrations/destination/hubspot/config/catalog.json
340
351
  - lib/multiwoven/integrations/destination/hubspot/config/meta.json