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 +4 -4
- data/lib/multiwoven/integrations/core/base_connector.rb +8 -0
- data/lib/multiwoven/integrations/core/constants.rb +10 -0
- data/lib/multiwoven/integrations/destination/airtable/client.rb +152 -0
- data/lib/multiwoven/integrations/destination/airtable/config/catalog.json +6 -0
- data/lib/multiwoven/integrations/destination/airtable/config/meta.json +15 -0
- data/lib/multiwoven/integrations/destination/airtable/config/spec.json +22 -0
- data/lib/multiwoven/integrations/destination/airtable/icon.svg +6 -0
- data/lib/multiwoven/integrations/destination/airtable/schema_helper.rb +141 -0
- data/lib/multiwoven/integrations/destination/google_sheets/client.rb +183 -0
- data/lib/multiwoven/integrations/destination/google_sheets/config/catalog.json +6 -0
- data/lib/multiwoven/integrations/destination/google_sheets/config/meta.json +15 -0
- data/lib/multiwoven/integrations/destination/google_sheets/config/spec.json +74 -0
- data/lib/multiwoven/integrations/destination/google_sheets/icon.svg +1 -0
- data/lib/multiwoven/integrations/rollout.rb +3 -1
- data/lib/multiwoven/integrations.rb +4 -0
- metadata +13 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 154bbe9f4e36d95f4cd7fdb8094077e2ff724a3eef95ec76264950747d619fe2
|
4
|
+
data.tar.gz: b3da4210a997a0143b637a32d4d4b0dc6ba741742e5a1f73a3a465c8187479de
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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,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,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.
|
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.
|
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-
|
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
|