sip2-ruby 0.1.0

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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +136 -0
  4. data/Rakefile +10 -0
  5. data/bin/json-to-sip2 +109 -0
  6. data/bin/sip2-to-json +81 -0
  7. data/lib/sip2/checksum_encoder.rb +11 -0
  8. data/lib/sip2/field_parser_rules.rb +490 -0
  9. data/lib/sip2/fields.rb +742 -0
  10. data/lib/sip2/message/base_message.rb +135 -0
  11. data/lib/sip2/message/unknown_message.rb +26 -0
  12. data/lib/sip2/message.rb +95 -0
  13. data/lib/sip2/message_parser_rules.rb +122 -0
  14. data/lib/sip2/messages.rb +628 -0
  15. data/lib/sip2/parser.rb +14 -0
  16. data/lib/sip2/parser_atoms.rb +53 -0
  17. data/lib/sip2/transformer.rb +94 -0
  18. data/lib/sip2/types.rb +6 -0
  19. data/lib/sip2/version.rb +3 -0
  20. data/lib/sip2.rb +18 -0
  21. data/sip2-ruby.gemspec +39 -0
  22. data/test/fixture_helper.rb +31 -0
  23. data/test/fixtures/09.sip2 +1 -0
  24. data/test/fixtures/10.sip2 +1 -0
  25. data/test/fixtures/17.sip2 +1 -0
  26. data/test/fixtures/18.sip2 +1 -0
  27. data/test/fixtures/98-with-unexpected.sip2 +1 -0
  28. data/test/fixtures/98.sip2 +1 -0
  29. data/test/fixtures/99-with-unexpected.sip2 +1 -0
  30. data/test/fixtures/99.sip2 +1 -0
  31. data/test/fixtures/XX.sip2 +1 -0
  32. data/test/fixtures/alma/09.sip2 +1 -0
  33. data/test/fixtures/alma/10.sip2 +1 -0
  34. data/test/fixtures/alma/17.sip2 +1 -0
  35. data/test/fixtures/alma/18.sip2 +1 -0
  36. data/test/fixtures/alma/98.sip2 +1 -0
  37. data/test/fixtures/alma/99.sip2 +1 -0
  38. data/test/round_trip_spec.rb +82 -0
  39. data/test/sip2/message_parser_rules_spec.rb +50 -0
  40. data/test/sip2/parser_atoms_spec.rb +354 -0
  41. data/test/sip2/parser_test.rb +11 -0
  42. data/test/sip2_spec.rb +145 -0
  43. data/test/spec_helper.rb +4 -0
  44. data/test/test_helper.rb +4 -0
  45. metadata +190 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 35f09dc0ff1cc1cc6d033faf06eb5ec6108d37c4f9cd89d3e63ecf66614292ab
