sip2 0.2.1 → 0.2.2

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: 4d3b9a457d4ca0131eb2039eda501a303c75c69e4891fc1128f75096279134e7
4
- data.tar.gz: 6bb443020825760ba59150d79eb23490d220123ab0b86567535f6b89cee5a46a
3
+ metadata.gz: a1e096d30ed5962669bb45c1747f83339682f4a3fa8067e35301ea3a975de13c
4
+ data.tar.gz: 7be6116ba6402c005ea78a0dc131dca72762cb593d083529b63dac82ed88de00
5
5
  SHA512:
6
- metadata.gz: e0d0ce1e898a903b9713ab305631fa1e12a0d28eee2efad234b00c858a5d4aca2fef8e18449d1a1dd34ec2b0e549be6859eb9ace0d563a5037716ca62508c898
7
- data.tar.gz: 800ff16573a4c25e76a8bcaf234dcf79745e3c3ee119d3276b8195ff8a0bc7fe1c6ad7eb0c1c920cce19076cd503e273ac3169b32ec7524e0f04872742a57557
6
+ metadata.gz: 03ad63e45484c5cf767ba8f261b2b8b90155721b40d789d1d8b2418ad26396f768272a3584f987d628779bce09f83f39ac94d0ab04f109dc8c981340fabfd1c3
7
+ data.tar.gz: 7c1e703837a65efd8c4fa02bad6f589fbc4e8f48d9d81627f90da31bf3f66fe7216704d448756c62e3159bfca7d2cf7d99566aba34797eaa562de6c7c8c63cd1
@@ -0,0 +1,104 @@
1
+ [![Travis Build Status](http://img.shields.io/travis/Studiosity/sip2-ruby.svg?style=flat)](https://travis-ci.org/Studiosity/sip2-ruby)
2
+ [![Gem Version](http://img.shields.io/gem/v/sip2.svg?style=flat)](#)
3
+
4
+ # 3M™ Standard Interchange Protocol v2 (SIP2) client implementation in Ruby
5
+
6
+ This is a gem wrapping the SIP v2 protocol.
7
+
8
+ http://multimedia.3m.com/mws/media/355361O/sip2-protocol.pdf
9
+
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem 'sip2'
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ ```bash
22
+ $ bundle
23
+ ```
24
+
25
+
26
+ ## Protocol support
27
+
28
+ So far only login (code 93) and patron_information (code 63) are supported
29
+
30
+
31
+ ## Usage
32
+
33
+ ```ruby
34
+ client = Sip2::Client.new(host: 'my.sip2.host.net', port: 6001)
35
+ patron =
36
+ client.connect do |connection|
37
+ if connection.login 'sip_username', 'sip_password'
38
+ connection.patron_information 'patron_username', 'patron_password'
39
+ end
40
+ end
41
+
42
+ puts 'Valid patron' if patron && patron.authenticated?
43
+ ```
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.
84
+
85
+ ## Contributing
86
+
87
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Studiosity/sip2-ruby.
88
+
89
+ Note that spec tests are appreciated to minimise regressions. Before submitting a PR, please ensure that:
90
+
91
+ ```bash
92
+ $ rspec
93
+ ```
94
+ and
95
+
96
+ ```bash
97
+ $ rubocop
98
+ ```
99
+ both succeed
100
+
101
+
102
+ ## License
103
+
104
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -4,10 +4,14 @@ require 'sip2/version'
4
4
 
5
5
  require 'openssl'
6
6
 
7
- require 'sip2/patron_information'
7
+ require 'sip2/responses/base'
8
+ require 'sip2/responses/patron_information'
9
+ require 'sip2/responses/status'
8
10
 
11
+ require 'sip2/messages/base'
9
12
  require 'sip2/messages/login'
10
13
  require 'sip2/messages/patron_information'
14
+ require 'sip2/messages/status'
11
15
 
12
16
  module Sip2
13
17
  class TimeoutError < StandardError; end
@@ -13,8 +13,7 @@ module Sip2
13
13
  @ssl_context = ssl_context
14
14
  end
15
15
 
16
- # rubocop:disable Metrics/MethodLength
17
- def connect
16
+ def connect # rubocop:disable Metrics/MethodLength
18
17
  socket = NonBlockingSocket.connect host: @host, port: @port, timeout: @timeout
19
18
 
20
19
  # If we've been provided with an SSL context then use it to wrap out existing connection
@@ -32,6 +31,5 @@ module Sip2
32
31
  ensure
33
32
  socket&.close
34
33
  end
35
- # rubocop:enable Metrics/MethodLength
36
34
  end
37
35
  end
@@ -7,19 +7,6 @@ module Sip2
7
7
  class Connection
8
8
  LINE_SEPARATOR = "\r"
9
9
 
10
- @connection_modules = []
11
-
12
- class << self
13
- attr_reader :connection_modules
14
-
15
- def add_connection_module(module_name)
16
- @connection_modules << module_name
17
- end
18
- end
19
-
20
- include Messages::Login
21
- include Messages::PatronInformation
22
-
23
10
  def initialize(socket:, ignore_error_detection: false)
24
11
  @socket = socket
25
12
  @ignore_error_detection = ignore_error_detection
@@ -27,33 +14,29 @@ module Sip2
27
14
  end
28
15
 
29
16
  def send_message(message)
17
+ message = with_checksum with_error_detection message
30
18
  write_with_timeout message
31
- read_with_timeout
19
+ response = read_with_timeout
20
+ response if sequence_and_checksum_valid? response
21
+ ensure
22
+ @sequence += 1
32
23
  end
33
24
 
34
25
  def method_missing(method_name, *args)
35
- if Connection.connection_modules.include?(method_name.to_sym)
36
- send_and_handle_message(method_name, *args)
37
- else
26
+ message_class = Messages::Base.message_class_for_method(method_name)
27
+ if message_class.nil?
38
28
  super
29
+ else
30
+ message_class.new(self).action_message(*args)
39
31
  end
40
32
  end
41
33
 
42
34
  def respond_to_missing?(method_name, _include_private = false)
43
- Connection.connection_modules.include?(method_name.to_sym) || super
35
+ !Messages::Base.message_class_for_method(method_name).nil? || super
44
36
  end
45
37
 
46
38
  private
47
39
 
48
- def send_and_handle_message(message_type, *args)
49
- message = send "build_#{message_type}_message", *args
50
- message = with_checksum with_error_detection message
51
- response = send_message message
52
- return if response.nil?
53
-
54
- send "handle_#{message_type}_response", response
55
- end
56
-
57
40
  def write_with_timeout(message, separator: LINE_SEPARATOR)
58
41
  ::Timeout.timeout connection_timeout, WriteTimeout do
59
42
  @socket.write message + separator
@@ -92,14 +75,13 @@ module Sip2
92
75
 
93
76
  def sequence_and_checksum_valid?(response)
94
77
  return true if @ignore_error_detection
78
+ return false unless response.is_a? String
95
79
 
96
80
  sequence_regex = /\A(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})\z/
97
81
  match = response.strip.match sequence_regex
98
82
  match &&
99
83
  match[:sequence] == @sequence.to_s &&
100
84
  match[:checksum] == checksum_for(match[:message])
101
- ensure
102
- @sequence += 1
103
85
  end
104
86
  end
105
87
  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
@@ -3,16 +3,26 @@
3
3
  module Sip2
4
4
  module Messages
5
5
  #
6
- # Sip2 Login message module
6
+ # Sip2 Login message
7
7
  #
8
- module Login
9
- def self.included(klass)
10
- klass.add_connection_module :login
11
- end
12
-
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
13
23
  private
14
24
 
15
- def build_login_message(username:, password:, location_code: nil)
25
+ def build_message(username:, password:, location_code: nil)
16
26
  code = '93' # Login
17
27
  uid_algorithm = pw_algorithm = '0' # Plain text
18
28
  username_field = "CN#{username}"
@@ -25,8 +35,8 @@ module Sip2
25
35
  ].join
26
36
  end
27
37
 
28
- def handle_login_response(response)
29
- sequence_and_checksum_valid?(response) && response[/\A94([01])AY/, 1] == '1'
38
+ def handle_response(response)
39
+ response[/\A94([01])AY/, 1] == '1'
30
40
  end
31
41
  end
32
42
  end
@@ -3,16 +3,25 @@
3
3
  module Sip2
4
4
  module Messages
5
5
  #
6
- # Sip2 Patron information message module
6
+ # Sip2 Patron information message
7
7
  #
8
- module PatronInformation
9
- def self.included(klass)
10
- klass.add_connection_module :patron_information
11
- end
12
-
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
13
22
  private
14
23
 
15
- def build_patron_information_message(uid:, password:, terminal_password: nil)
24
+ def build_message(uid:, password:, terminal_password: nil)
16
25
  code = '63' # Patron information
17
26
  language = '000' # Unknown
18
27
  timestamp = Time.now.strftime('%Y%m%d %H%M%S')
@@ -23,10 +32,10 @@ module Sip2
23
32
  ].join
24
33
  end
25
34
 
26
- def handle_patron_information_response(response)
27
- return unless sequence_and_checksum_valid?(response)
35
+ def handle_response(response)
36
+ return unless /\A#{Sip2::Responses::PatronInformation::RESPONSE_ID}/o.match?(response)
28
37
 
29
- Sip2::PatronInformation.new response
38
+ Sip2::Responses::PatronInformation.new response
30
39
  end
31
40
  end
32
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
@@ -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
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sip2
4
+ module Responses
5
+ #
6
+ # Sip2 Patron Information
7
+ #
8
+ # https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+ #
10
+ # Response message 64
11
+ # * patron status - 14 char, fixed-length required field
12
+ # * language - 3 char, fixed-length required field
13
+ # * transaction date - 18 char, fixed-length required field: YYYYMMDDZZZZHHMMSS
14
+ # * hold items count - 4 char, fixed-length required field
15
+ # * overdue items count - 4 char, fixed-length required field
16
+ # * charged items count - 4 char, fixed-length required field
17
+ # * fine items count - 4 char, fixed-length required field
18
+ # * recall items count - 4 char, fixed-length required field
19
+ # * unavailable holds count - 4 char, fixed-length required field
20
+ # * institution id - AO - variable-length required field
21
+ # * patron identifier - AA - variable-length required field
22
+ # * personal name - AE - variable-length required field
23
+ # * hold items limit - BZ - 4 char, fixed-length optional field
24
+ # * overdue items limit - CA - 4 char, fixed-length optional field
25
+ # * charged items limit - CB - 4 char, fixed-length optional field
26
+ # * valid patron - BL - 1 char, optional field: Y or N
27
+ # * valid patron password - CQ - 1 char, optional field: Y or N
28
+ # * currency type - BH - 3 char, fixed-length optional field
29
+ # * fee amount - BV - variable-length optional field
30
+ # * fee limit - CC - variable-length optional field
31
+ # * hold items - AS - variable-length optional field
32
+ # * overdue items - AT - variable-length optional field
33
+ # * charged items - AU - variable-length optional field
34
+ # * fine items - AV - variable-length optional field
35
+ # * recall items - BU - variable-length optional field
36
+ # * unavailable hold items - CD - variable-length optional field
37
+ # * home address - BD - variable-length optional field
38
+ # * email address - BE - variable-length optional field
39
+ # * home phone number - BF - variable-length optional field
40
+ # * screen message - AF - variable-length optional field
41
+ # * print line - AG - variable-length optional field
42
+ #
43
+ class PatronInformation < Base
44
+ RESPONSE_ID = 64
45
+ FIXED_LENGTH_CHARS = 61 # 59 chars + 2 for the header
46
+
47
+ def charge_privileges_denied?
48
+ parse_fixed_boolean 0
49
+ end
50
+
51
+ def renewal_privileges_denied?
52
+ parse_fixed_boolean 1
53
+ end
54
+
55
+ def recall_privileges_denied?
56
+ parse_fixed_boolean 2
57
+ end
58
+
59
+ def hold_privileges_denied?
60
+ parse_fixed_boolean 3
61
+ end
62
+
63
+ def card_reported_lost?
64
+ parse_fixed_boolean 4
65
+ end
66
+
67
+ def too_many_items_charged?
68
+ parse_fixed_boolean 5
69
+ end
70
+
71
+ def too_many_items_overdue?
72
+ parse_fixed_boolean 6
73
+ end
74
+
75
+ def too_many_renewals?
76
+ parse_fixed_boolean 7
77
+ end
78
+
79
+ def too_many_claims_of_items_returned?
80
+ parse_fixed_boolean 8
81
+ end
82
+
83
+ def too_many_items_lost?
84
+ parse_fixed_boolean 9
85
+ end
86
+
87
+ def excessive_outstanding_fines?
88
+ parse_fixed_boolean 10
89
+ end
90
+
91
+ def excessive_outstanding_fees?
92
+ parse_fixed_boolean 11
93
+ end
94
+
95
+ def recall_overdue?
96
+ parse_fixed_boolean 12
97
+ end
98
+
99
+ def too_many_items_billed?
100
+ parse_fixed_boolean 13
101
+ end
102
+
103
+ def language
104
+ LANGUAGE_LOOKUP_TABLE[parse_fixed_response(14, 3)]
105
+ end
106
+
107
+ def transaction_date
108
+ parse_datetime 17
109
+ end
110
+
111
+ def patron_valid?
112
+ parse_optional_boolean 'BL'
113
+ end
114
+
115
+ def authenticated?
116
+ parse_optional_boolean 'CQ'
117
+ end
118
+
119
+ def email
120
+ parse_text 'BE'
121
+ end
122
+
123
+ def location
124
+ parse_text 'AQ'
125
+ end
126
+
127
+ def screen_message
128
+ parse_text 'AF'
129
+ end
130
+
131
+ def inspect
132
+ format(
133
+ '#<%<class_name>s:0x%<object_id>p @patron_valid="%<patron_valid>s"' \
134
+ ' @email="%<email>s" @authenticated="%<authenticated>s">',
135
+ class_name: self.class.name,
136
+ object_id: object_id,
137
+ patron_valid: patron_valid?,
138
+ email: email,
139
+ authenticated: authenticated?
140
+ )
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sip2
4
+ module Responses
5
+ #
6
+ # Sip2 Patron Information
7
+ #
8
+ # https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+ #
10
+ # Response message 98
11
+ # * on-line status - 1 char, fixed-length required field: Y or N
12
+ # * checkin ok - 1 char, fixed-length required field: Y or N
13
+ # * checkout ok - 1 char, fixed-length required field: Y or N
14
+ # * ACS renewal policy - 1 char, fixed-length required field: Y or N
15
+ # * status update ok - 1 char, fixed-length required field: Y or N
16
+ # * off-line ok - 1 char, fixed-length required field: Y or N
17
+ # * timeout period - 3 char, fixed-length required field
18
+ # * retries allowed - 3 char, fixed-length required field
19
+ # * date / time sync - 18 char, fixed-length required field: YYYYMMDDZZZZHHMMSS
20
+ # * protocol version - 4 char, fixed-length required field: x.xx
21
+ # * institution ID - AO - variable-length required field
22
+ # * library name - AM - variable-length optional field
23
+ # * supported messages - BX - variable-length required field
24
+ # * terminal location - AN - variable-length optional field
25
+ # * screen message - AF - variable-length optional field
26
+ # * print line - AG - variable-length optional field
27
+ #
28
+ class Status < Base
29
+ RESPONSE_ID = 98
30
+ FIXED_LENGTH_CHARS = 36 # 34 chars + 2 for the header
31
+ SUPPORTED_MESSAGES = {
32
+ patron_status_request: 0,
33
+ checkout: 1,
34
+ checkin: 2,
35
+ block_patron: 3,
36
+ status: 4,
37
+ request_resend: 5,
38
+ login: 6,
39
+ patron_information: 7,
40
+ end_patron_session: 8,
41
+ fee_paid: 9,
42
+ item_information: 10,
43
+ item_status_update: 11,
44
+ patron_enable: 12,
45
+ hold: 13,
46
+ renew: 14,
47
+ renew_all: 15
48
+ }.freeze
49
+
50
+ def online?
51
+ parse_fixed_boolean 0
52
+ end
53
+
54
+ def checkin_ok?
55
+ parse_fixed_boolean 1
56
+ end
57
+
58
+ def checkout_ok?
59
+ parse_fixed_boolean 2
60
+ end
61
+
62
+ def acs_renewal_policy?
63
+ parse_fixed_boolean 3
64
+ end
65
+
66
+ def status_update_ok?
67
+ parse_fixed_boolean 4
68
+ end
69
+
70
+ def offline_ok?
71
+ parse_fixed_boolean 5
72
+ end
73
+
74
+ def timeout_period
75
+ timeout = parse_fixed_response 6, 3
76
+ timeout.to_i if timeout.match?(/\A\d+\z/)
77
+ end
78
+
79
+ def retries_allowed
80
+ retries = parse_fixed_response 9, 3
81
+ retries.to_i if retries.match?(/\A\d+\z/)
82
+ end
83
+
84
+ def date_sync
85
+ parse_datetime 12
86
+ end
87
+
88
+ def protocol_version
89
+ version = parse_fixed_response 30, 4
90
+ version.to_f if version.match?(/\A\d\.\d\d\z/)
91
+ end
92
+
93
+ def institution_id
94
+ parse_text 'AO'
95
+ end
96
+
97
+ def library_name
98
+ parse_text 'AM'
99
+ end
100
+
101
+ def supported_messages
102
+ message = parse_text('BX').to_s
103
+
104
+ SUPPORTED_MESSAGES.each_with_object([]) do |(supported_message, index), acc|
105
+ acc << supported_message if message[index] == 'Y'
106
+ end
107
+ end
108
+
109
+ def terminal_location
110
+ parse_text 'AN'
111
+ end
112
+
113
+ def screen_message
114
+ parse_text 'AF'
115
+ end
116
+
117
+ def print_line
118
+ parse_text 'AG'
119
+ end
120
+
121
+ def inspect
122
+ format(
123
+ '#<%<class_name>s:0x%<object_id>p @online="%<online>s"' \
124
+ ' @protocol_version="%<protocol_version>s"',
125
+ class_name: self.class.name,
126
+ object_id: object_id,
127
+ online: online?,
128
+ protocol_version: protocol_version
129
+ )
130
+ end
131
+ end
132
+ end
133
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sip2
4
- VERSION = '0.2.1'
4
+ VERSION = '0.2.2'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sip2
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - abrom
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-17 00:00:00.000000000 Z
11
+ date: 2020-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '12.0'
33
+ version: '13.0'
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '12.0'
40
+ version: '13.0'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rspec
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -58,14 +58,42 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '0.60'
61
+ version: '1.0'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '0.60'
68
+ version: '1.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-performance
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-rspec
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
69
97
  - !ruby/object:Gem::Dependency
70
98
  name: timecop
71
99
  requirement: !ruby/object:Gem::Requirement
@@ -88,13 +116,18 @@ extensions: []
88
116
  extra_rdoc_files: []
89
117
  files:
90
118
  - LICENSE
119
+ - README.md
91
120
  - lib/sip2.rb
92
121
  - lib/sip2/client.rb
93
122
  - lib/sip2/connection.rb
123
+ - lib/sip2/messages/base.rb
94
124
  - lib/sip2/messages/login.rb
95
125
  - lib/sip2/messages/patron_information.rb
126
+ - lib/sip2/messages/status.rb
96
127
  - lib/sip2/non_blocking_socket.rb
97
- - lib/sip2/patron_information.rb
128
+ - lib/sip2/responses/base.rb
129
+ - lib/sip2/responses/patron_information.rb
130
+ - lib/sip2/responses/status.rb
98
131
  - lib/sip2/version.rb
99
132
  homepage: https://github.com/Studiosity/sip2-ruby
100
133
  licenses:
@@ -1,202 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sip2
4
- #
5
- # Sip2 Patron Information
6
- #
7
- class PatronInformation
8
- attr_reader :raw_response
9
-
10
- def initialize(patron_response)
11
- @raw_response = patron_response
12
- end
13
-
14
- def charge_privileges_denied?
15
- parse_patron_status 0
16
- end
17
-
18
- def renewal_privileges_denied?
19
- parse_patron_status 1
20
- end
21
-
22
- def recall_privileges_denied?
23
- parse_patron_status 2
24
- end
25
-
26
- def hold_privileges_denied?
27
- parse_patron_status 3
28
- end
29
-
30
- def card_reported_lost?
31
- parse_patron_status 4
32
- end
33
-
34
- def too_many_items_charged?
35
- parse_patron_status 5
36
- end
37
-
38
- def too_many_items_overdue?
39
- parse_patron_status 6
40
- end
41
-
42
- def too_many_renewals?
43
- parse_patron_status 7
44
- end
45
-
46
- def too_many_claims_of_items_returned?
47
- parse_patron_status 8
48
- end
49
-
50
- def too_many_items_lost?
51
- parse_patron_status 9
52
- end
53
-
54
- def excessive_outstanding_fines?
55
- parse_patron_status 10
56
- end
57
-
58
- def excessive_outstanding_fees?
59
- parse_patron_status 11
60
- end
61
-
62
- def recall_overdue?
63
- parse_patron_status 12
64
- end
65
-
66
- def too_many_items_billed?
67
- parse_patron_status 13
68
- end
69
-
70
- def language
71
- LANGUAGE_LOOKUP_TABLE[parse_fixed_response(14, 3)]
72
- end
73
-
74
- def transaction_date
75
- match = raw_response.match(/\A64.{17}(\d{4})(\d{2})(\d{2})(.{4})(\d{2})(\d{2})(\d{2})/)
76
- return unless match
77
-
78
- _, year, month, day, zone, hour, minute, second = match.to_a
79
- Time.new(
80
- year.to_i, month.to_i, day.to_i,
81
- hour.to_i, minute.to_i, second.to_i,
82
- offset_from_zone(zone)
83
- )
84
- end
85
-
86
- def patron_valid?
87
- parse_boolean 'BL'
88
- end
89
-
90
- def authenticated?
91
- parse_boolean 'CQ'
92
- end
93
-
94
- def email
95
- parse_text 'BE'
96
- end
97
-
98
- def location
99
- parse_text 'AQ'
100
- end
101
-
102
- def screen_message
103
- parse_text 'AF'
104
- end
105
-
106
- def inspect
107
- format(
108
- '#<%<class_name>s:0x%<object_id>p @patron_valid="%<patron_valid>s"' \
109
- ' @email="%<email>s" @authenticated="%<authenticated>s">',
110
- class_name: self.class.name,
111
- object_id: object_id,
112
- patron_valid: patron_valid?,
113
- email: email,
114
- authenticated: authenticated?
115
- )
116
- end
117
-
118
- private
119
-
120
- def parse_boolean(message_id)
121
- raw_response[/\|#{message_id}([YN])\|/, 1] == 'Y'
122
- end
123
-
124
- def parse_text(message_id)
125
- raw_response[/\|#{message_id}(.*?)\|/, 1]
126
- end
127
-
128
- def parse_patron_status(position)
129
- parse_fixed_response(position) == 'Y'
130
- end
131
-
132
- def parse_fixed_response(position, count = 1)
133
- raw_response[/\A64.{#{position}}(.{#{count}})/, 1]
134
- end
135
-
136
- def offset_from_zone(zone)
137
- zone.strip!
138
- lookup = TIME_ZONE_LOOKUP_TABLE.find { |_, v| v.include? zone }
139
- lookup ? lookup.first : '+00:00'
140
- end
141
-
142
- TIME_ZONE_LOOKUP_TABLE = {
143
- '-12:00' => %w[Y],
144
- '-11:00' => %w[X BST],
145
- '-10:00' => %w[W HST BDT],
146
- '-09:00' => %w[V YST HDT],
147
- '-08:00' => %w[U PST YDT],
148
- '-07:00' => %w[T MST PDT],
149
- '-06:00' => %w[S CST MDT],
150
- '-05:00' => %w[R EST CDT],
151
- '-04:00' => %w[Q AST EDT],
152
- '-03:00' => %w[P ADT],
153
- '-02:00' => %w[O],
154
- '-01:00' => %w[N],
155
- '+00:00' => %w[Z GMT WET],
156
- '+01:00' => %w[A CET BST],
157
- '+02:00' => %w[B EET],
158
- '+03:00' => %w[C],
159
- '+04:00' => %w[D],
160
- '+05:00' => %w[E],
161
- '+06:00' => %w[F],
162
- '+07:00' => %w[G],
163
- '+08:00' => %w[H SST WST],
164
- '+09:00' => %w[I JST],
165
- '+10:00' => %w[K JDT],
166
- '+11:00' => %w[L],
167
- '+12:00' => %w[M NZST],
168
- '+13:00' => %w[NZDT]
169
- }.freeze
170
-
171
- LANGUAGE_LOOKUP_TABLE = {
172
- '000' => 'Unknown',
173
- '001' => 'English',
174
- '002' => 'French',
175
- '003' => 'German',
176
- '004' => 'Italian',
177
- '005' => 'Dutch',
178
- '006' => 'Swedish',
179
- '007' => 'Finnish',
180
- '008' => 'Spanish',
181
- '009' => 'Danish',
182
- '010' => 'Portuguese',
183
- '011' => 'Canadian-French',
184
- '012' => 'Norwegian',
185
- '013' => 'Hebrew',
186
- '014' => 'Japanese',
187
- '015' => 'Russian',
188
- '016' => 'Arabic',
189
- '017' => 'Polish',
190
- '018' => 'Greek',
191
- '019' => 'Chinese',
192
- '020' => 'Korean',
193
- '021' => 'North American Spanish',
194
- '022' => 'Tamil',
195
- '023' => 'Malay',
196
- '024' => 'United Kingdom',
197
- '025' => 'Icelandic',
198
- '026' => 'Belgian',
199
- '027' => 'Taiwanese'
200
- }.freeze
201
- end
202
- end