sip2 0.2.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
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