4
+ data.tar.gz: 6e21ab95f53d364cc215eeb66db6f540d0315cc203a355476d172d13bb7aaccd
5
+ SHA512:
6
+ metadata.gz: f695891e29b8ada0c7ec5fa68b606931fa5f0f4b866bd0e4cf02c1914dbe5a29013313384af73f76eae04f9b18d4078df03ddfbb9e558f48124a3c22b9edcc5b
7
+ data.tar.gz: 7ca77c9993bef861c547ef2405be6da9d0383cd3065be6464b582acfc50b9a13709e6227e288474e696c6481e3adddaf312b7bc814a15ce1328d6624fa76efa4
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 University of Borås
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # Sip2 (sip2-ruby)
2
+
3
+ Sip2 is a library for parsing and encoding messages in the [Sip2 standard][sip2]
4
+ used for communication between library automation devices and automated
5
+ circulation systems. It is also two executables, `sip2-to-json` and
6
+ `json-to-sip2`, for parsing and encoding respectively.
7
+
8
+ [sip2]: https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+
10
+ Sip2 messages are parsed into a Hash/JSON representation, and the
11
+ Hash/JSON representation can be converted back into Sip2 format.
12
+
13
+ The library is only concerned with parsing and encoding and does not provide
14
+ anything related to the connection between the devices and the library system.
15
+
16
+ The parser is built on the excellent Parslet library.
17
+
18
+ ## Basic usage
19
+
20
+ ### As a script
21
+
22
+ Given that `sip2.log` contains only Sip2 messages, separated by `\r` (CR) and
23
+ optionally `\n` (LF).
24
+
25
+ ```
26
+ sip2-to-json < sip2.log > sip2-log.json
27
+
28
+ json-to-sip2 < sip2-log.json > sip2.txt
29
+ ```
30
+
31
+ Normally `sip2.log` and `sip2.txt` should now be semantically identical. (Field
32
+ order might differ.)
33
+
34
+ Typically `sip2-to-json` can be used to view, query and filter logs, while
35
+ `json-to-sip2` is best used in combination with `sip2-to-json` to rewrite
36
+ messages, operating on the JSON representation rather than on the raw strings:
37
+
38
+ ```
39
+ sip2-to-json < sip2.log | your-script-here | json-to-sip2
40
+ ```
41
+
42
+ ### As a library
43
+
44
+ The main interfaces are two methods on the `Sip2` module: `Sip2.parse` and
45
+ `Sip2.encode`.
46
+
47
+ ```ruby
48
+ require 'sip2'
49
+ message = "9900802.00\r"
50
+
51
+ parsed_message = Sip2.parse(message).first
52
+ #=> {:message_code=>"99", :message_name=>"SC Status", :status_code=>0,
53
+ # :max_print_width=>80, :protocol_version=>"2.00"}
54
+
55
+ parsed_message[:max_print_width] = 100
56
+
57
+ Sip2.encode(parsed_message)
58
+ #=> "9901002.00\r"
59
+ ```
60
+
61
+ ## Validation
62
+
63
+ `Sip2.parse` only accepts well formed messages, but handles invalid dates in a
64
+ possibly unexpected way. It enforces *date-like* values, but would accept e.g.
65
+ "2023-02-30" *and convert it* to 2023-03-02. Messages that are not well formed
66
+ will cause a `Parslet::ParseFailed` error to be raised. See [Parslet
67
+ documentation][parslet-docs] for how to get more information from the error.
68
+
69
+ [parslet-docs]: https://kschiess.github.io/parslet/documentation.html
70
+
71
+ `Sip2.encode` enforces the type of all values and outputs valid Sip2
72
+ messages. On most type errors, a `Dry::Struct::Error` will be raised.
73
+
74
+ Unrecognized message types and unrecognized fields for recognized messages are
75
+ accepted, in accordance with the Sip2 standard. The data is captured and can
76
+ round trip between parsing and encoding.
77
+
78
+ ## Error detection and checksums
79
+
80
+ The Sip2 standard allows for some basic error detection using checksums. This
81
+ library does not validate checksums, but can create and update them.
82
+ When integrating with a system that validates and enforces checksums the way
83
+ checksums are calculated must match.
84
+
85
+ The standard is a bit vague on the topic of encodings, and especially for
86
+ checksums, where it is important since characters are "binary summed".
87
+ `Sip2#encode` accepts an optional `checksum_encoder`, a simple lambda that
88
+ accepts a string and returns a string. If supplied, the checksum will be
89
+ calculated on the return value of this lambda applied to the message. (The
90
+ lambda should not alter the original message.) In `Sip2::ChecksumEncoder` a
91
+ couple of encoders are provided (accessible both through their constant names
92
+ and through `#[]`):
93
+
94
+ * `ALMA`: Encodes the message as ASCII, using replacement characters for
95
+ non-ascii values. Matches the (undocumented) behaviour of Ex Libris Alma.
96
+
97
+ * `IDENTITY`: Pass through of the original message. Thus, a message in UTF-8
98
+ will be checksummed as UTF-8, while a message in Latin-1 will be checksummed
99
+ as Latin1. (This is the same as not specifying a `checksum_encoder`.)
100
+
101
+ The pre defined encoders are also available for `json-to-sip2` through the
102
+ command line option `--checksum-encoder`.
103
+
104
+ ## Caveats
105
+
106
+ ### Documentation
107
+
108
+ *Note that the ruby code is currently entirely undocumented.* This is due to
109
+ much of it being dynamically generated from a code representation of the Sip2
110
+ standard, and I simply do not know how to let rdoc or yard pick this up. The
111
+ parts that are static can of course be documented, but as they read or return
112
+ the dynamically generated message classes the documentation would still be
113
+ incomplete.
114
+
115
+ Running `script/sip2-messages-info` will output a JSON representation of all
116
+ known messages and the fields they include. There is also a JSON representation
117
+ of all fields in `doc/sip2_fields.json`. These two sources can be used to some
118
+ extent to see how the mapping between the Sip2 format and the Hash/JSON
119
+ representation looks like.
120
+
121
+ ### Time Zones
122
+
123
+ Time zones are not fully implemented and do not round trip. The Sip2
124
+ specification declares that time zones should be expressed according to ancient
125
+ ANSI standard X3.43, which in its turn refers to X3.51. This standard is
126
+ mentioned in RFC822, which specifies a few two or three letter codes, "military"
127
+ one letter time zone codes, and explicit offset using four digits prefixed with
128
+ `+` or `-`. The prefixed digits will not fit in the designated four character
129
+ segment.
130
+
131
+ In this library only `Z` for "UTC" and ` ` (four spaces, no time zone) are
132
+ round tripped. When parsing, `Z` and `UTC` will be parsed as times with UTC time
133
+ zone, four blanks will be parsed as local time, and military one letter time
134
+ zone codes will be parsed into times with the correct offset but with no
135
+ `Time#zone` set. When encoding, times in UTC will be encoded as `Z`, and all
136
+ other times, including local time, will be encoded without time zone data.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ task default: "test"
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.pattern = 'test/**/*_spec.rb'
8
+ t.libs << "test"
9
+ t.libs << "lib"
10
+ end
data/bin/json-to-sip2 ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../environment'
4
+
5
+ require 'yajl'
6
+ require 'optparse'
7
+ require 'sip2'
8
+ require 'sip2/checksum_encoder'
9
+
10
+ options = {
11
+ checksum_encoder: nil,
12
+ continue_on_error: false,
13
+ debug: false,
14
+ error_detection: false,
15
+ line_by_line: true,
16
+ strip_extra_fields: false,
17
+ }
18
+ OptionParser.new do |opts|
19
+ opts.banner = "Usage: #{$0} [options]"
20
+ opts.separator ""
21
+ opts.separator "Options:"
22
+
23
+ opts.on("-a", "--array", "Read messages from a JSON array.") do
24
+ options[:line_by_line] = false
25
+ end
26
+
27
+ opts.on("-c", "--continue-on-error",
28
+ "When parsing an object fails, continue with the next.",
29
+ "The exit code will still be non-zero.",
30
+ 'This option does not apply when using `--array`.'
31
+ ) do
32
+ options[:continue_on_error] = true
33
+ end
34
+
35
+ opts.on("-C", "--checksum-encoder=<NAME>",
36
+ "Reencode the message with Sip2::ChecksumEncoder::<NAME>",
37
+ "before calculating the checksum, where <NAME> is one of:",
38
+ *Sip2::ChecksumEncoder.constants.map { |c| sprintf(" %s", c) }
39
+ ) do |name|
40
+ options[:checksum_encoder] = name
41
+ end
42
+
43
+ opts.on("-E", "--error-detection",
44
+ "Append error detection fields to the message",
45
+ ) do |name|
46
+ options[:error_detection] = true
47
+ end
48
+
49
+ opts.on("--strip-extra-fields",
50
+ "Remove extra fields that are not part of the specification.",
51
+ ) do
52
+ options[:strip_extra_fields] = true
53
+ end
54
+
55
+ opts.on("-v", "--version", "Print version and exit.") do
56
+ puts Sip2::VERSION
57
+ exit
58
+ end
59
+
60
+ opts.on("-h", "--help", "Display this message.") do
61
+ puts opts
62
+ exit
63
+ end
64
+
65
+ end.parse!
66
+
67
+ UnknownError = Class.new(StandardError)
68
+
69
+ checksum_encoder_name = options[:checksum_encoder] || ENV['SIP2_CHECKSUM_ENCODER']
70
+ checksum_encoder = Sip2::ChecksumEncoder[checksum_encoder_name] if checksum_encoder_name
71
+
72
+ encode = ->(obj) {
73
+ Sip2.encode(
74
+ obj,
75
+ add_error_detection: options[:error_detection],
76
+ checksum_encoder: checksum_encoder,
77
+ strip_extra_fields: options[:strip_extra_fields]
78
+ )
79
+ }
80
+
81
+ if options[:line_by_line]
82
+ error_count = 0
83
+ Yajl.load($stdin) do |obj|
84
+ begin
85
+ puts encode.call(obj)
86
+ rescue Dry::Struct::Error => error
87
+ warn "Invalid object: #{Yajl.dump(obj)}"
88
+ if options[:continue_on_error]
89
+ warn error.message
90
+ error_count += 1
91
+ else
92
+ raise error
93
+ end
94
+ end
95
+ end
96
+ if error_count > 0
97
+ warn sprintf(
98
+ "WARNING: %d object%s could not be parsed",
99
+ error_count,
100
+ error_count == 1 ? "" : "s"
101
+ )
102
+ exit 1
103
+ end
104
+ else
105
+ data = Yajl.load($stdin)
106
+ data.each do |obj|
107
+ puts encode.call(obj)
108
+ end
109
+ end
data/bin/sip2-to-json ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../environment'
4
+
5
+ require 'json'
6
+ require 'optparse'
7
+ require 'sip2'
8
+
9
+ options = {
10
+ continue_on_error: false,
11
+ debug: false,
12
+ line_by_line: true,
13
+ }
14
+ OptionParser.new do |opts|
15
+ opts.banner = "Usage: #{$0} [options]"
16
+ opts.separator ""
17
+ opts.separator "Options:"
18
+
19
+ opts.on("-a", "--all", "Parse all messages into a JSON array.") do
20
+ options[:line_by_line] = false
21
+ end
22
+
23
+ opts.on("-c", "--continue-on-error",
24
+ "When parsing a line fails, continue with the next.",
25
+ "The exit code will still be non-zero.",
26
+ 'This option does not apply when using `--all`.'
27
+ ) do
28
+ options[:continue_on_error] = true
29
+ end
30
+
31
+ opts.on("-D", "--debug", "Report errors with a parse tree.") do
32
+ options[:debug] = true
33
+ end
34
+
35
+ opts.on("-v", "--version", "Print version and exit.") do
36
+ puts Sip2::VERSION
37
+ exit
38
+ end
39
+
40
+ opts.on("-h", "--help", "Display this message.") do
41
+ puts opts
42
+ exit
43
+ end
44
+
45
+ end.parse!
46
+
47
+ if options[:line_by_line]
48
+ begin
49
+ error_count = 0
50
+ ARGF.each_line.with_index(1) do |data,i|
51
+ begin
52
+ puts Sip2.parse(data).first.to_json
53
+ rescue Parslet::ParseFailed => error
54
+ warn "Error parsing line #{i}: #{data.dump}"
55
+ warn error.parse_failure_cause.ascii_tree if options[:debug]
56
+ if options[:continue_on_error]
57
+ error_count += 1
58
+ else
59
+ raise error
60
+ end
61
+ end
62
+ end
63
+ if error_count > 0
64
+ warn sprintf(
65
+ "WARNING: %d line%s could not be parsed",
66
+ error_count,
67
+ error_count == 1 ? "" : "s"
68
+ )
69
+ exit 1
70
+ end
71
+ rescue Errno::EPIPE
72
+ exit 0
73
+ end
74
+ else
75
+ begin
76
+ puts Sip2.parse(ARGF.read).to_json
77
+ rescue Parslet::ParseFailed => error
78
+ warn error.parse_failure_cause.ascii_tree if options[:debug]
79
+ raise error
80
+ end
81
+ end
@@ -0,0 +1,11 @@
1
+ module Sip2
2
+ module ChecksumEncoder
3
+
4
+ def self.[](name)
5
+ const_get(name.to_s.upcase)
6
+ end
7
+
8
+ ALMA = ->(str) { str.encode(Encoding::ASCII, undef: :replace) }
9
+ IDENTITY = ->(x) { x }
10
+ end
11
+ end