sip2 0.1.1 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +39 -0
- data/lib/sip2/client.rb +20 -5
- data/lib/sip2/connection.rb +40 -32
- data/lib/sip2/messages/base.rb +64 -0
- data/lib/sip2/messages/login.rb +23 -11
- data/lib/sip2/messages/patron_information.rb +21 -10
- data/lib/sip2/messages/status.rb +55 -0
- data/lib/sip2/non_blocking_socket.rb +8 -19
- data/lib/sip2/responses/base.rb +114 -0
- data/lib/sip2/responses/patron_information.rb +144 -0
- data/lib/sip2/responses/status.rb +133 -0
- data/lib/sip2/version.rb +3 -1
- data/lib/sip2.rb +12 -1
- metadata +69 -29
- data/.gitignore +0 -2
- data/.rubocop.yml +0 -9
- data/.travis.yml +0 -22
- data/Gemfile +0 -4
- data/Rakefile +0 -8
- data/lib/sip2/patron_information.rb +0 -200
- data/sip2.gemspec +0 -27
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7ef89c74848c5fbf09675afeb914f52985512f780fdcdc086288cebf6cd245c1
|
4
|
+
data.tar.gz: db0228463ed3b7466c650d4f3882befa65deeebbdd7433692698321dcada9a4b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3b57c2fc70a1514c5bd29741b5ec07f08f539105231ba0849f27e62d960e12d667a9e1dc22a089ddd250a85fef82497fc1ba0c3821060c651179bd8880d9ae9d
|
7
|
+
data.tar.gz: 056c703e7f951b0aeb92b41322d050627f3c354162d6dc8af985a3ae1ca649e100be6620d3cbe68e36090a8c7c64b00a98ff647394c5260918b2a01f7da9bc1c
|
data/README.md
CHANGED
@@ -42,6 +42,45 @@ patron =
|
|
42
42
|
puts 'Valid patron' if patron && patron.authenticated?
|
43
43
|
```
|
44
44
|
|
45
|
+
### Using TLS
|
46
|
+
|
47
|
+
The Sip2::Client will use TLS if a
|
48
|
+
[SSLContext](https://ruby-doc.org/stdlib-2.4.10/libdoc/openssl/rdoc/OpenSSL/SSL/SSLContext.html)
|
49
|
+
is passed in the `ssl_context` parameter. There are quite a few ways this can be configured, but that will
|
50
|
+
depend on how the server being connected to is configured. A basic example is:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
# Setup a cert store using the system certificates/trust chain
|
54
|
+
cert_store = OpenSSL::X509::Store.new
|
55
|
+
cert_store.set_default_paths
|
56
|
+
|
57
|
+
# Setup the SSL context
|
58
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
59
|
+
ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER # This is important. We want to verify the certificate is legitimate
|
60
|
+
ssl_context.min_version = OpenSSL::SSL::TLS1_2_VERSION # Generally good practice to enforce the most recent TLS version
|
61
|
+
ssl_context.cert_store = cert_store # Use the certificate store we configured above
|
62
|
+
|
63
|
+
# Raise an exception if the certificate doesn't check out
|
64
|
+
ssl_context.verify_callback = proc do |preverify_ok, context|
|
65
|
+
raise OpenSSL::SSL::SSLError, <<~ERROR.strip if preverify_ok != true || context.error != 0
|
66
|
+
SSL Verification failed -- Preverify: #{preverify_ok}, Error: #{context.error_string} (#{context.error})
|
67
|
+
ERROR
|
68
|
+
|
69
|
+
true
|
70
|
+
end
|
71
|
+
|
72
|
+
client = Sip2::Client.new(host: 'my.sip2.host.net', port: 6001, ssl_context: ssl_context)
|
73
|
+
```
|
74
|
+
|
75
|
+
If you needed to explicitly specify the certificate to be used there are a few options available
|
76
|
+
to use instead of cert_store (see the documentation for full details and other options):
|
77
|
+
* ca_file - path to a file containing a CA certificate
|
78
|
+
* ca_path - path to a directory containing CA certificates
|
79
|
+
* client_ca - a certificate or array of certificates
|
80
|
+
* client_cert_cb - callback where an array containing an X509 certificate and key are returned
|
81
|
+
|
82
|
+
Be sure to validate that your setup behaves the way you expect it to.
|
83
|
+
Pass in an invalid certificate and see it fails. Pass a mismatching hostname and see it fails. etc.
|
45
84
|
|
46
85
|
## Contributing
|
47
86
|
|
data/lib/sip2/client.rb
CHANGED
@@ -1,20 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Sip2
|
2
4
|
#
|
3
5
|
# Sip2 Client
|
4
6
|
#
|
5
7
|
class Client
|
6
|
-
def initialize(host:, port:, ignore_error_detection: false, timeout: nil)
|
8
|
+
def initialize(host:, port:, ignore_error_detection: false, timeout: nil, ssl_context: nil)
|
7
9
|
@host = host
|
8
10
|
@port = port
|
9
11
|
@ignore_error_detection = ignore_error_detection
|
10
12
|
@timeout = timeout || NonBlockingSocket::DEFAULT_TIMEOUT
|
13
|
+
@ssl_context = ssl_context
|
11
14
|
end
|
12
15
|
|
13
|
-
def connect
|
14
|
-
socket = NonBlockingSocket.connect @host, @port, @timeout
|
15
|
-
|
16
|
+
def connect # rubocop:disable Metrics/MethodLength
|
17
|
+
socket = NonBlockingSocket.connect host: @host, port: @port, timeout: @timeout
|
18
|
+
|
19
|
+
# If we've been provided with an SSL context then use it to wrap out existing connection
|
20
|
+
if @ssl_context
|
21
|
+
socket = ::OpenSSL::SSL::SSLSocket.new socket, @ssl_context
|
22
|
+
socket.hostname = @host # Needed for SNI
|
23
|
+
socket.sync_close = true
|
24
|
+
socket.connect
|
25
|
+
socket.post_connection_check @host # Validate the peer certificate matches the host
|
26
|
+
end
|
27
|
+
|
28
|
+
if block_given?
|
29
|
+
yield Connection.new(socket: socket, ignore_error_detection: @ignore_error_detection)
|
30
|
+
end
|
16
31
|
ensure
|
17
|
-
socket
|
32
|
+
socket&.close
|
18
33
|
end
|
19
34
|
end
|
20
35
|
end
|
data/lib/sip2/connection.rb
CHANGED
@@ -1,57 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Sip2
|
2
4
|
#
|
3
5
|
# Sip2 Connection
|
4
6
|
#
|
5
7
|
class Connection
|
6
|
-
|
7
|
-
|
8
|
-
class << self
|
9
|
-
attr_reader :connection_modules
|
10
|
-
|
11
|
-
def add_connection_module(module_name)
|
12
|
-
@connection_modules << module_name
|
13
|
-
end
|
14
|
-
end
|
15
|
-
|
16
|
-
include Messages::Login
|
17
|
-
include Messages::PatronInformation
|
8
|
+
LINE_SEPARATOR = "\r"
|
18
9
|
|
19
|
-
def initialize(socket
|
10
|
+
def initialize(socket:, ignore_error_detection: false)
|
20
11
|
@socket = socket
|
21
12
|
@ignore_error_detection = ignore_error_detection
|
22
13
|
@sequence = 1
|
23
14
|
end
|
24
15
|
|
25
|
-
def
|
26
|
-
|
27
|
-
|
28
|
-
|
16
|
+
def send_message(message)
|
17
|
+
message = with_checksum with_error_detection message
|
18
|
+
write_with_timeout message
|
19
|
+
# Read the response and strip any leading newline
|
20
|
+
# - Some ACS terminate messages with /r/n by mistake.
|
21
|
+
# We need to remove from the front (i.e. buffer remnant from the previous message)
|
22
|
+
response = read_with_timeout&.[](/\A\n?(.*)\z/, 1)
|
23
|
+
response if sequence_and_checksum_valid? response
|
24
|
+
ensure
|
25
|
+
@sequence += 1
|
26
|
+
end
|
27
|
+
|
28
|
+
def method_missing(method_name, *args, **kwargs)
|
29
|
+
message_class = Messages::Base.message_class_for_method(method_name)
|
30
|
+
if message_class.nil?
|
29
31
|
super
|
32
|
+
else
|
33
|
+
message_class.new(self).action_message(*args, **kwargs)
|
30
34
|
end
|
31
35
|
end
|
32
36
|
|
33
37
|
def respond_to_missing?(method_name, _include_private = false)
|
34
|
-
|
38
|
+
!Messages::Base.message_class_for_method(method_name).nil? || super
|
35
39
|
end
|
36
40
|
|
37
41
|
private
|
38
42
|
|
39
|
-
def
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
43
|
+
def write_with_timeout(message, separator: LINE_SEPARATOR)
|
44
|
+
::Timeout.timeout connection_timeout, WriteTimeout do
|
45
|
+
@socket.write message + separator
|
46
|
+
end
|
47
|
+
end
|
44
48
|
|
45
|
-
|
49
|
+
def read_with_timeout(separator: LINE_SEPARATOR)
|
50
|
+
::Timeout.timeout connection_timeout, ReadTimeout do
|
51
|
+
@socket.gets(separator)&.chomp(separator)
|
52
|
+
end
|
46
53
|
end
|
47
54
|
|
48
|
-
def
|
49
|
-
|
50
|
-
|
55
|
+
def connection_timeout
|
56
|
+
# We want the underlying connection where the timeout is configured,
|
57
|
+
# so if we're dealing with an SSLSocket then we need to unwrap it
|
58
|
+
io = @socket.respond_to?(:io) ? @socket.io : @socket
|
59
|
+
io.connection_timeout || NonBlockingSocket::DEFAULT_TIMEOUT
|
51
60
|
end
|
52
61
|
|
53
62
|
def with_error_detection(message)
|
54
|
-
message
|
63
|
+
"#{message}|AY#{@sequence}"
|
55
64
|
end
|
56
65
|
|
57
66
|
def with_checksum(message)
|
@@ -64,19 +73,18 @@ module Sip2
|
|
64
73
|
message.each_char { |m| check += m.ord }
|
65
74
|
check += "\0".ord
|
66
75
|
check = (check ^ 0xFFFF) + 1
|
67
|
-
format '
|
76
|
+
format '%<check>4.4X', check: check
|
68
77
|
end
|
69
78
|
|
70
79
|
def sequence_and_checksum_valid?(response)
|
71
80
|
return true if @ignore_error_detection
|
81
|
+
return false unless response.is_a? String
|
72
82
|
|
73
|
-
sequence_regex =
|
83
|
+
sequence_regex = /\A(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})\z/
|
74
84
|
match = response.strip.match sequence_regex
|
75
85
|
match &&
|
76
86
|
match[:sequence] == @sequence.to_s &&
|
77
87
|
match[:checksum] == checksum_for(match[:message])
|
78
|
-
ensure
|
79
|
-
@sequence += 1
|
80
88
|
end
|
81
89
|
end
|
82
90
|
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sip2
|
4
|
+
module Messages
|
5
|
+
#
|
6
|
+
# Sip2 Base message handler
|
7
|
+
#
|
8
|
+
class Base
|
9
|
+
@message_class_lookup = {}
|
10
|
+
|
11
|
+
# Class helper to fetch the descendant "message" class based on a method name
|
12
|
+
#
|
13
|
+
# @param [String] method_name the underscore case name of the class to fetch
|
14
|
+
# @return [Class] the message class fetched based on the method_name,
|
15
|
+
# `nil` if no descendant was found
|
16
|
+
# @example
|
17
|
+
# message_class_for_method('patron_information')
|
18
|
+
# => Sip2::Messages::PatronInformation
|
19
|
+
def self.message_class_for_method(method_name) # rubocop:disable Metrics/MethodLength
|
20
|
+
return @message_class_lookup[method_name] if @message_class_lookup.key? :method_name
|
21
|
+
|
22
|
+
@message_class_lookup[method_name] =
|
23
|
+
begin
|
24
|
+
# classify the method name so we can fetch the message class of the same name
|
25
|
+
class_name = method_name.to_s.capitalize.gsub(%r{(?:_|(/))([a-z\d]*)}i) do
|
26
|
+
"#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}"
|
27
|
+
end
|
28
|
+
message_class = Messages.const_get(class_name)
|
29
|
+
message_class if message_class && message_class < self
|
30
|
+
rescue NameError
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def initialize(connection)
|
36
|
+
@connection = connection
|
37
|
+
end
|
38
|
+
|
39
|
+
# Action the message, passing the dynamic arguments to the specific message implementation
|
40
|
+
#
|
41
|
+
# @param [*various] args Arguments to pass to the specific message implementation
|
42
|
+
# @return returns `nil` if there was no valid message returned.
|
43
|
+
# Otherwise value will depend on the specific message. See the `handle_response`
|
44
|
+
# method in those classes for more information
|
45
|
+
def action_message(**args)
|
46
|
+
message = build_message(**args)
|
47
|
+
response = @connection.send_message message
|
48
|
+
return if response.nil?
|
49
|
+
|
50
|
+
handle_response(response)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def build_message(**)
|
56
|
+
raise NotImplementedError, "#{self.class} must implement `build_message` method"
|
57
|
+
end
|
58
|
+
|
59
|
+
def handle_response(_response)
|
60
|
+
raise NotImplementedError, "#{self.class} must implement `handle_response` method"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/sip2/messages/login.rb
CHANGED
@@ -1,20 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Sip2
|
2
4
|
module Messages
|
3
5
|
#
|
4
|
-
# Sip2 Login message
|
6
|
+
# Sip2 Login message
|
5
7
|
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
8
|
+
# https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
|
9
|
+
#
|
10
|
+
# Request message 93
|
11
|
+
# * UID algorithm - 1 char, fixed-length required field; the algorithm used
|
12
|
+
# to encrypt the user id
|
13
|
+
# * PWD algorithm - 1 char, fixed-length required field; the algorithm used
|
14
|
+
# to encrypt the password
|
15
|
+
# * login user id - CN - variable-length required field
|
16
|
+
# * login password - CO - variable-length required field
|
17
|
+
# * location code - CP - variable-length required field
|
18
|
+
#
|
19
|
+
# Response message 94
|
20
|
+
# * ok - 1 char, fixed-length required field: 0 or 1
|
21
|
+
#
|
22
|
+
class Login < Base
|
11
23
|
private
|
12
24
|
|
13
|
-
def
|
25
|
+
def build_message(username:, password:, location_code: nil)
|
14
26
|
code = '93' # Login
|
15
27
|
uid_algorithm = pw_algorithm = '0' # Plain text
|
16
|
-
username_field =
|
17
|
-
password_field =
|
28
|
+
username_field = "CN#{username}"
|
29
|
+
password_field = "CO#{password}"
|
18
30
|
location_code = location_code.strip if location_code.is_a? String
|
19
31
|
location_field = location_code ? "|CP#{location_code}" : ''
|
20
32
|
|
@@ -23,8 +35,8 @@ module Sip2
|
|
23
35
|
].join
|
24
36
|
end
|
25
37
|
|
26
|
-
def
|
27
|
-
|
38
|
+
def handle_response(response)
|
39
|
+
response[/\A94([01])AY/, 1] == '1'
|
28
40
|
end
|
29
41
|
end
|
30
42
|
end
|
@@ -1,16 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Sip2
|
2
4
|
module Messages
|
3
5
|
#
|
4
|
-
# Sip2 Patron information message
|
6
|
+
# Sip2 Patron information message
|
5
7
|
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
8
|
+
# https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
|
9
|
+
#
|
10
|
+
# Request message 63
|
11
|
+
# * language - 3 char, fixed-length required field
|
12
|
+
# * transaction date - 18 char, fixed-length required field: YYYYMMDDZZZZHHMMSS
|
13
|
+
# * summary - 10 char, fixed-length required field
|
14
|
+
# * institution id - AO - variable-length required field
|
15
|
+
# * patron identifier - AA - variable-length required field
|
16
|
+
# * terminal password - AC - variable-length optional field
|
17
|
+
# * patron password - AD - variable-length optional field
|
18
|
+
# * start item - BP - variable-length optional field
|
19
|
+
# * end item - BQ - variable-length optional field
|
20
|
+
#
|
21
|
+
class PatronInformation < Base
|
11
22
|
private
|
12
23
|
|
13
|
-
def
|
24
|
+
def build_message(uid:, password:, terminal_password: nil)
|
14
25
|
code = '63' # Patron information
|
15
26
|
language = '000' # Unknown
|
16
27
|
timestamp = Time.now.strftime('%Y%m%d %H%M%S')
|
@@ -21,10 +32,10 @@ module Sip2
|
|
21
32
|
].join
|
22
33
|
end
|
23
34
|
|
24
|
-
def
|
25
|
-
return unless
|
35
|
+
def handle_response(response)
|
36
|
+
return unless /\A#{Sip2::Responses::PatronInformation::RESPONSE_ID}/o.match?(response)
|
26
37
|
|
27
|
-
Sip2::PatronInformation.new response
|
38
|
+
Sip2::Responses::PatronInformation.new response
|
28
39
|
end
|
29
40
|
end
|
30
41
|
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sip2
|
4
|
+
module Messages
|
5
|
+
#
|
6
|
+
# Sip2 Patron information message
|
7
|
+
#
|
8
|
+
# https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
|
9
|
+
#
|
10
|
+
# Request message 99
|
11
|
+
# * status code - 1 char, fixed-length required field: 0, 1 or 2
|
12
|
+
# * max print width - 3 char, fixed-length required field
|
13
|
+
# * protocol version - 4 char, fixed-length required field: x.xx
|
14
|
+
#
|
15
|
+
class Status < Base
|
16
|
+
STATUS_CODE_LOOKUP = {
|
17
|
+
ok: 0,
|
18
|
+
out_of_paper: 1,
|
19
|
+
about_to_shut_down: 2
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def build_message(status_code: :ok, max_print_width: 999, protocol_version: 2)
|
25
|
+
[
|
26
|
+
'99', # SC Status
|
27
|
+
normalize_status_code(status_code),
|
28
|
+
normalize_print_width(max_print_width),
|
29
|
+
normalize_protocol_version(protocol_version)
|
30
|
+
].join
|
31
|
+
end
|
32
|
+
|
33
|
+
def normalize_status_code(code)
|
34
|
+
format(
|
35
|
+
'%<code>d',
|
36
|
+
code: (code.is_a?(Symbol) ? STATUS_CODE_LOOKUP[code] : code).to_i.abs
|
37
|
+
)[0]
|
38
|
+
end
|
39
|
+
|
40
|
+
def normalize_print_width(width)
|
41
|
+
format('%03<width>d', width: width % 1000)
|
42
|
+
end
|
43
|
+
|
44
|
+
def normalize_protocol_version(version)
|
45
|
+
format('%.2<version>f', version: version % 10)
|
46
|
+
end
|
47
|
+
|
48
|
+
def handle_response(response)
|
49
|
+
return unless /\A#{Sip2::Responses::Status::RESPONSE_ID}/o.match?(response)
|
50
|
+
|
51
|
+
Sip2::Responses::Status.new response
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'socket'
|
2
4
|
require 'timeout'
|
3
5
|
|
@@ -8,24 +10,11 @@ module Sip2
|
|
8
10
|
#
|
9
11
|
class NonBlockingSocket < Socket
|
10
12
|
DEFAULT_TIMEOUT = 5
|
11
|
-
SEPARATOR = "\r".freeze
|
12
13
|
|
13
14
|
attr_accessor :connection_timeout
|
14
15
|
|
15
|
-
def send_with_timeout(message, separator = SEPARATOR)
|
16
|
-
::Timeout.timeout (connection_timeout || DEFAULT_TIMEOUT), WriteTimeout do
|
17
|
-
send message + separator, 0
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
def gets_with_timeout(separator = SEPARATOR)
|
22
|
-
::Timeout.timeout (connection_timeout || DEFAULT_TIMEOUT), ReadTimeout do
|
23
|
-
gets separator
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
16
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
28
|
-
def self.connect(host
|
17
|
+
def self.connect(host:, port:, timeout: DEFAULT_TIMEOUT)
|
29
18
|
# Convert the passed host into structures the non-blocking calls can deal with
|
30
19
|
addr = Socket.getaddrinfo(host, nil)
|
31
20
|
sockaddr = Socket.pack_sockaddr_in(port, addr[0][3])
|
@@ -41,13 +30,13 @@ module Sip2
|
|
41
30
|
# indicating the connection is in progress.
|
42
31
|
socket.connect_nonblock(sockaddr)
|
43
32
|
rescue IO::WaitWritable
|
44
|
-
#
|
45
|
-
#
|
46
|
-
if
|
33
|
+
# wait_writable waits until the socket is writable without blocking,
|
34
|
+
# and returns self or `nil` when times out
|
35
|
+
if socket.wait_writable(timeout)
|
47
36
|
begin
|
48
37
|
# Verify there is now a good connection
|
49
38
|
socket.connect_nonblock(sockaddr)
|
50
|
-
rescue Errno::EISCONN
|
39
|
+
rescue Errno::EISCONN
|
51
40
|
# Good news everybody, the socket is connected!
|
52
41
|
rescue StandardError
|
53
42
|
# An unexpected exception was raised - the connection is no good.
|
@@ -55,7 +44,7 @@ module Sip2
|
|
55
44
|
raise
|
56
45
|
end
|
57
46
|
else
|
58
|
-
#
|
47
|
+
# wait_writable returns nil when the socket is not ready before timeout
|
59
48
|
# seconds have elapsed
|
60
49
|
socket.close
|
61
50
|
raise ConnectionTimeout
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sip2
|
4
|
+
module Responses
|
5
|
+
#
|
6
|
+
# Sip2 Base response
|
7
|
+
#
|
8
|
+
class Base
|
9
|
+
attr_reader :raw_response
|
10
|
+
|
11
|
+
def initialize(raw_response)
|
12
|
+
@raw_response = raw_response
|
13
|
+
end
|
14
|
+
|
15
|
+
TIME_ZONE_LOOKUP_TABLE = {
|
16
|
+
'-12:00' => %w[Y],
|
17
|
+
'-11:00' => %w[X BST],
|
18
|
+
'-10:00' => %w[W HST BDT],
|
19
|
+
'-09:00' => %w[V YST HDT],
|
20
|
+
'-08:00' => %w[U PST YDT],
|
21
|
+
'-07:00' => %w[T MST PDT],
|
22
|
+
'-06:00' => %w[S CST MDT],
|
23
|
+
'-05:00' => %w[R EST CDT],
|
24
|
+
'-04:00' => %w[Q AST EDT],
|
25
|
+
'-03:00' => %w[P ADT],
|
26
|
+
'-02:00' => %w[O],
|
27
|
+
'-01:00' => %w[N],
|
28
|
+
'+00:00' => %w[Z GMT WET],
|
29
|
+
'+01:00' => %w[A CET BST],
|
30
|
+
'+02:00' => %w[B EET],
|
31
|
+
'+03:00' => %w[C],
|
32
|
+
'+04:00' => %w[D],
|
33
|
+
'+05:00' => %w[E],
|
34
|
+
'+06:00' => %w[F],
|
35
|
+
'+07:00' => %w[G],
|
36
|
+
'+08:00' => %w[H SST WST],
|
37
|
+
'+09:00' => %w[I JST],
|
38
|
+
'+10:00' => %w[K JDT],
|
39
|
+
'+11:00' => %w[L],
|
40
|
+
'+12:00' => %w[M NZST],
|
41
|
+
'+13:00' => %w[NZDT]
|
42
|
+
}.freeze
|
43
|
+
|
44
|
+
LANGUAGE_LOOKUP_TABLE = {
|
45
|
+
'000' => 'Unknown',
|
46
|
+
'001' => 'English',
|
47
|
+
'002' => 'French',
|
48
|
+
'003' => 'German',
|
49
|
+
'004' => 'Italian',
|
50
|
+
'005' => 'Dutch',
|
51
|
+
'006' => 'Swedish',
|
52
|
+
'007' => 'Finnish',
|
53
|
+
'008' => 'Spanish',
|
54
|
+
'009' => 'Danish',
|
55
|
+
'010' => 'Portuguese',
|
56
|
+
'011' => 'Canadian-French',
|
57
|
+
'012' => 'Norwegian',
|
58
|
+
'013' => 'Hebrew',
|
59
|
+
'014' => 'Japanese',
|
60
|
+
'015' => 'Russian',
|
61
|
+
'016' => 'Arabic',
|
62
|
+
'017' => 'Polish',
|
63
|
+
'018' => 'Greek',
|
64
|
+
'019' => 'Chinese',
|
65
|
+
'020' => 'Korean',
|
66
|
+
'021' => 'North American Spanish',
|
67
|
+
'022' => 'Tamil',
|
68
|
+
'023' => 'Malay',
|
69
|
+
'024' => 'United Kingdom',
|
70
|
+
'025' => 'Icelandic',
|
71
|
+
'026' => 'Belgian',
|
72
|
+
'027' => 'Taiwanese'
|
73
|
+
}.freeze
|
74
|
+
|
75
|
+
protected
|
76
|
+
|
77
|
+
DATE_MATCH_REGEX = '(\d{4})(\d{2})(\d{2})(.{4})(\d{2})(\d{2})(\d{2})'
|
78
|
+
|
79
|
+
def parse_fixed_response(position, count = 1)
|
80
|
+
raw_response[/\A#{self.class::RESPONSE_ID}.{#{position}}(.{#{count}})/, 1]
|
81
|
+
end
|
82
|
+
|
83
|
+
def parse_fixed_boolean(position)
|
84
|
+
parse_fixed_response(position) == 'Y'
|
85
|
+
end
|
86
|
+
|
87
|
+
def parse_optional_boolean(message_id)
|
88
|
+
raw_response[/(?:\A.{#{self.class::FIXED_LENGTH_CHARS}}|\|)#{message_id}([YN])\|/, 1] == 'Y'
|
89
|
+
end
|
90
|
+
|
91
|
+
def parse_text(message_id)
|
92
|
+
raw_response[/(?:\A.{#{self.class::FIXED_LENGTH_CHARS}}|\|)#{message_id}(.*?)\|/, 1]
|
93
|
+
end
|
94
|
+
|
95
|
+
def parse_datetime(position)
|
96
|
+
match = raw_response.match(/\A#{self.class::RESPONSE_ID}.{#{position}}#{DATE_MATCH_REGEX}/)
|
97
|
+
return unless match
|
98
|
+
|
99
|
+
_, year, month, day, zone, hour, minute, second = match.to_a
|
100
|
+
Time.new(
|
101
|
+
year.to_i, month.to_i, day.to_i,
|
102
|
+
hour.to_i, minute.to_i, second.to_i,
|
103
|
+
offset_from_zone(zone)
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
def offset_from_zone(zone)
|
108
|
+
zone.strip!
|
109
|
+
lookup = TIME_ZONE_LOOKUP_TABLE.find { |_, v| v.include? zone }
|
110
|
+
lookup ? lookup.first : '+00:00'
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|