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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 722fb600da2ce3d9e52217a69c531d8991746dc4fef92cb22ac436a5f54b1ad8
4
- data.tar.gz: 3ed0109dda31689e8807ac1601b5008fd52c93f06b5f15ba135ca3fc4b6cfbca
3
+ metadata.gz: e35c4cdc96ef297f6f538368d5c14c5fb20f26e81137801049350d9c0ccc51aa
4
+ data.tar.gz: 17aa07d139d7338a09ff7e20529245e310f6ce18b448e7f8812837df413b3f78
5
5
  SHA512:
6
- metadata.gz: 5ddaaa66f7ca6ac717f5dc1cb4cb4ef7e329dd96e9a57a10424d4edf39a09e5963620a00cfdbf07f58ded7ad366d73d914fd6370661ce96555dd623ade3f94d9
7
- data.tar.gz: 85a0969e935fa72f385a2d215cb4aa6c835157f4460d4e561a3d0d8ac1ddebd579d5c8d31ba081f2ab5de1fab7ae6f2a5184c044789ba8a847c6d60981315ae4
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 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.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 '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.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-14 00:00:00.000000000 Z
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