sip2 0.0.11 → 0.2.3

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
- SHA1:
3
- metadata.gz: 62a9c7e482b7366879f24572357834bbbb0f4749
4
- data.tar.gz: 15a6885fdd4355ef88a7015c9f3f09fa28c10005
2
+ SHA256:
3
+ metadata.gz: 11edbd7e4605f2e348428819ca7045156eca4b95a34161c6a38b0aef0a3e7846
4
+ data.tar.gz: 854ee00429be13cc04ab99229dccdfc1a7055e106542d1e9c36c5f05a8d7b236
5
5
  SHA512:
6
- metadata.gz: ba0270c5be9a2c2b776da93aa2b4e75739722e84b0d0a4c245c6ca001f0d083ceff7345d7963718521a557d1f0f1ad6299b5391cce1bd5f4d9d39f8affb017fa
7
- data.tar.gz: 6b9bcfd1f82b8f407c47acfb79e01836acd895b43f94cedf2b9733efaf0e23ac08d45bf37f98f9609f6f5e203c6e97d13db4a1a9ab750f3833faabcf44a0ec0c
6
+ metadata.gz: 277765cd44b1086cda50dfb35393adaa25ff466e3c1cfb02ee2dd723471be68125b052b4741ab1178c6139c4c12571b31a95faa7d1fbc52058f075c676208fb2
7
+ data.tar.gz: 62016708c1636bf81aaf39fcab683c241a472339d28285411716414bcc57dbf1238cf97d2c8eab2de103f6c6e5d23fccb8a6a66eb90865bec3b27ccb287473fd
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.rb CHANGED
@@ -1,14 +1,25 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'sip2/version'
2
4
 
3
- require 'sip2/patron_information'
5
+ require 'openssl'
6
+
7
+ require 'sip2/responses/base'
8
+ require 'sip2/responses/patron_information'
9
+ require 'sip2/responses/status'
4
10
 
11
+ require 'sip2/messages/base'
5
12
  require 'sip2/messages/login'
6
13
  require 'sip2/messages/patron_information'
14
+ require 'sip2/messages/status'
7
15
 
8
16
  module Sip2
9
17
  class TimeoutError < StandardError; end
18
+
10
19
  class ConnectionTimeout < TimeoutError; end
20
+
11
21
  class WriteTimeout < TimeoutError; end
22
+
12
23
  class ReadTimeout < TimeoutError; end
13
24
  end
14
25
 
data/lib/sip2/client.rb CHANGED
@@ -1,19 +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)
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
12
+ @timeout = timeout || NonBlockingSocket::DEFAULT_TIMEOUT
13
+ @ssl_context = ssl_context
10
14
  end
11
15
 
12
- def connect
13
- socket = NonBlockingSocket.connect @host, @port
14
- yield Connection.new(socket, @ignore_error_detection) if block_given?
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
15
31
  ensure
16
- socket.close if socket
32
+ socket&.close
17
33
  end
18
34
  end
19
35
  end
@@ -1,56 +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
- @connection_modules = []
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, ignore_error_detection)
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
 
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
+
25
28
  def method_missing(method_name, *args)
26
- if Connection.connection_modules.include?(method_name.to_sym)
27
- send_and_handle_message(method_name, *args)
28
- else
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)
30
34
  end
31
35
  end
32
36
 
33
37
  def respond_to_missing?(method_name, _include_private = false)
34
- Connection.connection_modules.include?(method_name.to_sym) || super
38
+ !Messages::Base.message_class_for_method(method_name).nil? || super
35
39
  end
36
40
 
37
41
  private
38
42
 
39
- def send_and_handle_message(message_type, *args)
40
- message = send "build_#{message_type}_message", *args
41
- message = with_checksum with_error_detection message
42
- response = send_message message
43
- return if response.nil?
44
- send "handle_#{message_type}_response", response
43
+ def write_with_timeout(message, separator: LINE_SEPARATOR)
44
+ ::Timeout.timeout connection_timeout, WriteTimeout do
45
+ @socket.write message + separator
46
+ end
45
47
  end
46
48
 
47
- def send_message(message)
48
- @socket.send_with_timeout message
49
- @socket.gets_with_timeout
49
+ def read_with_timeout(separator: LINE_SEPARATOR)
50
+ ::Timeout.timeout connection_timeout, ReadTimeout do
51
+ @socket.gets(separator)&.chomp(separator)
52
+ end
53
+ end
54
+
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
50
60
  end
51
61
 
52
62
  def with_error_detection(message)
53
- message + '|AY' + @sequence.to_s
63
+ "#{message}|AY#{@sequence}"
54
64
  end
55
65
 
56
66
  def with_checksum(message)
@@ -63,18 +73,18 @@ module Sip2
63
73
  message.each_char { |m| check += m.ord }
64
74
  check += "\0".ord
65
75
  check = (check ^ 0xFFFF) + 1
66
- format '%4.4X', check
76
+ format '%<check>4.4X', check: check
67
77
  end
68
78
 
69
79
  def sequence_and_checksum_valid?(response)
70
80
  return true if @ignore_error_detection
71
- sequence_regex = /^(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})$/
81
+ return false unless response.is_a? String
82
+
83
+ sequence_regex = /\A(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})\z/
72
84
  match = response.strip.match sequence_regex
73
85
  match &&
74
86
  match[:sequence] == @sequence.to_s &&
75
87
  match[:checksum] == checksum_for(match[:message])
76
- ensure
77
- @sequence += 1
78
88
  end
79
89
  end
80
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
@@ -1,20 +1,32 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  module Messages
3
5
  #
4
- # Sip2 Login message module
6
+ # Sip2 Login message
5
7
  #
6
- module Login
7
- def self.included(klass)
8
- klass.add_connection_module :login
9
- end
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 build_login_message(username, password, location_code = nil)
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 = 'CN' + username
17
- password_field = 'CO' + password
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 handle_login_response(response)
27
- sequence_and_checksum_valid?(response) && response[/^94([01])AY/, 1] == '1'
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 module
6
+ # Sip2 Patron information message
5
7
  #
6
- module PatronInformation
7
- def self.included(klass)
8
- klass.add_connection_module :patron_information
9
- end
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 build_patron_information_message(uid, password, terminal_password = nil)
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,9 +32,10 @@ module Sip2
21
32
  ].join
22
33
  end
23
34
 
24
- def handle_patron_information_response(response)
25
- return unless sequence_and_checksum_valid?(response)
26
- Sip2::PatronInformation.new response
35
+ def handle_response(response)
36
+ return unless /\A#{Sip2::Responses::PatronInformation::RESPONSE_ID}/o.match?(response)
37
+
38
+ Sip2::Responses::PatronInformation.new response
27
39
  end
28
40
  end
29
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