multiwoven-integrations 0.39.3 → 0.40.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/core/oauth_client_credentials.rb +143 -14
- data/lib/multiwoven/integrations/destination/http/config/spec.json +54 -2
- data/lib/multiwoven/integrations/rollout.rb +2 -1
- data/lib/multiwoven/integrations/source/epic_fhir/client.rb +350 -0
- data/lib/multiwoven/integrations/source/epic_fhir/config/meta.json +16 -0
- data/lib/multiwoven/integrations/source/epic_fhir/config/spec.json +112 -0
- data/lib/multiwoven/integrations/source/epic_fhir/icon.svg +5 -0
- data/lib/multiwoven/integrations/source/http/config/spec.json +54 -2
- data/lib/multiwoven/integrations.rb +1 -0
- data/multiwoven-integrations.gemspec +1 -0
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e35c4cdc96ef297f6f538368d5c14c5fb20f26e81137801049350d9c0ccc51aa
|
|
4
|
+
data.tar.gz: 17aa07d139d7338a09ff7e20529245e310f6ce18b448e7f8812837df413b3f78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f77986008b3370e138389a8918e4f1250781efc130299bbc078c8fa38a08f26b0487f1247208ac759c7c02e87bd6fcc9dd953eb7be2acf43983478ad0f23d39
|
|
7
|
+
data.tar.gz: a4e749c3b6d1fc27e9a8eb08d6659f3185f40f4c5d7154bee3392a666c3e83058453380fc0ecf5c7424e869c4ba623f4e69b8b1088f78e823c8ede48668920e6
|
|
@@ -1,26 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "jwt"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
3
7
|
module Multiwoven
|
|
4
8
|
module Integrations::Core
|
|
5
|
-
# Shared OAuth2 client_credentials
|
|
9
|
+
# Shared OAuth2 client_credentials flows. Include in a connector client to:
|
|
6
10
|
# * inject `Authorization: Bearer <token>` when connection_config[:auth_type]
|
|
7
|
-
# is `oauth_client_credentials`
|
|
11
|
+
# is `oauth_client_credentials` or `oauth_private_key_jwt`
|
|
8
12
|
# * cache the token in the connector's `configuration` JSON and refresh it
|
|
9
13
|
# shortly before expiry
|
|
10
14
|
#
|
|
15
|
+
# `oauth_client_credentials` — classic client_id + client_secret.
|
|
16
|
+
# `oauth_private_key_jwt` — client_assertion JWT signed with an RSA private
|
|
17
|
+
# key (Epic Backend Services / SMART confidential asymmetric).
|
|
18
|
+
#
|
|
11
19
|
# The including class is expected to set `@connector_instance` (an object
|
|
12
20
|
# responding to `configuration` and `update!`) before making requests that
|
|
13
21
|
# should benefit from the cache. Without it, a fresh token is fetched every
|
|
14
22
|
# call — safe but wasteful.
|
|
15
23
|
module OauthClientCredentials
|
|
16
24
|
AUTH_TYPE_OAUTH_CLIENT_CREDENTIALS = "oauth_client_credentials"
|
|
25
|
+
AUTH_TYPE_PRIVATE_KEY_JWT = "oauth_private_key_jwt"
|
|
26
|
+
OAUTH_AUTH_TYPES = [AUTH_TYPE_OAUTH_CLIENT_CREDENTIALS, AUTH_TYPE_PRIVATE_KEY_JWT].freeze
|
|
27
|
+
|
|
28
|
+
CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
29
|
+
# Epic / SMART Backend Services require RS384; RS256 is kept for other IdPs.
|
|
30
|
+
DEFAULT_JWT_ALGORITHM = "RS384"
|
|
31
|
+
ALLOWED_JWT_ALGORITHMS = %w[RS384 RS256].freeze
|
|
32
|
+
JWT_ASSERTION_LIFETIME_SECONDS = 300
|
|
17
33
|
# Refresh access tokens this many seconds before their advertised expiry,
|
|
18
34
|
# so a token that expires mid-request doesn't leave the connector.
|
|
19
35
|
TOKEN_EXPIRY_BUFFER_SECONDS = 300
|
|
20
36
|
|
|
21
37
|
def build_headers(connection_config)
|
|
22
38
|
headers = (connection_config[:headers] || {}).to_h.dup
|
|
23
|
-
return headers unless connection_config[:auth_type]
|
|
39
|
+
return headers unless oauth_auth_type?(connection_config[:auth_type])
|
|
24
40
|
|
|
25
41
|
headers["Authorization"] = "Bearer #{ensure_oauth_token(connection_config)}"
|
|
26
42
|
headers
|
|
@@ -35,6 +51,10 @@ module Multiwoven
|
|
|
35
51
|
|
|
36
52
|
private
|
|
37
53
|
|
|
54
|
+
def oauth_auth_type?(auth_type)
|
|
55
|
+
OAUTH_AUTH_TYPES.include?(auth_type)
|
|
56
|
+
end
|
|
57
|
+
|
|
38
58
|
def cached_oauth_token
|
|
39
59
|
config = connector_configuration
|
|
40
60
|
return nil unless config
|
|
@@ -50,12 +70,12 @@ module Multiwoven
|
|
|
50
70
|
end
|
|
51
71
|
|
|
52
72
|
def fetch_and_cache_oauth_token(connection_config)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
response = case connection_config[:auth_type]
|
|
74
|
+
when AUTH_TYPE_PRIVATE_KEY_JWT
|
|
75
|
+
post_private_key_jwt_token_request(connection_config)
|
|
76
|
+
else
|
|
77
|
+
post_client_secret_token_request(connection_config)
|
|
78
|
+
end
|
|
59
79
|
raise "OAuth token request failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
60
80
|
|
|
61
81
|
body = JSON.parse(response.body)
|
|
@@ -67,17 +87,126 @@ module Multiwoven
|
|
|
67
87
|
access_token
|
|
68
88
|
end
|
|
69
89
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
def post_client_secret_token_request(connection_config)
|
|
91
|
+
token_url = connection_config[:token_url]
|
|
92
|
+
client_id = connection_config[:client_id]
|
|
93
|
+
client_secret = connection_config[:client_secret]
|
|
94
|
+
if token_url.to_s.empty? || client_id.to_s.empty? || client_secret.to_s.empty?
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"OAuth token_url, client_id, and client_secret are required when auth_type is " \
|
|
97
|
+
"#{AUTH_TYPE_OAUTH_CLIENT_CREDENTIALS}"
|
|
98
|
+
end
|
|
74
99
|
|
|
75
100
|
form = {
|
|
76
101
|
"grant_type" => "client_credentials",
|
|
77
102
|
"client_id" => client_id,
|
|
78
103
|
"client_secret" => client_secret
|
|
79
104
|
}
|
|
80
|
-
form["scope"] = scope unless scope.to_s.empty?
|
|
105
|
+
form["scope"] = connection_config[:scope] unless connection_config[:scope].to_s.empty?
|
|
106
|
+
post_form(token_url, form)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def post_private_key_jwt_token_request(connection_config)
|
|
110
|
+
token_url = connection_config[:token_url]
|
|
111
|
+
client_id = connection_config[:client_id]
|
|
112
|
+
private_key = connection_config[:private_key]
|
|
113
|
+
kid = connection_config[:kid]
|
|
114
|
+
if token_url.to_s.empty? || client_id.to_s.empty? || private_key.to_s.empty? || kid.to_s.empty?
|
|
115
|
+
raise ArgumentError,
|
|
116
|
+
"OAuth token_url, client_id, private_key, and kid are required when auth_type is " \
|
|
117
|
+
"#{AUTH_TYPE_PRIVATE_KEY_JWT}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
assertion = build_client_assertion(connection_config)
|
|
121
|
+
form = {
|
|
122
|
+
"grant_type" => "client_credentials",
|
|
123
|
+
"client_id" => client_id,
|
|
124
|
+
"client_assertion_type" => CLIENT_ASSERTION_TYPE,
|
|
125
|
+
"client_assertion" => assertion
|
|
126
|
+
}
|
|
127
|
+
form["scope"] = connection_config[:scope] unless connection_config[:scope].to_s.empty?
|
|
128
|
+
post_form(token_url, form)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_client_assertion(connection_config)
|
|
132
|
+
token_url = connection_config[:token_url].to_s
|
|
133
|
+
client_id = connection_config[:client_id].to_s
|
|
134
|
+
audience = connection_config[:audience].to_s
|
|
135
|
+
audience = token_url if audience.empty?
|
|
136
|
+
algorithm = resolve_jwt_algorithm(connection_config[:algorithm])
|
|
137
|
+
kid = connection_config[:kid].to_s
|
|
138
|
+
|
|
139
|
+
now = Time.now.to_i
|
|
140
|
+
payload = {
|
|
141
|
+
"iss" => client_id,
|
|
142
|
+
"sub" => client_id,
|
|
143
|
+
"aud" => audience,
|
|
144
|
+
"jti" => SecureRandom.uuid,
|
|
145
|
+
"iat" => now,
|
|
146
|
+
"nbf" => now,
|
|
147
|
+
"exp" => now + JWT_ASSERTION_LIFETIME_SECONDS
|
|
148
|
+
}
|
|
149
|
+
headers = { "typ" => "JWT", "kid" => kid }
|
|
150
|
+
|
|
151
|
+
key = load_private_key(connection_config[:private_key])
|
|
152
|
+
JWT.encode(payload, key, algorithm, headers)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def resolve_jwt_algorithm(algorithm)
|
|
156
|
+
algorithm = algorithm.to_s
|
|
157
|
+
algorithm = DEFAULT_JWT_ALGORITHM if algorithm.empty?
|
|
158
|
+
return algorithm if ALLOWED_JWT_ALGORITHMS.include?(algorithm)
|
|
159
|
+
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"Unsupported OAuth JWT algorithm '#{algorithm}'. " \
|
|
162
|
+
"Allowed: #{ALLOWED_JWT_ALGORITHMS.join(", ")}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def load_private_key(private_key)
|
|
166
|
+
key = begin
|
|
167
|
+
OpenSSL::PKey.read(normalize_pem(private_key))
|
|
168
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError => e
|
|
169
|
+
raise ArgumentError, "Invalid OAuth private_key: #{e.message}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
raise ArgumentError, "OAuth private_key must be an RSA private key in PEM format" unless key.is_a?(OpenSSL::PKey::RSA) && key.private?
|
|
173
|
+
|
|
174
|
+
key
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# JSON/UI fields often store PEMs with escaped newlines ("\n") or as a single
|
|
178
|
+
# line with spaces. OpenSSL::PKey.read rejects both; normalize before parsing.
|
|
179
|
+
def normalize_pem(private_key)
|
|
180
|
+
pem = private_key.to_s.strip
|
|
181
|
+
pem = pem.gsub('\r\n', "\n").gsub('\n', "\n").gsub('\r', "\n")
|
|
182
|
+
|
|
183
|
+
return pem if pem.include?("\n")
|
|
184
|
+
|
|
185
|
+
labels = [
|
|
186
|
+
"RSA PRIVATE KEY",
|
|
187
|
+
"PRIVATE KEY"
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
labels.each do |label|
|
|
191
|
+
begin_marker = "-----BEGIN #{label}-----"
|
|
192
|
+
end_marker = "-----END #{label}-----"
|
|
193
|
+
|
|
194
|
+
next unless pem.start_with?(begin_marker) && pem.end_with?(end_marker)
|
|
195
|
+
|
|
196
|
+
body = pem.delete_prefix(begin_marker)
|
|
197
|
+
.delete_suffix(end_marker)
|
|
198
|
+
.delete(" ")
|
|
199
|
+
|
|
200
|
+
return "#{begin_marker}\n#{body.scan(/.{1,64}/).join("\n")}\n#{end_marker}\n"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
pem
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def post_form(token_url, form)
|
|
207
|
+
uri = URI(token_url)
|
|
208
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
209
|
+
http.use_ssl = (uri.scheme == "https")
|
|
81
210
|
|
|
82
211
|
request = Net::HTTP::Post.new(uri)
|
|
83
212
|
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"auth_type": {
|
|
16
16
|
"type": "string",
|
|
17
17
|
"title": "Authentication Type",
|
|
18
|
-
"description": "Select
|
|
19
|
-
"enum": ["none", "oauth_client_credentials"],
|
|
18
|
+
"description": "Select an OAuth2 option to have Multiwoven fetch and refresh an access token automatically. Use 'oauth_private_key_jwt' for Epic Backend Services / SMART confidential asymmetric clients.",
|
|
19
|
+
"enum": ["none", "oauth_client_credentials", "oauth_private_key_jwt"],
|
|
20
20
|
"default": "none",
|
|
21
21
|
"order": 2
|
|
22
22
|
},
|
|
@@ -65,6 +65,58 @@
|
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"required": ["token_url", "client_id", "client_secret"]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"properties": {
|
|
71
|
+
"auth_type": { "enum": ["oauth_private_key_jwt"] },
|
|
72
|
+
"token_url": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"title": "OAuth Token URL",
|
|
75
|
+
"description": "E.g. https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
|
|
76
|
+
"order": 3
|
|
77
|
+
},
|
|
78
|
+
"client_id": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"multiwoven_secret": true,
|
|
81
|
+
"title": "OAuth Client ID",
|
|
82
|
+
"description": "Non-production or production Client ID from Epic app registration.",
|
|
83
|
+
"order": 4
|
|
84
|
+
},
|
|
85
|
+
"private_key": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"multiwoven_secret": true,
|
|
88
|
+
"title": "RSA Private Key (PEM)",
|
|
89
|
+
"description": "PEM-encoded RSA private key used to sign the client_assertion JWT. The matching public key must be published at your registered JWK Set URL.",
|
|
90
|
+
"order": 5
|
|
91
|
+
},
|
|
92
|
+
"kid": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"title": "Key ID (kid)",
|
|
95
|
+
"description": "Must match the kid of the public key in your JWK Set.",
|
|
96
|
+
"order": 6
|
|
97
|
+
},
|
|
98
|
+
"algorithm": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"title": "JWT Signing Algorithm",
|
|
101
|
+
"description": "Epic Backend Services require RS384.",
|
|
102
|
+
"enum": ["RS384", "RS256"],
|
|
103
|
+
"default": "RS384",
|
|
104
|
+
"order": 7
|
|
105
|
+
},
|
|
106
|
+
"audience": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"title": "JWT Audience (aud)",
|
|
109
|
+
"description": "Optional. Defaults to the Token URL. Epic expects the token endpoint URL.",
|
|
110
|
+
"order": 8
|
|
111
|
+
},
|
|
112
|
+
"scope": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"title": "OAuth Scope",
|
|
115
|
+
"description": "Optional. E.g. system/Patient.read system/Observation.read",
|
|
116
|
+
"order": 9
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
"required": ["token_url", "client_id", "private_key", "kid"]
|
|
68
120
|
}
|
|
69
121
|
]
|
|
70
122
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Multiwoven
|
|
4
4
|
module Integrations
|
|
5
|
-
VERSION = "0.
|
|
5
|
+
VERSION = "0.40.0"
|
|
6
6
|
|
|
7
7
|
ENABLED_SOURCES = %w[
|
|
8
8
|
Snowflake
|
|
@@ -37,6 +37,7 @@ module Multiwoven
|
|
|
37
37
|
Aisquared
|
|
38
38
|
OneDrive
|
|
39
39
|
MicrosoftDynamics
|
|
40
|
+
EpicFhir
|
|
40
41
|
].freeze
|
|
41
42
|
|
|
42
43
|
ENABLED_DESTINATIONS = %w[
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Multiwoven::Integrations::Source
|
|
4
|
+
module EpicFhir
|
|
5
|
+
include Multiwoven::Integrations::Core
|
|
6
|
+
|
|
7
|
+
FHIR_ACCEPT = "application/fhir+json"
|
|
8
|
+
NDJSON_ACCEPT = "application/fhir+ndjson"
|
|
9
|
+
DEFAULT_EXPORT_POLL_INTERVAL = 5
|
|
10
|
+
DEFAULT_EXPORT_TIMEOUT = 120
|
|
11
|
+
REQUIRED_CONFIG_KEYS = %w[fhir_base_url token_url client_id private_key kid export_group_id].freeze
|
|
12
|
+
DEFAULT_RESOURCES = %w[
|
|
13
|
+
AllergyIntolerance
|
|
14
|
+
Appointment
|
|
15
|
+
CarePlan
|
|
16
|
+
CareTeam
|
|
17
|
+
Condition
|
|
18
|
+
Consent
|
|
19
|
+
Device
|
|
20
|
+
DiagnosticReport
|
|
21
|
+
DocumentReference
|
|
22
|
+
Encounter
|
|
23
|
+
EpisodeOfCare
|
|
24
|
+
Goal
|
|
25
|
+
Immunization
|
|
26
|
+
List
|
|
27
|
+
MedicationRequest
|
|
28
|
+
Observation
|
|
29
|
+
Patient
|
|
30
|
+
Procedure
|
|
31
|
+
ServiceRequest
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
class Client < SourceConnector
|
|
35
|
+
include Multiwoven::Integrations::Core::OauthClientCredentials
|
|
36
|
+
|
|
37
|
+
def check_connection(connection_config)
|
|
38
|
+
connection_config = prepare_config(connection_config)
|
|
39
|
+
validate_config!(connection_config)
|
|
40
|
+
build_headers(connection_config)
|
|
41
|
+
success_status
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
handle_exception(e, {
|
|
44
|
+
context: "EPIC_FHIR:CHECK_CONNECTION:EXCEPTION",
|
|
45
|
+
type: "error"
|
|
46
|
+
})
|
|
47
|
+
failure_status(e)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def discover(connection_config)
|
|
51
|
+
connection_config = prepare_config(connection_config)
|
|
52
|
+
validate_config!(connection_config)
|
|
53
|
+
streams = discover_resource_types(connection_config).map { |resource_type| create_stream(resource_type) }
|
|
54
|
+
|
|
55
|
+
Catalog.new(streams: streams).to_multiwoven_message
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
handle_exception(e, {
|
|
58
|
+
context: "EPIC_FHIR:DISCOVER:EXCEPTION",
|
|
59
|
+
type: "error"
|
|
60
|
+
})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read(sync_config)
|
|
64
|
+
connection_config = prepare_config(sync_config&.source&.connection_specification)
|
|
65
|
+
validate_config!(connection_config)
|
|
66
|
+
@connector_instance = sync_config&.source&.connector_instance
|
|
67
|
+
|
|
68
|
+
sql_query = sync_config.model.query
|
|
69
|
+
sql_query = batched_query(sql_query, sync_config.limit, sync_config.offset) if
|
|
70
|
+
sync_config.limit.present? || sync_config.offset.present?
|
|
71
|
+
query(connection_config, sql_query)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
handle_exception(e, {
|
|
74
|
+
context: "EPIC_FHIR:READ:EXCEPTION",
|
|
75
|
+
type: "error",
|
|
76
|
+
sync_id: sync_config.sync_id,
|
|
77
|
+
sync_run_id: sync_config.sync_run_id
|
|
78
|
+
})
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def prepare_config(config)
|
|
84
|
+
config = config.to_unsafe_h if config.respond_to?(:to_unsafe_h)
|
|
85
|
+
config = {} unless config.is_a?(Hash)
|
|
86
|
+
config.with_indifferent_access.tap do |conf|
|
|
87
|
+
conf[:auth_type] = AUTH_TYPE_PRIVATE_KEY_JWT
|
|
88
|
+
conf[:fhir_base_url] = conf[:fhir_base_url].to_s.sub(%r{/+\z}, "")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_config!(config)
|
|
93
|
+
missing = REQUIRED_CONFIG_KEYS.reject { |key| config[key].to_s.strip.present? }
|
|
94
|
+
raise ArgumentError, "Missing required Epic FHIR configuration: #{missing.join(", ")}" if missing.any?
|
|
95
|
+
|
|
96
|
+
validate_export_timeout!(config)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_export_timeout!(config)
|
|
100
|
+
raw = config[:export_timeout]
|
|
101
|
+
return if raw.to_s.strip.empty?
|
|
102
|
+
|
|
103
|
+
value = raw.to_f
|
|
104
|
+
raise ArgumentError, "Export timeout must be greater than 0" unless value.positive?
|
|
105
|
+
return if value <= DEFAULT_EXPORT_TIMEOUT
|
|
106
|
+
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"Export timeout must be equal to or less than #{DEFAULT_EXPORT_TIMEOUT}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Model preview passes this value back into #query.
|
|
112
|
+
def create_connection(connection_config)
|
|
113
|
+
prepare_config(connection_config)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def query(connection_config, sql_query)
|
|
117
|
+
connection_config = prepare_config(connection_config)
|
|
118
|
+
validate_config!(connection_config)
|
|
119
|
+
resource_type, limit, offset = parse_sql_query(sql_query)
|
|
120
|
+
|
|
121
|
+
bulk_export_resources(connection_config, resource_type, limit: limit, offset: offset).map do |resource|
|
|
122
|
+
RecordMessage.new(data: displayable_resource(resource), emitted_at: Time.now.to_i).to_multiwoven_message
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def displayable_resource(resource)
|
|
127
|
+
resource.to_h.transform_values do |value|
|
|
128
|
+
value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def discover_resource_types(connection_config)
|
|
133
|
+
configured = configured_resources(connection_config)
|
|
134
|
+
configured.any? ? configured : DEFAULT_RESOURCES
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def configured_resources(connection_config)
|
|
138
|
+
raw = connection_config[:resources]
|
|
139
|
+
values = raw.is_a?(Array) ? raw : raw.to_s.split(",")
|
|
140
|
+
values.map(&:to_s).map(&:strip).reject(&:empty?).select { |value| valid_resource_type?(value) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def create_stream(resource_type)
|
|
144
|
+
Multiwoven::Integrations::Protocol::Stream.new(
|
|
145
|
+
name: resource_type,
|
|
146
|
+
action: StreamAction["fetch"],
|
|
147
|
+
json_schema: base_resource_schema,
|
|
148
|
+
supported_sync_modes: %w[full_refresh],
|
|
149
|
+
source_defined_primary_key: [["id"]]
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Shared across resource types: FHIR Resource / DomainResource keys are
|
|
154
|
+
# always present (nested values become JSON strings in #displayable_resource).
|
|
155
|
+
# Type-specific fields (e.g. Patient.name) also appear at read time, so the
|
|
156
|
+
# schema allows additional string properties instead of pretending the
|
|
157
|
+
# catalog only has id + resourceType.
|
|
158
|
+
def base_resource_schema
|
|
159
|
+
schema = convert_to_json_schema(
|
|
160
|
+
%w[
|
|
161
|
+
id
|
|
162
|
+
resourceType
|
|
163
|
+
meta
|
|
164
|
+
implicitRules
|
|
165
|
+
language
|
|
166
|
+
text
|
|
167
|
+
contained
|
|
168
|
+
extension
|
|
169
|
+
modifierExtension
|
|
170
|
+
].map { |column_name| { column_name: column_name, type: "string" } }
|
|
171
|
+
)
|
|
172
|
+
schema["additionalProperties"] = true
|
|
173
|
+
schema
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The manifest and records are cached for batched LIMIT/OFFSET reads during this client run.
|
|
177
|
+
def bulk_export_resources(connection_config, resource_type, limit:, offset:)
|
|
178
|
+
return [] if limit&.zero?
|
|
179
|
+
|
|
180
|
+
@bulk_manifests ||= {}
|
|
181
|
+
@bulk_records ||= {}
|
|
182
|
+
|
|
183
|
+
key = [
|
|
184
|
+
connection_config[:fhir_base_url],
|
|
185
|
+
connection_config[:export_group_id],
|
|
186
|
+
resource_type
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
manifest = @bulk_manifests[key] ||= run_bulk_export(connection_config, resource_type)
|
|
190
|
+
|
|
191
|
+
records = @bulk_records[key] ||= bulk_output_urls(manifest, resource_type).flat_map do |url|
|
|
192
|
+
download_ndjson(connection_config, url)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
apply_limit_offset(records, limit: limit, offset: offset)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def apply_limit_offset(records, limit:, offset:)
|
|
199
|
+
start = offset.to_i
|
|
200
|
+
return records.drop(start) if limit.nil?
|
|
201
|
+
|
|
202
|
+
records[start, limit] || []
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def run_bulk_export(connection_config, resource_type)
|
|
206
|
+
status_url = kickoff_bulk_export(connection_config, resource_type)
|
|
207
|
+
poll_bulk_export(connection_config, status_url)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def kickoff_bulk_export(connection_config, resource_type)
|
|
211
|
+
url = bulk_export_url(connection_config, resource_type)
|
|
212
|
+
response = fhir_get(connection_config, url, extra_headers: { "Prefer" => "respond-async" })
|
|
213
|
+
raise fhir_api_error(response, url) unless response.code.to_s == "202"
|
|
214
|
+
|
|
215
|
+
status_url = response["content-location"].presence
|
|
216
|
+
raise StandardError, "Epic FHIR 202 #{url} missing Content-Location header" if status_url.nil?
|
|
217
|
+
|
|
218
|
+
status_url
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def poll_bulk_export(connection_config, status_url)
|
|
222
|
+
timeout = export_timeout(connection_config)
|
|
223
|
+
deadline = Time.now + timeout
|
|
224
|
+
|
|
225
|
+
loop do
|
|
226
|
+
response = fhir_get(connection_config, status_url)
|
|
227
|
+
code = response.code.to_s
|
|
228
|
+
return JSON.parse(response.body) if code == "200"
|
|
229
|
+
raise fhir_api_error(response, status_url) unless code == "202"
|
|
230
|
+
raise StandardError, "Epic FHIR 202 #{status_url} still in progress after #{timeout}s" if Time.now >= deadline
|
|
231
|
+
|
|
232
|
+
sleep(retry_after(response) || export_poll_interval(connection_config))
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def download_bulk_output(connection_config, manifest, resource_type, limit:, offset:)
|
|
237
|
+
return [] if limit&.zero?
|
|
238
|
+
|
|
239
|
+
records = []
|
|
240
|
+
pending_offset = offset.to_i
|
|
241
|
+
|
|
242
|
+
bulk_output_urls(manifest, resource_type).each do |url|
|
|
243
|
+
resources = download_ndjson(connection_config, url)
|
|
244
|
+
skipped = [pending_offset, resources.size].min
|
|
245
|
+
pending_offset -= skipped
|
|
246
|
+
records.concat(resources.drop(skipped))
|
|
247
|
+
break if limit_reached?(records, limit)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
limit_reached?(records, limit) ? records.first(limit) : records
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def bulk_output_urls(manifest, resource_type)
|
|
254
|
+
Array(manifest["output"]).filter_map do |entry|
|
|
255
|
+
entry["url"].presence if entry.is_a?(Hash) && entry["type"].to_s == resource_type
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def download_ndjson(connection_config, url)
|
|
260
|
+
response = fhir_get(connection_config, url, accept: NDJSON_ACCEPT)
|
|
261
|
+
raise fhir_api_error(response, url) unless success?(response)
|
|
262
|
+
|
|
263
|
+
parse_ndjson(response.body)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def parse_ndjson(body)
|
|
267
|
+
body.to_s.each_line.with_index(1).filter_map do |line, line_number|
|
|
268
|
+
line = line.strip
|
|
269
|
+
next if line.empty?
|
|
270
|
+
|
|
271
|
+
JSON.parse(line)
|
|
272
|
+
rescue JSON::ParserError => e
|
|
273
|
+
raise StandardError, "Error parsing NDJSON: #{e.message} at line #{line_number}: #{line}"
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Epic serves Bulk Data only from Group level; there is no system-level $export.
|
|
278
|
+
def bulk_export_url(connection_config, resource_type)
|
|
279
|
+
group_id = connection_config[:export_group_id].to_s.strip
|
|
280
|
+
base = "#{connection_config[:fhir_base_url]}/Group/#{group_id}/$export"
|
|
281
|
+
params = { "_type" => resource_type }
|
|
282
|
+
since = connection_config[:export_since].to_s.strip
|
|
283
|
+
params["_since"] = since if since.present?
|
|
284
|
+
"#{base}?#{URI.encode_www_form(params)}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def retry_after(response)
|
|
288
|
+
value = response["retry-after"].to_s.strip
|
|
289
|
+
delay = value.to_i if value.match?(/\A\d+\z/)
|
|
290
|
+
delay if delay&.positive?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def export_poll_interval(connection_config)
|
|
294
|
+
interval = numeric_setting(connection_config[:export_poll_interval], DEFAULT_EXPORT_POLL_INTERVAL)
|
|
295
|
+
interval.finite? && interval.positive? ? interval : DEFAULT_EXPORT_POLL_INTERVAL
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def export_timeout(connection_config)
|
|
299
|
+
numeric_setting(connection_config[:export_timeout], DEFAULT_EXPORT_TIMEOUT)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def numeric_setting(raw, default)
|
|
303
|
+
return default if raw.to_s.strip.empty?
|
|
304
|
+
|
|
305
|
+
value = raw.to_f
|
|
306
|
+
value.negative? ? default : value
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def limit_reached?(records, limit)
|
|
310
|
+
!limit.nil? && records.size >= limit
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def parse_sql_query(sql_query)
|
|
314
|
+
query = sql_query.to_s.strip.chomp(";")
|
|
315
|
+
resource_type = query[/FROM\s+([^\s;]+)/i, 1]
|
|
316
|
+
raise ArgumentError, "Could not extract FHIR resource type from query" if resource_type.blank?
|
|
317
|
+
raise ArgumentError, "Invalid FHIR resource type: #{resource_type}" unless valid_resource_type?(resource_type)
|
|
318
|
+
|
|
319
|
+
limit = query[/LIMIT\s+(\d+)/i, 1]&.to_i
|
|
320
|
+
offset = query[/OFFSET\s+(\d+)/i, 1]&.to_i
|
|
321
|
+
[resource_type, limit, offset]
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def valid_resource_type?(resource_type)
|
|
325
|
+
resource_type.match?(/\A[A-Z][A-Za-z0-9]*\z/)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def fhir_get(connection_config, url, extra_headers: {}, accept: FHIR_ACCEPT)
|
|
329
|
+
Multiwoven::Integrations::Core::HttpClient.request(
|
|
330
|
+
url,
|
|
331
|
+
HTTP_GET,
|
|
332
|
+
headers: fhir_headers(connection_config, accept: accept).merge(extra_headers),
|
|
333
|
+
options: { config: connection_config[:config] || {} }
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def fhir_headers(connection_config, accept: FHIR_ACCEPT)
|
|
338
|
+
headers = build_headers(connection_config).transform_keys(&:to_s)
|
|
339
|
+
headers["Accept"] = accept
|
|
340
|
+
headers
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def fhir_api_error(response, url = nil)
|
|
344
|
+
body = response.respond_to?(:body) ? response.body.to_s : response.to_s
|
|
345
|
+
code = response.respond_to?(:code) ? response.code.to_s : "unknown"
|
|
346
|
+
StandardError.new(["Epic FHIR", code, url, body].map(&:to_s).reject(&:empty?).join(" "))
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"data": {
|
|
3
|
+
"name": "EpicFhir",
|
|
4
|
+
"title": "Epic FHIR",
|
|
5
|
+
"connector_type": "source",
|
|
6
|
+
"category": "Data Warehouse",
|
|
7
|
+
"sub_category": "Relational Database",
|
|
8
|
+
"documentation_url": "https://docs.squared.ai/guides/sources/data-sources/epic_fhir",
|
|
9
|
+
"github_issue_label": "source-epic-fhir",
|
|
10
|
+
"icon": "icon.svg",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"release_stage": "alpha",
|
|
13
|
+
"support_level": "community",
|
|
14
|
+
"tags": ["language:ruby", "multiwoven", "fhir", "epic"]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
{
|
|
2
|
+
"documentation_url": "https://docs.squared.ai/guides/sources/data-sources/epic_fhir",
|
|
3
|
+
"stream_type": "dynamic",
|
|
4
|
+
"connector_query_type": "raw_sql",
|
|
5
|
+
"connection_specification": {
|
|
6
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
7
|
+
"title": "Epic FHIR",
|
|
8
|
+
"type": "object",
|
|
9
|
+
"required": ["fhir_base_url", "token_url", "client_id", "private_key", "kid", "export_group_id"],
|
|
10
|
+
"properties": {
|
|
11
|
+
"fhir_base_url": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"title": "FHIR Base URL",
|
|
14
|
+
"description": "Your Epic FHIR R4 base URL.",
|
|
15
|
+
"order": 0
|
|
16
|
+
},
|
|
17
|
+
"token_url": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"title": "OAuth Token URL",
|
|
20
|
+
"description": "Your Epic OAuth token endpoint.",
|
|
21
|
+
"order": 1
|
|
22
|
+
},
|
|
23
|
+
"client_id": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"multiwoven_secret": true,
|
|
26
|
+
"title": "OAuth Client ID",
|
|
27
|
+
"description": "The Client ID from your Epic app.",
|
|
28
|
+
"order": 2
|
|
29
|
+
},
|
|
30
|
+
"private_key": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"multiwoven_secret": true,
|
|
33
|
+
"title": "RSA Private Key",
|
|
34
|
+
"description": "The RSA private key for your Epic app.",
|
|
35
|
+
"order": 3
|
|
36
|
+
},
|
|
37
|
+
"kid": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"title": "Key ID (kid)",
|
|
40
|
+
"description": "The Key ID for your Epic app's public key.",
|
|
41
|
+
"order": 4
|
|
42
|
+
},
|
|
43
|
+
"algorithm": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"title": "JWT Signing Algorithm",
|
|
46
|
+
"description": "The signing algorithm used for OAuth authentication.",
|
|
47
|
+
"enum": ["RS384"],
|
|
48
|
+
"default": "RS384",
|
|
49
|
+
"order": 5
|
|
50
|
+
},
|
|
51
|
+
"audience": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"title": "JWT Audience (aud)",
|
|
54
|
+
"description": "Optional. Defaults to your OAuth Token URL.",
|
|
55
|
+
"order": 6
|
|
56
|
+
},
|
|
57
|
+
"scope": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"title": "OAuth Scope",
|
|
60
|
+
"description": "Optional. The OAuth scopes requested from Epic.",
|
|
61
|
+
"default": "system/*.read",
|
|
62
|
+
"order": 7
|
|
63
|
+
},
|
|
64
|
+
"resources": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"title": "Resources",
|
|
67
|
+
"description": "Optional. Comma-separated FHIR resources to export, such as Patient, Observation, or Condition.",
|
|
68
|
+
"order": 8
|
|
69
|
+
},
|
|
70
|
+
"export_group_id": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"title": "Bulk Export Group ID",
|
|
73
|
+
"description": "The Epic Group ID used for Bulk Data exports.",
|
|
74
|
+
"order": 9
|
|
75
|
+
},
|
|
76
|
+
"export_since": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"title": "Export Since",
|
|
79
|
+
"description": "Optional. Only export resources changed after this date and time.",
|
|
80
|
+
"order": 10
|
|
81
|
+
},
|
|
82
|
+
"export_poll_interval": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"title": "Export Poll Interval (seconds)",
|
|
85
|
+
"description": "How often to check the export status.",
|
|
86
|
+
"default": "5",
|
|
87
|
+
"order": 11
|
|
88
|
+
},
|
|
89
|
+
"export_timeout": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"title": "Export Timeout (seconds)",
|
|
92
|
+
"description": "Maximum time to wait for an export to complete. Must be greater than 0 and at most 120.",
|
|
93
|
+
"default": "120",
|
|
94
|
+
"order": 12
|
|
95
|
+
},
|
|
96
|
+
"config": {
|
|
97
|
+
"title": "",
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {
|
|
100
|
+
"timeout": {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"default": "30",
|
|
103
|
+
"title": "HTTP Timeout",
|
|
104
|
+
"description": "Maximum time to wait for an API response.",
|
|
105
|
+
"order": 0
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"order": 13
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect width="128" height="128" rx="24" fill="#0B3D5C"/>
|
|
3
|
+
<path d="M64 28C44.118 28 28 44.118 28 64C28 83.882 44.118 100 64 100C83.882 100 100 83.882 100 64C100 44.118 83.882 28 64 28ZM64 90C49.641 90 38 78.359 38 64C38 49.641 49.641 38 64 38C78.359 38 90 49.641 90 64C90 78.359 78.359 90 64 90Z" fill="#7FD3F0"/>
|
|
4
|
+
<path d="M58 48H70V58H80V70H70V80H58V70H48V58H58V48Z" fill="#FFFFFF"/>
|
|
5
|
+
</svg>
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"auth_type": {
|
|
37
37
|
"type": "string",
|
|
38
38
|
"title": "Authentication Type",
|
|
39
|
-
"description": "Select
|
|
40
|
-
"enum": ["none", "oauth_client_credentials"],
|
|
39
|
+
"description": "Select an OAuth2 option to have Multiwoven fetch and refresh an access token automatically. Use 'oauth_private_key_jwt' for Epic Backend Services / SMART confidential asymmetric clients.",
|
|
40
|
+
"enum": ["none", "oauth_client_credentials", "oauth_private_key_jwt"],
|
|
41
41
|
"default": "none",
|
|
42
42
|
"order": 14
|
|
43
43
|
},
|
|
@@ -158,6 +158,58 @@
|
|
|
158
158
|
}
|
|
159
159
|
},
|
|
160
160
|
"required": ["token_url", "client_id", "client_secret"]
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"properties": {
|
|
164
|
+
"auth_type": { "enum": ["oauth_private_key_jwt"] },
|
|
165
|
+
"token_url": {
|
|
166
|
+
"type": "string",
|
|
167
|
+
"title": "OAuth Token URL",
|
|
168
|
+
"description": "E.g. https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
|
|
169
|
+
"order": 15
|
|
170
|
+
},
|
|
171
|
+
"client_id": {
|
|
172
|
+
"type": "string",
|
|
173
|
+
"multiwoven_secret": true,
|
|
174
|
+
"title": "OAuth Client ID",
|
|
175
|
+
"description": "Non-production or production Client ID from Epic app registration.",
|
|
176
|
+
"order": 16
|
|
177
|
+
},
|
|
178
|
+
"private_key": {
|
|
179
|
+
"type": "string",
|
|
180
|
+
"multiwoven_secret": true,
|
|
181
|
+
"title": "RSA Private Key (PEM)",
|
|
182
|
+
"description": "PEM-encoded RSA private key used to sign the client_assertion JWT. The matching public key must be published at your registered JWK Set URL.",
|
|
183
|
+
"order": 17
|
|
184
|
+
},
|
|
185
|
+
"kid": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"title": "Key ID (kid)",
|
|
188
|
+
"description": "Must match the kid of the public key in your JWK Set.",
|
|
189
|
+
"order": 18
|
|
190
|
+
},
|
|
191
|
+
"algorithm": {
|
|
192
|
+
"type": "string",
|
|
193
|
+
"title": "JWT Signing Algorithm",
|
|
194
|
+
"description": "Epic Backend Services require RS384.",
|
|
195
|
+
"enum": ["RS384", "RS256"],
|
|
196
|
+
"default": "RS384",
|
|
197
|
+
"order": 19
|
|
198
|
+
},
|
|
199
|
+
"audience": {
|
|
200
|
+
"type": "string",
|
|
201
|
+
"title": "JWT Audience (aud)",
|
|
202
|
+
"description": "Optional. Defaults to the Token URL. Epic expects the token endpoint URL.",
|
|
203
|
+
"order": 20
|
|
204
|
+
},
|
|
205
|
+
"scope": {
|
|
206
|
+
"type": "string",
|
|
207
|
+
"title": "OAuth Scope",
|
|
208
|
+
"description": "Optional. E.g. system/Patient.read system/Observation.read",
|
|
209
|
+
"order": 21
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
"required": ["token_url", "client_id", "private_key", "kid"]
|
|
161
213
|
}
|
|
162
214
|
]
|
|
163
215
|
}
|
|
@@ -103,6 +103,7 @@ require_relative "integrations/source/http/client"
|
|
|
103
103
|
require_relative "integrations/source/aisquared/client"
|
|
104
104
|
require_relative "integrations/source/one_drive/client"
|
|
105
105
|
require_relative "integrations/source/microsoft_dynamics/client"
|
|
106
|
+
require_relative "integrations/source/epic_fhir/client"
|
|
106
107
|
|
|
107
108
|
# Destination
|
|
108
109
|
require_relative "integrations/destination/klaviyo/client"
|
|
@@ -51,6 +51,7 @@ Gem::Specification.new do |spec|
|
|
|
51
51
|
spec.add_runtime_dependency "grpc"
|
|
52
52
|
spec.add_runtime_dependency "hubspot-api-client"
|
|
53
53
|
spec.add_runtime_dependency "iterable-api-client"
|
|
54
|
+
spec.add_runtime_dependency "jwt"
|
|
54
55
|
spec.add_runtime_dependency "MailchimpMarketing"
|
|
55
56
|
spec.add_runtime_dependency "net-sftp"
|
|
56
57
|
spec.add_runtime_dependency "pg"
|
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.40.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-08-
|
|
11
|
+
date: 2026-08-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -262,6 +262,20 @@ dependencies:
|
|
|
262
262
|
- - ">="
|
|
263
263
|
- !ruby/object:Gem::Version
|
|
264
264
|
version: '0'
|
|
265
|
+
- !ruby/object:Gem::Dependency
|
|
266
|
+
name: jwt
|
|
267
|
+
requirement: !ruby/object:Gem::Requirement
|
|
268
|
+
requirements:
|
|
269
|
+
- - ">="
|
|
270
|
+
- !ruby/object:Gem::Version
|
|
271
|
+
version: '0'
|
|
272
|
+
type: :runtime
|
|
273
|
+
prerelease: false
|
|
274
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
275
|
+
requirements:
|
|
276
|
+
- - ">="
|
|
277
|
+
- !ruby/object:Gem::Version
|
|
278
|
+
version: '0'
|
|
265
279
|
- !ruby/object:Gem::Dependency
|
|
266
280
|
name: MailchimpMarketing
|
|
267
281
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -764,6 +778,10 @@ files:
|
|
|
764
778
|
- lib/multiwoven/integrations/source/databrics_model/config/meta.json
|
|
765
779
|
- lib/multiwoven/integrations/source/databrics_model/config/spec.json
|
|
766
780
|
- lib/multiwoven/integrations/source/databrics_model/icon.svg
|
|
781
|
+
- lib/multiwoven/integrations/source/epic_fhir/client.rb
|
|
782
|
+
- lib/multiwoven/integrations/source/epic_fhir/config/meta.json
|
|
783
|
+
- lib/multiwoven/integrations/source/epic_fhir/config/spec.json
|
|
784
|
+
- lib/multiwoven/integrations/source/epic_fhir/icon.svg
|
|
767
785
|
- lib/multiwoven/integrations/source/firecrawl/client.rb
|
|
768
786
|
- lib/multiwoven/integrations/source/firecrawl/config/catalog.json
|
|
769
787
|
- lib/multiwoven/integrations/source/firecrawl/config/meta.json
|