tyler_efm_client 1.0.0.pre.alpha.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.
@@ -0,0 +1,627 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'openssl'
6
+ require 'base64'
7
+ require 'securerandom'
8
+ require 'time'
9
+ require 'digest'
10
+ require 'nokogiri'
11
+ require 'json'
12
+
13
+ module TylerEfmClient
14
+ ##
15
+ # Tyler EFM Client for Electronic Court Filing (ECF) services.
16
+ #
17
+ # This client provides a simple interface for:
18
+ # 1. Authentication with Tyler EFM services
19
+ # 2. Making SOAP calls to any Tyler EFM service operation
20
+ #
21
+ # Key features:
22
+ # - Automatic certificate handling (PFX files)
23
+ # - WS-Security digital signatures (RSA-SHA1/SHA1 for Tyler compatibility)
24
+ # - Support for both User Service and Court Record Service operations
25
+ # - Flexible response formats (XML or JSON)
26
+ #
27
+ # @example Basic usage
28
+ # client = TylerEfmClient::Client.new
29
+ #
30
+ # # Authenticate
31
+ # auth_result = client.authenticate(
32
+ # base_url: "https://server.com/EFM/EFMUserService.svc",
33
+ # pfx_file: "certificate.pfx",
34
+ # pfx_password: "password",
35
+ # user_email: "user@example.com",
36
+ # user_password: "userpass"
37
+ # )
38
+ #
39
+ # if auth_result.success?
40
+ # # Call a service
41
+ # response = client.call_service(
42
+ # base_url: "https://server.com/efm/v5/CourtRecordService.svc",
43
+ # password_hash: auth_result.password_hash,
44
+ # operation: "GetCaseList",
45
+ # soap_body: "<GetCaseListRequest>...</GetCaseListRequest>",
46
+ # user_email: "user@example.com"
47
+ # )
48
+ # end
49
+ class Client
50
+ def initialize
51
+ @pkcs12_data = nil
52
+ @certificate = nil
53
+ @private_key = nil
54
+ end
55
+
56
+ ##
57
+ # Authenticate with Tyler EFM User Service.
58
+ #
59
+ # @param base_url [String] Base URL for the EFM User Service (e.g., "https://server/EFM/EFMUserService.svc")
60
+ # @param pfx_file [String] Path to the PFX certificate file
61
+ # @param pfx_password [String] Password for the PFX certificate
62
+ # @param user_email [String] User's email address
63
+ # @param user_password [String] User's password
64
+ #
65
+ # @return [AuthenticationResult] Result with success status and authentication details
66
+ #
67
+ # @raise [TylerEfmClient::AuthenticationError] If authentication fails
68
+ # @raise [TylerEfmClient::CertificateError] If certificate loading fails
69
+ # @raise [TylerEfmClient::Error] If there are other errors (network, etc.)
70
+ def authenticate(base_url:, pfx_file:, pfx_password:, user_email:, user_password:)
71
+ begin
72
+ # Step 1: Load certificate
73
+ unless load_certificate(pfx_file, pfx_password)
74
+ return AuthenticationResult.new(
75
+ success: false,
76
+ error_code: 'CERT_LOAD_ERROR',
77
+ error_message: 'Failed to load certificate from PFX file'
78
+ )
79
+ end
80
+
81
+ # Step 2: Create authentication SOAP envelope
82
+ soap_envelope = create_auth_soap_envelope(user_email, user_password)
83
+
84
+ # Step 3: Make HTTP request
85
+ headers = {
86
+ 'Content-Type' => 'text/xml; charset=utf-8',
87
+ 'SOAPAction' => 'urn:tyler:efm:services/IEfmUserService/AuthenticateUser',
88
+ 'User-Agent' => 'Tyler-EFM-Client-Ruby/1.0',
89
+ 'Accept' => 'text/xml, application/soap+xml, */*'
90
+ }
91
+
92
+ response = make_http_request(base_url, soap_envelope, headers)
93
+
94
+ if response.code.to_i == 200
95
+ parse_auth_response(response.body)
96
+ else
97
+ AuthenticationResult.new(
98
+ success: false,
99
+ error_code: "HTTP_#{response.code}",
100
+ error_message: "Authentication request failed: HTTP #{response.code}"
101
+ )
102
+ end
103
+
104
+ rescue => e
105
+ raise Error, "Authentication error: #{e.message}"
106
+ end
107
+ end
108
+
109
+ ##
110
+ # Call any Tyler EFM SOAP service operation.
111
+ #
112
+ # @param base_url [String] Base URL for the EFM service (User Service or Court Record Service)
113
+ # @param password_hash [String] Password hash from successful authentication
114
+ # @param operation [String] Name of the SOAP operation (e.g., "GetCaseList", "GetCase")
115
+ # @param soap_body [String] SOAP body content as XML string
116
+ # @param user_email [String, nil] User's email (required for Court Record Service operations)
117
+ # @param pfx_file [String, nil] Path to PFX certificate (if not already loaded from authentication)
118
+ # @param pfx_password [String, nil] PFX password (if not already loaded from authentication)
119
+ # @param return_json [Boolean] If true, attempt to convert XML response to JSON (default: false)
120
+ # @param soap_action [String, nil] Custom SOAP action header (auto-generated if not provided)
121
+ #
122
+ # @return [ServiceResponse] Response with call results
123
+ #
124
+ # @raise [TylerEfmClient::ServiceError] If the service call fails
125
+ # @raise [TylerEfmClient::Error] If there are other errors
126
+ def call_service(base_url:, password_hash:, operation:, soap_body:, user_email: nil,
127
+ pfx_file: nil, pfx_password: nil, return_json: false, soap_action: nil)
128
+ begin
129
+ # Load certificate if not already loaded
130
+ if @certificate.nil? && pfx_file
131
+ unless load_certificate(pfx_file, pfx_password)
132
+ return ServiceResponse.new(
133
+ success: false,
134
+ status_code: 0,
135
+ raw_xml: '',
136
+ error_message: 'Failed to load certificate'
137
+ )
138
+ end
139
+ end
140
+
141
+ if @certificate.nil?
142
+ return ServiceResponse.new(
143
+ success: false,
144
+ status_code: 0,
145
+ raw_xml: '',
146
+ error_message: 'No certificate loaded. Provide pfx_file or call authenticate() first.'
147
+ )
148
+ end
149
+
150
+ # Determine service type and create appropriate SOAP envelope
151
+ if base_url.include?('CourtRecord') || base_url.downcase.include?('courtrecord')
152
+ # Court Record Service - requires UserNameHeader
153
+ if user_email.nil?
154
+ return ServiceResponse.new(
155
+ success: false,
156
+ status_code: 0,
157
+ raw_xml: '',
158
+ error_message: 'user_email is required for Court Record Service operations'
159
+ )
160
+ end
161
+ soap_envelope = create_court_record_soap_envelope(user_email, password_hash, operation, soap_body)
162
+ else
163
+ # User Service - standard WS-Security only
164
+ soap_envelope = create_user_service_soap_envelope(operation, soap_body)
165
+ end
166
+
167
+ # Auto-generate SOAP action if not provided
168
+ if soap_action.nil?
169
+ if base_url.include?('CourtRecord')
170
+ soap_action = "https://docs.oasis-open.org/legalxml-courtfiling/ns/v5.0WSDL/CourtRecordMDE/#{operation}"
171
+ else
172
+ soap_action = "urn:tyler:efm:services/IEfmUserService/#{operation}"
173
+ end
174
+ end
175
+
176
+ headers = {
177
+ 'Content-Type' => 'text/xml; charset=utf-8',
178
+ 'SOAPAction' => soap_action,
179
+ 'User-Agent' => 'Tyler-EFM-Client-Ruby/1.0',
180
+ 'Accept' => 'text/xml, application/soap+xml, */*'
181
+ }
182
+
183
+ response = make_http_request(base_url, soap_envelope, headers)
184
+
185
+ # Parse response
186
+ json_data = nil
187
+ if return_json && response.code.to_i == 200
188
+ json_data = xml_to_json(response.body)
189
+ end
190
+
191
+ ServiceResponse.new(
192
+ success: (response.code.to_i == 200),
193
+ status_code: response.code.to_i,
194
+ raw_xml: response.body,
195
+ json_data: json_data,
196
+ error_message: response.code.to_i == 200 ? nil : "HTTP #{response.code}"
197
+ )
198
+
199
+ rescue => e
200
+ raise ServiceError, "Service call error: #{e.message}"
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ def load_certificate(pfx_file, pfx_password)
207
+ begin
208
+ pfx_data = File.binread(pfx_file)
209
+ @pkcs12_data = OpenSSL::PKCS12.new(pfx_data, pfx_password)
210
+ @certificate = @pkcs12_data.certificate
211
+ @private_key = @pkcs12_data.key
212
+ true
213
+ rescue => e
214
+ false
215
+ end
216
+ end
217
+
218
+ def get_certificate_base64
219
+ cert_der = @certificate.to_der
220
+ Base64.strict_encode64(cert_der)
221
+ end
222
+
223
+ def canonicalize_xml(xml_element)
224
+ begin
225
+ # Use Nokogiri's canonicalize with exclusive C14N
226
+ xml_element.canonicalize(Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0)
227
+ rescue => e
228
+ xml_element.to_s
229
+ end
230
+ end
231
+
232
+ def compute_sha1_digest(data)
233
+ Digest::SHA1.digest(data)
234
+ end
235
+
236
+ def sign_data_rsa_sha1(data)
237
+ @private_key.sign(OpenSSL::Digest::SHA1.new, data)
238
+ end
239
+
240
+ def create_auth_soap_envelope(email, password)
241
+ timestamp_id = '_0'
242
+ token_id = "X509-#{SecureRandom.uuid}"
243
+
244
+ # Create timestamp
245
+ now = Time.now.utc
246
+ created = now.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
247
+ expires = (now + 300).strftime('%Y-%m-%dT%H:%M:%S.%3NZ') # 5 minutes
248
+
249
+ cert_base64 = get_certificate_base64
250
+
251
+ # Build SOAP envelope XML using Nokogiri
252
+ builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
253
+ xml['soap'].Envelope(
254
+ 'xmlns:soap' => 'http://schemas.xmlsoap.org/soap/envelope/',
255
+ 'xmlns:tns' => 'urn:tyler:efm:services'
256
+ ) do
257
+ xml['soap'].Header do
258
+ xml['wsse'].Security(
259
+ 'xmlns:wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
260
+ 'xmlns:wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
261
+ 'soap:mustUnderstand' => '1'
262
+ ) do
263
+ xml['wsu'].Timestamp('wsu:Id' => timestamp_id) do
264
+ xml['wsu'].Created created
265
+ xml['wsu'].Expires expires
266
+ end
267
+
268
+ xml['wsse'].BinarySecurityToken(
269
+ cert_base64,
270
+ 'ValueType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3',
271
+ 'EncodingType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary',
272
+ 'wsu:Id' => token_id
273
+ )
274
+ end
275
+ end
276
+
277
+ xml['soap'].Body do
278
+ xml['tns'].AuthenticateUser do
279
+ xml['tns'].AuthenticateRequest do
280
+ xml.Email(email, 'xmlns' => 'urn:tyler:efm:services:schema:AuthenticateRequest')
281
+ xml.Password(password, 'xmlns' => 'urn:tyler:efm:services:schema:AuthenticateRequest')
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ # Parse the built XML to add digital signature
289
+ doc = Nokogiri::XML(builder.to_xml)
290
+
291
+ # Find the timestamp element for signing
292
+ timestamp_elem = doc.xpath('//wsu:Timestamp', 'wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd').first
293
+
294
+ if timestamp_elem
295
+ add_digital_signature(doc, timestamp_elem, timestamp_id, token_id)
296
+ end
297
+
298
+ # Return compact XML (no pretty printing)
299
+ doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
300
+ end
301
+
302
+ def create_court_record_soap_envelope(email, password_hash, operation, soap_body)
303
+ timestamp_id = '_0'
304
+ token_id = "X509-#{SecureRandom.uuid}"
305
+
306
+ # Create timestamp
307
+ now = Time.now.utc
308
+ created = now.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
309
+ expires = (now + 300).strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
310
+
311
+ cert_base64 = get_certificate_base64
312
+
313
+ # Build SOAP envelope XML using Nokogiri
314
+ builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
315
+ xml['soap'].Envelope(
316
+ 'xmlns:soap' => 'http://schemas.xmlsoap.org/soap/envelope/',
317
+ 'xmlns:tns' => 'https://docs.oasis-open.org/legalxml-courtfiling/ns/v5.0WSDL/CourtRecordMDE',
318
+ 'xmlns:wrappers' => 'https://docs.oasis-open.org/legalxml-courtfiling/ns/v5.0/wrappers',
319
+ 'xmlns' => 'urn:tyler:efm:services' # Default namespace for Tyler services
320
+ ) do
321
+ xml['soap'].Header do
322
+ # CRITICAL: UserNameHeader MUST be first and WITHOUT namespace prefix
323
+ xml.UserNameHeader do
324
+ xml.UserName email
325
+ xml.Password password_hash # Use hashed password from authentication
326
+ end
327
+
328
+ # WS-Security as second header element
329
+ xml['wsse'].Security(
330
+ 'xmlns:wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
331
+ 'xmlns:wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
332
+ 'soap:mustUnderstand' => '1'
333
+ ) do
334
+ xml['wsu'].Timestamp('wsu:Id' => timestamp_id) do
335
+ xml['wsu'].Created created
336
+ xml['wsu'].Expires expires
337
+ end
338
+
339
+ xml['wsse'].BinarySecurityToken(
340
+ cert_base64,
341
+ 'ValueType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3',
342
+ 'EncodingType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary',
343
+ 'wsu:Id' => token_id
344
+ )
345
+ end
346
+ end
347
+
348
+ xml['soap'].Body('xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
349
+ 'xmlns:xsd' => 'http://www.w3.org/2001/XMLSchema') do
350
+ # Parse and insert the provided SOAP body
351
+ begin
352
+ body_doc = Nokogiri::XML(soap_body)
353
+ xml << body_doc.root.to_xml
354
+ rescue
355
+ # If parsing fails, add as text (fallback)
356
+ xml << soap_body
357
+ end
358
+ end
359
+ end
360
+ end
361
+
362
+ # Parse the built XML to add digital signature
363
+ doc = Nokogiri::XML(builder.to_xml)
364
+
365
+ # Find the timestamp element for signing
366
+ timestamp_elem = doc.xpath('//wsu:Timestamp', 'wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd').first
367
+
368
+ if timestamp_elem
369
+ add_digital_signature(doc, timestamp_elem, timestamp_id, token_id)
370
+ end
371
+
372
+ # Return compact XML (no pretty printing)
373
+ doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
374
+ end
375
+
376
+ def create_user_service_soap_envelope(operation, soap_body)
377
+ timestamp_id = '_0'
378
+ token_id = "X509-#{SecureRandom.uuid}"
379
+
380
+ # Create timestamp
381
+ now = Time.now.utc
382
+ created = now.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
383
+ expires = (now + 300).strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
384
+
385
+ cert_base64 = get_certificate_base64
386
+
387
+ # Build SOAP envelope XML using Nokogiri
388
+ builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
389
+ xml['soap'].Envelope(
390
+ 'xmlns:soap' => 'http://schemas.xmlsoap.org/soap/envelope/',
391
+ 'xmlns:tns' => 'urn:tyler:efm:services'
392
+ ) do
393
+ xml['soap'].Header do
394
+ xml['wsse'].Security(
395
+ 'xmlns:wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
396
+ 'xmlns:wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
397
+ 'soap:mustUnderstand' => '1'
398
+ ) do
399
+ xml['wsu'].Timestamp('wsu:Id' => timestamp_id) do
400
+ xml['wsu'].Created created
401
+ xml['wsu'].Expires expires
402
+ end
403
+
404
+ xml['wsse'].BinarySecurityToken(
405
+ cert_base64,
406
+ 'ValueType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3',
407
+ 'EncodingType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary',
408
+ 'wsu:Id' => token_id
409
+ )
410
+ end
411
+ end
412
+
413
+ xml['soap'].Body do
414
+ # Parse and insert the provided SOAP body
415
+ begin
416
+ body_doc = Nokogiri::XML(soap_body)
417
+ xml << body_doc.root.to_xml
418
+ rescue
419
+ # If parsing fails, add as text (fallback)
420
+ xml << soap_body
421
+ end
422
+ end
423
+ end
424
+ end
425
+
426
+ # Parse the built XML to add digital signature
427
+ doc = Nokogiri::XML(builder.to_xml)
428
+
429
+ # Find the timestamp element for signing
430
+ timestamp_elem = doc.xpath('//wsu:Timestamp', 'wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd').first
431
+
432
+ if timestamp_elem
433
+ add_digital_signature(doc, timestamp_elem, timestamp_id, token_id)
434
+ end
435
+
436
+ # Return compact XML (no pretty printing)
437
+ doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
438
+ end
439
+
440
+ def add_digital_signature(doc, timestamp_element, timestamp_id, token_id)
441
+ begin
442
+ # Canonicalize timestamp and compute digest
443
+ timestamp_canonical = canonicalize_xml(timestamp_element)
444
+ timestamp_digest = compute_sha1_digest(timestamp_canonical)
445
+ timestamp_digest_b64 = Base64.strict_encode64(timestamp_digest)
446
+
447
+ # Create SignedInfo XML
448
+ signed_info_xml = %Q{<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
449
+ <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
450
+ <ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
451
+ <ds:Reference URI="##{timestamp_id}">
452
+ <ds:Transforms>
453
+ <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
454
+ </ds:Transforms>
455
+ <ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
456
+ <ds:DigestValue>#{timestamp_digest_b64}</ds:DigestValue>
457
+ </ds:Reference>
458
+ </ds:SignedInfo>}
459
+
460
+ # Parse SignedInfo for canonicalization
461
+ signed_info_doc = Nokogiri::XML(signed_info_xml)
462
+ signed_info_elem = signed_info_doc.root
463
+
464
+ # Canonicalize SignedInfo and sign
465
+ signed_info_canonical = canonicalize_xml(signed_info_elem)
466
+ signature_bytes = sign_data_rsa_sha1(signed_info_canonical)
467
+ signature_b64 = Base64.strict_encode64(signature_bytes)
468
+
469
+ # Create complete signature XML
470
+ signature_xml = %Q{<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
471
+ #{signed_info_xml.gsub('<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">', '<ds:SignedInfo>').gsub('</ds:SignedInfo>', '</ds:SignedInfo>')}
472
+ <ds:SignatureValue>#{signature_b64}</ds:SignatureValue>
473
+ <ds:KeyInfo>
474
+ <wsse:SecurityTokenReference>
475
+ <wsse:Reference ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" URI="##{token_id}"/>
476
+ </wsse:SecurityTokenReference>
477
+ </ds:KeyInfo>
478
+ </ds:Signature>}
479
+
480
+ # Add signature to Security element
481
+ security_elem = doc.xpath('//wsse:Security', 'wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd').first
482
+ signature_doc = Nokogiri::XML(signature_xml)
483
+ security_elem.add_child(signature_doc.root)
484
+
485
+ rescue => e
486
+ # Signature creation failed, but continue without signature
487
+ end
488
+ end
489
+
490
+ def make_http_request(base_url, soap_envelope, headers)
491
+ uri = URI(base_url)
492
+ http = Net::HTTP.new(uri.host, uri.port)
493
+ http.use_ssl = true
494
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
495
+
496
+ # Set client certificate for mutual TLS
497
+ http.cert = @certificate
498
+ http.key = @private_key
499
+
500
+ # Create POST request
501
+ request = Net::HTTP::Post.new(uri.path)
502
+ headers.each { |key, value| request[key] = value }
503
+ request.body = soap_envelope
504
+
505
+ # Make the request
506
+ http.request(request)
507
+ end
508
+
509
+ def parse_auth_response(response_text)
510
+ begin
511
+ # Handle multipart MIME response
512
+ xml_content = response_text
513
+ if response_text.include?('--uuid:')
514
+ xml_start = response_text.index('<s:Envelope') || response_text.index('<')
515
+ if xml_start
516
+ xml_end = response_text.rindex('</s:Envelope>')
517
+ if xml_end
518
+ xml_end += '</s:Envelope>'.length
519
+ xml_content = response_text[xml_start...xml_end]
520
+ end
521
+ end
522
+ end
523
+
524
+ doc = Nokogiri::XML(xml_content)
525
+
526
+ # Extract authentication response data
527
+ password_hash = extract_element_text(doc, 'PasswordHash')
528
+ user_id = extract_element_text(doc, 'UserID')
529
+ first_name = extract_element_text(doc, 'FirstName')
530
+ last_name = extract_element_text(doc, 'LastName')
531
+ email = extract_element_text(doc, 'Email')
532
+ expiration_date = extract_element_text(doc, 'ExpirationDateTime')
533
+ error_code = extract_element_text(doc, 'ErrorCode')
534
+ error_message = extract_element_text(doc, 'ErrorText')
535
+
536
+ # Determine success
537
+ success = !password_hash.nil? && (error_code.nil? || error_code == '0')
538
+
539
+ AuthenticationResult.new(
540
+ success: success,
541
+ password_hash: password_hash,
542
+ user_id: user_id,
543
+ first_name: first_name,
544
+ last_name: last_name,
545
+ email: email,
546
+ expiration_date: expiration_date,
547
+ error_code: error_code,
548
+ error_message: error_message
549
+ )
550
+
551
+ rescue => e
552
+ AuthenticationResult.new(
553
+ success: false,
554
+ error_code: 'PARSE_ERROR',
555
+ error_message: "Failed to parse authentication response: #{e.message}"
556
+ )
557
+ end
558
+ end
559
+
560
+ def extract_element_text(doc, element_name)
561
+ elements = doc.xpath("//*[local-name()='#{element_name}']")
562
+ elements.first&.text&.strip
563
+ end
564
+
565
+ def xml_to_json(xml_text)
566
+ begin
567
+ # Handle multipart MIME response
568
+ xml_content = xml_text
569
+ if xml_text.include?('--uuid:')
570
+ xml_start = xml_text.index('<s:Envelope') || xml_text.index('<')
571
+ if xml_start
572
+ xml_end = xml_text.rindex('</s:Envelope>')
573
+ if xml_end
574
+ xml_end += '</s:Envelope>'.length
575
+ xml_content = xml_text[xml_start...xml_end]
576
+ end
577
+ end
578
+ end
579
+
580
+ doc = Nokogiri::XML(xml_content)
581
+ element_to_hash(doc.root)
582
+
583
+ rescue => e
584
+ nil
585
+ end
586
+ end
587
+
588
+ def element_to_hash(element)
589
+ result = {}
590
+
591
+ # Add attributes
592
+ element.attributes.each do |key, attr|
593
+ result["@#{key}"] = attr.value
594
+ end
595
+
596
+ # Add text content
597
+ if element.text && !element.text.strip.empty?
598
+ if element.children.length == 1 && element.children.first.text?
599
+ return element.text.strip
600
+ else
601
+ result['#text'] = element.text.strip
602
+ end
603
+ end
604
+
605
+ # Add child elements
606
+ element.children.each do |child|
607
+ next if child.text?
608
+
609
+ child_name = child.name
610
+ child_value = element_to_hash(child)
611
+
612
+ if result[child_name]
613
+ # Convert to array if multiple elements with same name
614
+ if result[child_name].is_a?(Array)
615
+ result[child_name] << child_value
616
+ else
617
+ result[child_name] = [result[child_name], child_value]
618
+ end
619
+ else
620
+ result[child_name] = child_value
621
+ end
622
+ end
623
+
624
+ result
625
+ end
626
+ end
627
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TylerEfmClient
4
+ ##
5
+ # Base exception for Tyler EFM Client errors
6
+ class Error < StandardError; end
7
+
8
+ ##
9
+ # Exception raised for authentication failures
10
+ class AuthenticationError < Error; end
11
+
12
+ ##
13
+ # Exception raised for certificate handling errors
14
+ class CertificateError < Error; end
15
+
16
+ ##
17
+ # Exception raised for SOAP service call errors
18
+ class ServiceError < Error; end
19
+ end