multiwoven-integrations 0.39.3 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 722fb600da2ce3d9e52217a69c531d8991746dc4fef92cb22ac436a5f54b1ad8
4
- data.tar.gz: 3ed0109dda31689e8807ac1601b5008fd52c93f06b5f15ba135ca3fc4b6cfbca
3
+ metadata.gz: d02ac40ce23d00833bf9387f1363d3f871c0abdfb500c235e3d44a37110c9d8f
4
+ data.tar.gz: e76e2c81bf09fc64962b2639addb8d11ff7d9ad37a38c88b1267e0778d77bd95
5
5
  SHA512:
6
- metadata.gz: 5ddaaa66f7ca6ac717f5dc1cb4cb4ef7e329dd96e9a57a10424d4edf39a09e5963620a00cfdbf07f58ded7ad366d73d914fd6370661ce96555dd623ade3f94d9
7
- data.tar.gz: 85a0969e935fa72f385a2d215cb4aa6c835157f4460d4e561a3d0d8ac1ddebd579d5c8d31ba081f2ab5de1fab7ae6f2a5184c044789ba8a847c6d60981315ae4
6
+ metadata.gz: 2bf4882833f8c7e2f823b0077b3a4402d78f5c6acbdee0dc068063dd0be1310b0005c3f73305f26389e4bbf34d17ed6011aa6251852dfc0b17a7956a2928bba2
7
+ data.tar.gz: d460e9bd2b1e2b26fff0c1dd33148952c5f8f1d241cb208bc4da5c4cefbdd9e33b16e6e90ecc449c8e1ddd047b2a40c718dcb51f20aa0b7a9e4d61e21f301229
@@ -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 flow. Include in a connector client to:
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] == AUTH_TYPE_OAUTH_CLIENT_CREDENTIALS
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
- token_url = connection_config[:token_url]
54
- client_id = connection_config[:client_id]
55
- client_secret = connection_config[:client_secret]
56
- raise ArgumentError, "OAuth token_url, client_id, and client_secret are required when auth_type is #{AUTH_TYPE_OAUTH_CLIENT_CREDENTIALS}" if token_url.to_s.empty? || client_id.to_s.empty? || client_secret.to_s.empty?
57
-
58
- response = post_token_request(token_url, client_id, client_secret, connection_config[:scope])
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 post_token_request(token_url, client_id, client_secret, scope)
71
- uri = URI(token_url)
72
- http = Net::HTTP.new(uri.host, uri.port)
73
- http.use_ssl = (uri.scheme == "https")
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 'oauth_client_credentials' to have Multiwoven fetch and refresh an OAuth2 access token automatically.",
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.39.3"
5
+ VERSION = "0.40.1"
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,383 @@
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
+ CONCURRENT_EXPORT_MESSAGE = "Another request for this same Client and Group is in progress."
12
+ REQUIRED_CONFIG_KEYS = %w[fhir_base_url token_url client_id private_key kid export_group_id].freeze
13
+ DEFAULT_RESOURCES = %w[
14
+ AllergyIntolerance
15
+ Appointment
16
+ CarePlan
17
+ CareTeam
18
+ Condition
19
+ Consent
20
+ Device
21
+ DiagnosticReport
22
+ DocumentReference
23
+ Encounter
24
+ EpisodeOfCare
25
+ Goal
26
+ Immunization
27
+ List
28
+ MedicationRequest
29
+ Observation
30
+ Patient
31
+ Procedure
32
+ ServiceRequest
33
+ ].freeze
34
+
35
+ class Client < SourceConnector
36
+ include Multiwoven::Integrations::Core::OauthClientCredentials
37
+
38
+ def check_connection(connection_config)
39
+ connection_config = prepare_config(connection_config)
40
+ validate_config!(connection_config)
41
+ build_headers(connection_config)
42
+ success_status
43
+ rescue StandardError => e
44
+ handle_exception(e, {
45
+ context: "EPIC_FHIR:CHECK_CONNECTION:EXCEPTION",
46
+ type: "error"
47
+ })
48
+ failure_status(e)
49
+ end
50
+
51
+ def discover(connection_config)
52
+ connection_config = prepare_config(connection_config)
53
+ validate_config!(connection_config)
54
+ streams = discover_resource_types(connection_config).map { |resource_type| create_stream(resource_type) }
55
+
56
+ Catalog.new(streams: streams).to_multiwoven_message
57
+ rescue StandardError => e
58
+ handle_exception(e, {
59
+ context: "EPIC_FHIR:DISCOVER:EXCEPTION",
60
+ type: "error"
61
+ })
62
+ end
63
+
64
+ def read(sync_config)
65
+ connection_config = prepare_config(sync_config&.source&.connection_specification)
66
+ validate_config!(connection_config)
67
+ @connector_instance = sync_config&.source&.connector_instance
68
+
69
+ sql_query = sync_config.model.query
70
+ sql_query = batched_query(sql_query, sync_config.limit, sync_config.offset) if
71
+ sync_config.limit.present? || sync_config.offset.present?
72
+ query(connection_config, sql_query)
73
+ rescue StandardError => e
74
+ handle_exception(e, {
75
+ context: "EPIC_FHIR:READ:EXCEPTION",
76
+ type: "error",
77
+ sync_id: sync_config.sync_id,
78
+ sync_run_id: sync_config.sync_run_id
79
+ })
80
+ end
81
+
82
+ private
83
+
84
+ def prepare_config(config)
85
+ config = config.to_unsafe_h if config.respond_to?(:to_unsafe_h)
86
+ config = {} unless config.is_a?(Hash)
87
+ config.with_indifferent_access.tap do |conf|
88
+ conf[:auth_type] = AUTH_TYPE_PRIVATE_KEY_JWT
89
+ conf[:fhir_base_url] = conf[:fhir_base_url].to_s.sub(%r{/+\z}, "")
90
+ end
91
+ end
92
+
93
+ def validate_config!(config)
94
+ missing = REQUIRED_CONFIG_KEYS.reject { |key| config[key].to_s.strip.present? }
95
+ raise ArgumentError, "Missing required Epic FHIR configuration: #{missing.join(", ")}" if missing.any?
96
+
97
+ validate_export_timeout!(config)
98
+ end
99
+
100
+ def validate_export_timeout!(config)
101
+ raw = config[:export_timeout]
102
+ return if raw.to_s.strip.empty?
103
+
104
+ value = raw.to_f
105
+ raise ArgumentError, "Export timeout must be greater than 0" unless value.positive?
106
+ return if value <= DEFAULT_EXPORT_TIMEOUT
107
+
108
+ raise ArgumentError,
109
+ "Export timeout must be equal to or less than #{DEFAULT_EXPORT_TIMEOUT}"
110
+ end
111
+
112
+ # Model preview passes this value back into #query.
113
+ def create_connection(connection_config)
114
+ prepare_config(connection_config)
115
+ end
116
+
117
+ def query(connection_config, sql_query)
118
+ connection_config = prepare_config(connection_config)
119
+ validate_config!(connection_config)
120
+ resource_type, limit, offset = parse_sql_query(sql_query)
121
+
122
+ bulk_export_resources(connection_config, resource_type, limit: limit, offset: offset).map do |resource|
123
+ RecordMessage.new(data: displayable_resource(resource), emitted_at: Time.now.to_i).to_multiwoven_message
124
+ end
125
+ end
126
+
127
+ def displayable_resource(resource)
128
+ resource.to_h.transform_values do |value|
129
+ value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value
130
+ end
131
+ end
132
+
133
+ def discover_resource_types(connection_config)
134
+ configured = configured_resources(connection_config)
135
+ configured.any? ? configured : DEFAULT_RESOURCES
136
+ end
137
+
138
+ def configured_resources(connection_config)
139
+ raw = connection_config[:resources]
140
+ values = raw.is_a?(Array) ? raw : raw.to_s.split(",")
141
+ values.map(&:to_s).map(&:strip).reject(&:empty?).select { |value| valid_resource_type?(value) }
142
+ end
143
+
144
+ def create_stream(resource_type)
145
+ Multiwoven::Integrations::Protocol::Stream.new(
146
+ name: resource_type,
147
+ action: StreamAction["fetch"],
148
+ json_schema: base_resource_schema,
149
+ supported_sync_modes: %w[full_refresh],
150
+ source_defined_primary_key: [["id"]]
151
+ )
152
+ end
153
+
154
+ # Shared across resource types: FHIR Resource / DomainResource keys are
155
+ # always present (nested values become JSON strings in #displayable_resource).
156
+ # Type-specific fields (e.g. Patient.name) also appear at read time, so the
157
+ # schema allows additional string properties instead of pretending the
158
+ # catalog only has id + resourceType.
159
+ def base_resource_schema
160
+ schema = convert_to_json_schema(
161
+ %w[
162
+ id
163
+ resourceType
164
+ meta
165
+ implicitRules
166
+ language
167
+ text
168
+ contained
169
+ extension
170
+ modifierExtension
171
+ ].map { |column_name| { column_name: column_name, type: "string" } }
172
+ )
173
+ schema["additionalProperties"] = true
174
+ schema
175
+ end
176
+
177
+ # The manifest and records are cached for batched LIMIT/OFFSET reads during this client run.
178
+ def bulk_export_resources(connection_config, resource_type, limit:, offset:)
179
+ return [] if limit&.zero?
180
+
181
+ @bulk_manifests ||= {}
182
+ @bulk_records ||= {}
183
+
184
+ key = [
185
+ connection_config[:fhir_base_url],
186
+ connection_config[:export_group_id],
187
+ resource_type
188
+ ]
189
+
190
+ manifest = @bulk_manifests[key] ||= run_bulk_export(connection_config, resource_type)
191
+
192
+ records = @bulk_records[key] ||= bulk_output_urls(manifest, resource_type).flat_map do |url|
193
+ download_ndjson(connection_config, url)
194
+ end
195
+
196
+ apply_limit_offset(records, limit: limit, offset: offset)
197
+ end
198
+
199
+ def apply_limit_offset(records, limit:, offset:)
200
+ start = offset.to_i
201
+ return records.drop(start) if limit.nil?
202
+
203
+ records[start, limit] || []
204
+ end
205
+
206
+ def run_bulk_export(connection_config, resource_type)
207
+ deadline = Time.now + export_timeout(connection_config)
208
+ status_url = kickoff_bulk_export(connection_config, resource_type, deadline: deadline)
209
+ poll_bulk_export(connection_config, status_url, deadline: deadline)
210
+ end
211
+
212
+ def kickoff_bulk_export(connection_config, resource_type, deadline:)
213
+ url = bulk_export_url(connection_config, resource_type)
214
+
215
+ loop do
216
+ response = fhir_get(connection_config, url, extra_headers: { "Prefer" => "respond-async" })
217
+ if response.code.to_s == "202"
218
+ status_url = response["content-location"].presence
219
+ raise StandardError, "Epic FHIR 202 #{url} missing Content-Location header" if status_url.nil?
220
+
221
+ return status_url
222
+ end
223
+
224
+ raise fhir_api_error(response, url) unless concurrent_export?(response)
225
+ raise concurrent_export_timeout_error unless wait_for_retry?(connection_config, response, deadline)
226
+ end
227
+ end
228
+
229
+ def poll_bulk_export(connection_config, status_url, deadline:)
230
+ loop do
231
+ response = fhir_get(connection_config, status_url)
232
+ code = response.code.to_s
233
+ return JSON.parse(response.body) if code == "200"
234
+ raise fhir_api_error(response, status_url) unless code == "202"
235
+
236
+ unless wait_for_retry?(connection_config, response, deadline)
237
+ raise StandardError,
238
+ "Epic FHIR 202 #{status_url} still in progress after #{export_timeout(connection_config)}s"
239
+ end
240
+ end
241
+ end
242
+
243
+ # Waits between attempts without ever waiting past the shared export
244
+ # deadline: sleeping longer and then retrying would issue a request the
245
+ # caller has already given up on, and Epic counts it as one more
246
+ # concurrent export for this client and group.
247
+ def wait_for_retry?(connection_config, response, deadline)
248
+ remaining = deadline - Time.now
249
+ return false unless remaining.positive?
250
+
251
+ delay = retry_after(response) || export_poll_interval(connection_config)
252
+ return false if delay > remaining
253
+
254
+ sleep(delay)
255
+ true
256
+ end
257
+
258
+ def concurrent_export?(response)
259
+ response.body.to_s.downcase.include?(CONCURRENT_EXPORT_MESSAGE.downcase)
260
+ end
261
+
262
+ def concurrent_export_timeout_error
263
+ StandardError.new(
264
+ "Epic is still preparing another bulk export for this Client and Group. " \
265
+ "Retry after the existing export completes."
266
+ )
267
+ end
268
+
269
+ def download_bulk_output(connection_config, manifest, resource_type, limit:, offset:)
270
+ return [] if limit&.zero?
271
+
272
+ records = []
273
+ pending_offset = offset.to_i
274
+
275
+ bulk_output_urls(manifest, resource_type).each do |url|
276
+ resources = download_ndjson(connection_config, url)
277
+ skipped = [pending_offset, resources.size].min
278
+ pending_offset -= skipped
279
+ records.concat(resources.drop(skipped))
280
+ break if limit_reached?(records, limit)
281
+ end
282
+
283
+ limit_reached?(records, limit) ? records.first(limit) : records
284
+ end
285
+
286
+ def bulk_output_urls(manifest, resource_type)
287
+ Array(manifest["output"]).filter_map do |entry|
288
+ entry["url"].presence if entry.is_a?(Hash) && entry["type"].to_s == resource_type
289
+ end
290
+ end
291
+
292
+ def download_ndjson(connection_config, url)
293
+ response = fhir_get(connection_config, url, accept: NDJSON_ACCEPT)
294
+ raise fhir_api_error(response, url) unless success?(response)
295
+
296
+ parse_ndjson(response.body)
297
+ end
298
+
299
+ def parse_ndjson(body)
300
+ body.to_s.each_line.with_index(1).filter_map do |line, line_number|
301
+ line = line.strip
302
+ next if line.empty?
303
+
304
+ JSON.parse(line)
305
+ rescue JSON::ParserError => e
306
+ raise StandardError, "Error parsing NDJSON: #{e.message} at line #{line_number}: #{line}"
307
+ end
308
+ end
309
+
310
+ # Epic serves Bulk Data only from Group level; there is no system-level $export.
311
+ def bulk_export_url(connection_config, resource_type)
312
+ group_id = connection_config[:export_group_id].to_s.strip
313
+ base = "#{connection_config[:fhir_base_url]}/Group/#{group_id}/$export"
314
+ params = { "_type" => resource_type }
315
+ since = connection_config[:export_since].to_s.strip
316
+ params["_since"] = since if since.present?
317
+ "#{base}?#{URI.encode_www_form(params)}"
318
+ end
319
+
320
+ def retry_after(response)
321
+ value = response["retry-after"].to_s.strip
322
+ delay = value.to_i if value.match?(/\A\d+\z/)
323
+ delay if delay&.positive?
324
+ end
325
+
326
+ def export_poll_interval(connection_config)
327
+ interval = numeric_setting(connection_config[:export_poll_interval], DEFAULT_EXPORT_POLL_INTERVAL)
328
+ interval.finite? && interval.positive? ? interval : DEFAULT_EXPORT_POLL_INTERVAL
329
+ end
330
+
331
+ def export_timeout(connection_config)
332
+ numeric_setting(connection_config[:export_timeout], DEFAULT_EXPORT_TIMEOUT)
333
+ end
334
+
335
+ def numeric_setting(raw, default)
336
+ return default if raw.to_s.strip.empty?
337
+
338
+ value = raw.to_f
339
+ value.negative? ? default : value
340
+ end
341
+
342
+ def limit_reached?(records, limit)
343
+ !limit.nil? && records.size >= limit
344
+ end
345
+
346
+ def parse_sql_query(sql_query)
347
+ query = sql_query.to_s.strip.chomp(";")
348
+ resource_type = query[/FROM\s+([^\s;]+)/i, 1]
349
+ raise ArgumentError, "Could not extract FHIR resource type from query" if resource_type.blank?
350
+ raise ArgumentError, "Invalid FHIR resource type: #{resource_type}" unless valid_resource_type?(resource_type)
351
+
352
+ limit = query[/LIMIT\s+(\d+)/i, 1]&.to_i
353
+ offset = query[/OFFSET\s+(\d+)/i, 1]&.to_i
354
+ [resource_type, limit, offset]
355
+ end
356
+
357
+ def valid_resource_type?(resource_type)
358
+ resource_type.match?(/\A[A-Z][A-Za-z0-9]*\z/)
359
+ end
360
+
361
+ def fhir_get(connection_config, url, extra_headers: {}, accept: FHIR_ACCEPT)
362
+ Multiwoven::Integrations::Core::HttpClient.request(
363
+ url,
364
+ HTTP_GET,
365
+ headers: fhir_headers(connection_config, accept: accept).merge(extra_headers),
366
+ options: { config: connection_config[:config] || {} }
367
+ )
368
+ end
369
+
370
+ def fhir_headers(connection_config, accept: FHIR_ACCEPT)
371
+ headers = build_headers(connection_config).transform_keys(&:to_s)
372
+ headers["Accept"] = accept
373
+ headers
374
+ end
375
+
376
+ def fhir_api_error(response, url = nil)
377
+ body = response.respond_to?(:body) ? response.body.to_s : response.to_s
378
+ code = response.respond_to?(:code) ? response.code.to_s : "unknown"
379
+ StandardError.new(["Epic FHIR", code, url, body].map(&:to_s).reject(&:empty?).join(" "))
380
+ end
381
+ end
382
+ end
383
+ 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 'oauth_client_credentials' to have Multiwoven fetch and refresh an OAuth2 access token automatically.",
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.39.3
4
+ version: 0.40.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Subin T P
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-08-20 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