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
@@ -0,0 +1,742 @@
1
+ require 'dry-types'
2
+ require 'dry-struct'
3
+ require 'sip2/types'
4
+ module Sip2
5
+
6
+ format_bool = ->(v) { v ? "Y" : "N" }
7
+
8
+ format_bool_nillable = ->(v) { v.nil? ? 'U' : format_bool.(v) }
9
+
10
+ format_bool_with_space = ->(v) { v ? "Y" : " " }
11
+
12
+ format_int_2 = ->(v) { sprintf('%02d', v) }
13
+
14
+ format_int_3 = ->(v) { sprintf('%03d', v) }
15
+
16
+ format_int_4 = ->(v) { sprintf('%04d', v) }
17
+
18
+ format_int_4_or_blank = ->(v) { v.nil? ? " " : sprintf("%04d", v) }
19
+
20
+ format_string = ->(v) { v.to_s }
21
+
22
+ format_coded = ->(code, formatter) {
23
+ ->(v) { sprintf("%s%s|", code, formatter.call(v)) }
24
+ }
25
+
26
+ format_coded_array = ->(code, formatter) {
27
+ ->(v) {
28
+ v.map { |el| format_coded.call(code, formatter).call(el) }
29
+ }
30
+ }
31
+
32
+ format_error_correction = ->(code, formatter) {
33
+ ->(v) { sprintf("%s%s", code, formatter.call(v)) }
34
+ }
35
+
36
+ # TODO: Proper roundtrip of one letter time zones?
37
+ format_timestamp = ->(v) {
38
+ tz = v.utc? ? "Z" : ""
39
+ tz = sprintf("%4s", tz)
40
+ v.strftime("%Y%m%d#{tz}%H%M%S")
41
+ }
42
+
43
+ format_timestamp_or_blanks = ->(v) {
44
+ v ? format_timestamp.(v) : sprintf("%18s", "")
45
+ }
46
+
47
+ FIELDS = {
48
+ acs_renewal_policy: {
49
+ type: Types::Bool,
50
+ format: format_bool,
51
+ },
52
+
53
+ alert: {
54
+ type: Types::Bool,
55
+ format: format_bool,
56
+ },
57
+
58
+ available: {
59
+ type: Types::Bool,
60
+ format: format_bool,
61
+ },
62
+
63
+ blocked_card_msg: {
64
+ code: "AL",
65
+ type: Types::String.constrained(max_size: 255),
66
+ format: format_string,
67
+ },
68
+
69
+ cancel: {
70
+ code: "BI",
71
+ type: Types::Bool,
72
+ format: format_bool,
73
+ },
74
+
75
+ card_retained: {
76
+ type: Types::Bool,
77
+ format: format_bool,
78
+ },
79
+
80
+ charged_items: {
81
+ code: "AU",
82
+ type: Types::String.constrained(max_size: 255),
83
+ format: format_string,
84
+ },
85
+
86
+ charged_items_count: {
87
+ type: Types::Integer.constrained(included_in: 0..9999).optional,
88
+ format: format_int_4_or_blank,
89
+ },
90
+
91
+ charged_items_limit: {
92
+ code: "CB",
93
+ type: Types::Integer.constrained(included_in: 0..9999),
94
+ format: format_int_4,
95
+ },
96
+
97
+ checkin_ok: {
98
+ type: Types::Bool,
99
+ format: format_bool,
100
+ },
101
+
102
+ checkout_ok: {
103
+ type: Types::Bool,
104
+ format: format_bool,
105
+ },
106
+
107
+ checksum: {
108
+ code: "AZ",
109
+ type: Types::String.constrained(format: /^[0-9A-Fa-f]{4,4}$/),
110
+ format: format_string,
111
+ },
112
+
113
+ circulation_status: {
114
+ type: Types::Integer.constrained(included_in: 0..99),
115
+ format: format_int_2,
116
+ },
117
+
118
+ currency_type: {
119
+ code: "BH",
120
+ type: Types::String.constrained(size: 3),
121
+ format: format_string,
122
+ },
123
+
124
+ currency_type_ordered: {
125
+ name: :currency_type,
126
+ type: Types::String.constrained(size: 3),
127
+ format: format_string,
128
+ },
129
+
130
+ current_location: {
131
+ code: "AP",
132
+ type: Types::String.constrained(max_size: 255),
133
+ format: format_string,
134
+ },
135
+
136
+ date_time_sync: {
137
+ type: Types::JSON::Time,
138
+ format: format_timestamp,
139
+ },
140
+
141
+ desensitize: {
142
+ type: Types::Bool.optional,
143
+ format: format_bool_nillable,
144
+ },
145
+
146
+ due_date: {
147
+ code: "AH",
148
+ type: Types::String.constrained(max_size: 255),
149
+ format: format_string,
150
+ },
151
+
152
+ email_address: {
153
+ code: "BE",
154
+ type: Types::String.constrained(max_size: 255),
155
+ format: format_string,
156
+ },
157
+
158
+ end_item: {
159
+ code: "BQ",
160
+ type: Types::String.constrained(max_size: 255),
161
+ format: format_string,
162
+ },
163
+
164
+ end_session: {
165
+ type: Types::Bool,
166
+ format: format_bool,
167
+ },
168
+
169
+ expiration_date: {
170
+ code: "BW",
171
+ type: Types::JSON::Time,
172
+ format: format_timestamp,
173
+ },
174
+
175
+ fee_acknowledged: {
176
+ code: "BO",
177
+ type: Types::Bool,
178
+ format: format_bool,
179
+ },
180
+
181
+ fee_amount: {
182
+ code: "BV",
183
+ type: Types::String.constrained(max_size: 255),
184
+ format: format_string,
185
+ },
186
+
187
+ fee_identifier: {
188
+ code: "CG",
189
+ type: Types::String.constrained(max_size: 255),
190
+ format: format_string,
191
+ },
192
+
193
+ fee_limit: {
194
+ code: "CC",
195
+ type: Types::String.constrained(max_size: 255),
196
+ format: format_string,
197
+ },
198
+
199
+ fee_type: {
200
+ code: "BT",
201
+ type: Types::Integer.constrained(included_in: 1..99),
202
+ format: format_int_2,
203
+ },
204
+
205
+ fee_type_ordered: {
206
+ name: :fee_type,
207
+ type: Types::Integer.constrained(included_in: 1..99),
208
+ format: format_int_2,
209
+ },
210
+
211
+ fine_items: {
212
+ code: "AV",
213
+ type: Types::String.constrained(max_size: 255),
214
+ format: format_string,
215
+ },
216
+
217
+ fine_items_count: {
218
+ type: Types::Integer.constrained(included_in: 0..9999).optional,
219
+ format: format_int_4_or_blank,
220
+ },
221
+
222
+ hold_items: {
223
+ code: "AS",
224
+ type: Types::String.constrained(max_size: 255),
225
+ format: format_string,
226
+ },
227
+
228
+ hold_items_count: {
229
+ type: Types::Integer.constrained(included_in: 0..9999).optional,
230
+ format: format_int_4_or_blank,
231
+ },
232
+
233
+ hold_items_limit: {
234
+ code: "BZ",
235
+ type: Types::Integer.constrained(included_in: 0..9999),
236
+ format: format_int_4,
237
+ },
238
+
239
+ hold_mode: {
240
+ type: Types::String.constrained(size: 1),
241
+ format: format_string,
242
+ },
243
+
244
+ hold_pickup_date: {
245
+ code: "CM",
246
+ type: Types::JSON::Time,
247
+ format: format_timestamp,
248
+ },
249
+
250
+ hold_queue_length: {
251
+ code: "CF",
252
+ type: Types::String.constrained(max_size: 255),
253
+ format: format_string,
254
+ },
255
+
256
+ hold_type: {
257
+ code: "BY",
258
+ type: Types::Integer.constrained(included_in: 1..9),
259
+ format: format_string,
260
+ },
261
+
262
+ home_address: {
263
+ code: "BD",
264
+ type: Types::String.constrained(max_size: 255),
265
+ format: format_string,
266
+ },
267
+
268
+ home_phone_number: {
269
+ code: "BF",
270
+ type: Types::String.constrained(max_size: 255),
271
+ format: format_string,
272
+ },
273
+
274
+ institution_id: {
275
+ code: "AO",
276
+ type: Types::String.constrained(max_size: 255),
277
+ format: format_string,
278
+ },
279
+
280
+ item_identifier: {
281
+ code: "AB",
282
+ type: Types::String.constrained(max_size: 255),
283
+ format: format_string,
284
+ },
285
+
286
+ item_properties: {
287
+ code: "CH",
288
+ type: Types::String.constrained(max_size: 255),
289
+ format: format_string,
290
+ },
291
+
292
+ item_properties_ok: {
293
+ type: Types::String.constrained(size: 1),
294
+ format: format_string,
295
+ },
296
+
297
+ # The :items key is defined *after* later as it refers to other keys.
298
+ # the hash.
299
+ #
300
+ #items: {}
301
+
302
+ language: {
303
+ type: Types::Integer.constrained(included_in: (0..999)),
304
+ format: format_int_3,
305
+ },
306
+
307
+ library_name: {
308
+ code: "AM",
309
+ type: Types::String.constrained(max_size: 255),
310
+ format: format_string,
311
+ },
312
+
313
+ location_code: {
314
+ code: "CP",
315
+ type: Types::String.constrained(max_size: 255),
316
+ format: format_string,
317
+ },
318
+
319
+ login_password: {
320
+ code: "CO",
321
+ type: Types::String.constrained(max_size: 255),
322
+ format: format_string,
323
+ },
324
+
325
+ login_user_id: {
326
+ code: "CN",
327
+ type: Types::String.constrained(max_size: 255),
328
+ format: format_string,
329
+ },
330
+
331
+ magnetic_media: {
332
+ type: Types::Bool.optional,
333
+ format: format_bool_nillable,
334
+ },
335
+
336
+ max_print_width: {
337
+ type: Types::Integer.constrained(included_in: 0..999),
338
+ format: format_int_3,
339
+ },
340
+
341
+ media_type: {
342
+ code: "CK",
343
+ type: Types::Integer.constrained(included_in: 0..999),
344
+ format: format_int_3,
345
+ },
346
+
347
+ nb_due_date: {
348
+ type: Types::JSON::Time.optional,
349
+ format: format_timestamp_or_blanks,
350
+ },
351
+
352
+ no_block: {
353
+ type: Types::Bool,
354
+ format: format_bool,
355
+ },
356
+
357
+ offline_ok: {
358
+ type: Types::Bool,
359
+ format: format_bool,
360
+ },
361
+
362
+ ok: {
363
+ type: Types::Bool,
364
+ format: ->(v) { v ? "1" : "0" },
365
+ },
366
+
367
+ online_status: {
368
+ type: Types::Bool,
369
+ format: format_bool,
370
+ },
371
+
372
+ overdue_items: {
373
+ code: "AT",
374
+ type: Types::String.constrained(max_size: 255),
375
+ format: format_string,
376
+ },
377
+
378
+ overdue_items_count: {
379
+ type: Types::Integer.constrained(included_in: 0..9999).optional,
380
+ format: format_int_4_or_blank,
381
+ },
382
+
383
+ overdue_items_limit: {
384
+ code: "CA",
385
+ type: Types::Integer.constrained(included_in: 0..9999),
386
+ format: format_int_4,
387
+ },
388
+
389
+ owner: {
390
+ code: "BG",
391
+ type: Types::String.constrained(max_size: 255),
392
+ format: format_string,
393
+ },
394
+
395
+ patron_identifier: {
396
+ code: "AA",
397
+ type: Types::String.constrained(max_size: 255),
398
+ format: format_string,
399
+ },
400
+
401
+ patron_password: {
402
+ code: "AD",
403
+ type: Types::String.constrained(max_size: 255),
404
+ format: format_string,
405
+ },
406
+
407
+ patron_status: {
408
+ type: {
409
+ charge_privileges_denied: Types::Bool,
410
+ renewal_privileges_denied: Types::Bool,
411
+ recall_privileges_denied: Types::Bool,
412
+ hold_privileges_denied: Types::Bool,
413
+ card_reported_lost: Types::Bool,
414
+ too_many_items_charged: Types::Bool,
415
+ too_many_items_overdue: Types::Bool,
416
+ too_many_renewals: Types::Bool,
417
+ too_many_claims_of_items_returned: Types::Bool,
418
+ too_many_items_lost: Types::Bool,
419
+ excessive_outstanding_fines: Types::Bool,
420
+ excessive_outstanding_fees: Types::Bool,
421
+ recall_overdue: Types::Bool,
422
+ too_many_items_billed: Types::Bool,
423
+ },
424
+ format: ->(v) {
425
+ v.attributes.map { |_,b| format_bool_with_space.call(b) }
426
+ },
427
+ },
428
+
429
+ payment_accepted: {
430
+ type: Types::Bool,
431
+ format: format_bool,
432
+ },
433
+
434
+ payment_type: {
435
+ type: Types::Integer.constrained(included_in: 0..99),
436
+ format: format_int_2,
437
+ },
438
+
439
+ permanent_location: {
440
+ code: "AQ",
441
+ type: Types::String.constrained(max_size: 255),
442
+ format: format_string,
443
+ },
444
+
445
+ personal_name: {
446
+ code: "AE",
447
+ type: Types::String.constrained(max_size: 255),
448
+ format: format_string,
449
+ },
450
+
451
+ pickup_location: {
452
+ code: "BS",
453
+ type: Types::String.constrained(max_size: 255),
454
+ format: format_string,
455
+ },
456
+
457
+ print_line: {
458
+ code: "AG",
459
+ type: Types::Array.of(Types::String.constrained(max_size: 255)),
460
+ format: format_string,
461
+ },
462
+
463
+ protocol_version: {
464
+ type: Types::String.constrained(format: /^\d\.\d\d$/),
465
+ format: format_string,
466
+ },
467
+
468
+ pwd_algorithm: {
469
+ type: Types::String.constrained(size: 1),
470
+ format: format_string,
471
+ },
472
+
473
+ queue_position: {
474
+ code: "BR",
475
+ type: Types::Integer.constrained(gteq: 0).optional,
476
+ format: format_string,
477
+ },
478
+
479
+ recall_date: {
480
+ code: "CJ",
481
+ type: Types::JSON::Time,
482
+ format: format_timestamp,
483
+ },
484
+
485
+ recall_items: {
486
+ code: "BU",
487
+ type: Types::String.constrained(max_size: 255),
488
+ format: format_string,
489
+ },
490
+
491
+ recall_items_count: {
492
+ type: Types::Integer.constrained(included_in: 0..9999).optional,
493
+ format: format_int_4_or_blank,
494
+ },
495
+
496
+ renewal_ok: {
497
+ type: Types::Bool,
498
+ format: format_bool,
499
+ },
500
+
501
+ renewed_count: {
502
+ type: Types::Integer.constrained(included_in: 0..9999),
503
+ format: format_int_4_or_blank,
504
+ },
505
+
506
+ renewed_items: {
507
+ code: "BM",
508
+ type: Types::String.constrained(max_size: 255),
509
+ format: format_string,
510
+ },
511
+
512
+ resensitize: {
513
+ type: Types::Bool,
514
+ format: format_bool,
515
+ },
516
+
517
+ retries_allowed: {
518
+ type: Types::Integer.constrained(included_in: 0..999),
519
+ format: format_int_3,
520
+ },
521
+
522
+ return_date: {
523
+ type: Types::JSON::Time,
524
+ format: format_timestamp,
525
+ },
526
+
527
+ sc_renewal_policy: {
528
+ type: Types::Bool,
529
+ format: format_bool,
530
+ },
531
+
532
+ screen_message: {
533
+ code: "AF",
534
+ type: Types::Array.of(Types::String.constrained(max_size: 255)),
535
+ format: format_string,
536
+ },
537
+
538
+ security_inhibit: {
539
+ code: "CI",
540
+ type: Types::Bool,
541
+ format: format_bool,
542
+ },
543
+
544
+ security_marker: {
545
+ type: Types::Integer.constrained(included_in: 0..99),
546
+ format: format_int_2,
547
+ },
548
+
549
+ sequence_number: {
550
+ code: "AY",
551
+ type: Types::Integer.constrained(included_in: 0..9),
552
+ format: format_string,
553
+ },
554
+
555
+ sort_bin: {
556
+ code: "CL",
557
+ type: Types::String.constrained(max_size: 255),
558
+ format: format_string,
559
+ },
560
+
561
+ start_item: {
562
+ code: "BP",
563
+ type: Types::String.constrained(max_size: 255),
564
+ format: format_string,
565
+ },
566
+
567
+ status_code: {
568
+ type: Types::Integer.constrained(included_in: 0..2),
569
+ format: format_string,
570
+ },
571
+
572
+ status_update_ok: {
573
+ type: Types::Bool,
574
+ format: format_bool,
575
+ },
576
+
577
+ summary: {
578
+ type: {
579
+ hold_items: Types::Bool,
580
+ overdue_items: Types::Bool,
581
+ charged_items: Types::Bool,
582
+ fine_items: Types::Bool,
583
+ recall_items: Types::Bool,
584
+ unavailable_holds: Types::Bool,
585
+ },
586
+ format: ->(v) {
587
+ v.attributes.map { |_,b| format_bool_with_space.call(b) }.join + " "*4
588
+ },
589
+ },
590
+
591
+ supported_messages: {
592
+ code: "BX",
593
+ type: {
594
+ patron_status_request: Types::Bool,
595
+ checkout: Types::Bool,
596
+ checkin: Types::Bool,
597
+ block_patron: Types::Bool,
598
+ sc_acs_status: Types::Bool,
599
+ request_sc_asc_resend: Types::Bool,
600
+ login: Types::Bool,
601
+ patron_information: Types::Bool,
602
+ end_patron_session: Types::Bool,
603
+ fee_paid: Types::Bool,
604
+ item_information: Types::Bool,
605
+ item_status_update: Types::Bool,
606
+ patron_enable: Types::Bool,
607
+ hold: Types::Bool,
608
+ renew: Types::Bool,
609
+ renew_all: Types::Bool,
610
+ },
611
+ format: ->(v) {
612
+ v.attributes.map { |_,b| format_bool.call(b) }.join
613
+ },
614
+ },
615
+
616
+ terminal_location: {
617
+ code: "AN",
618
+ type: Types::String.constrained(max_size: 255),
619
+ format: format_string,
620
+ },
621
+
622
+ terminal_password: {
623
+ code: "AC",
624
+ type: Types::String.constrained(max_size: 255),
625
+ format: format_string,
626
+ },
627
+
628
+ third_party_allowed: {
629
+ type: Types::Bool,
630
+ format: format_bool,
631
+ },
632
+
633
+ timeout_period: {
634
+ type: Types::Integer.constrained(included_in: 0..999),
635
+ format: format_int_3,
636
+ },
637
+
638
+ title_identifier: {
639
+ code: "AJ",
640
+ type: Types::String.constrained(max_size: 255),
641
+ format: format_string,
642
+ },
643
+
644
+ transaction_date: {
645
+ type: Types::JSON::Time,
646
+ format: format_timestamp,
647
+ },
648
+
649
+ transaction_id: {
650
+ code: "BK",
651
+ type: Types::String.constrained(max_size: 255),
652
+ format: format_string,
653
+ },
654
+
655
+ uid_algorithm: {
656
+ type: Types::String.constrained(size: 1),
657
+ format: format_string,
658
+ },
659
+
660
+ unavailable_holds_count: {
661
+ type: Types::Integer.constrained(included_in: 0..9999).optional,
662
+ format: format_int_4_or_blank,
663
+ },
664
+
665
+ unavailable_hold_items: {
666
+ code: "CD",
667
+ type: Types::String.constrained(max_size: 255),
668
+ format: format_string,
669
+ },
670
+
671
+ unrenewed_count: {
672
+ type: Types::Integer.constrained(included_in: 0..9999),
673
+ format: format_int_4,
674
+ },
675
+
676
+ unrenewed_items: {
677
+ code: "BN",
678
+ type: Types::String.constrained(max_size: 255),
679
+ format: format_string,
680
+ },
681
+
682
+ valid_patron: {
683
+ code: "BL",
684
+ type: Types::Bool,
685
+ format: format_bool,
686
+ },
687
+
688
+ valid_patron_password: {
689
+ code: "CQ",
690
+ type: Types::Bool,
691
+ format: format_bool,
692
+ },
693
+
694
+ }
695
+
696
+ items_subfields = %i[
697
+ hold_items
698
+ overdue_items
699
+ charged_items
700
+ fine_items
701
+ recall_items
702
+ unavailable_hold_items
703
+ ]
704
+
705
+ FIELDS[:items] = {
706
+ type: items_subfields
707
+ .map { |f|
708
+ Class.new(Dry::Struct) do
709
+ transform_keys(&:to_sym)
710
+ attribute f, Types::Array.of(FIELDS.fetch(f).fetch(:type))
711
+ end
712
+ }
713
+ .reduce { |res,type| res | type },
714
+ format: ->(outer) {
715
+ outer.attributes.map { |subfield,ary|
716
+ subfield_info = FIELDS.fetch(subfield)
717
+ formatter = subfield_info.fetch(:format)
718
+ ary.map { |inner| formatter.call(inner) }.join
719
+ }.join
720
+ }
721
+ }
722
+
723
+ FIELDS.each do |k,v|
724
+ code = v.fetch(:code,"")
725
+ original_formatter = v.fetch(:format)
726
+
727
+ final_formatter =
728
+ if [:sequence_number, :checksum].include?(k)
729
+ format_error_correction.call(code, original_formatter)
730
+ elsif [:print_line, :screen_message].include?(k)
731
+ format_coded_array.call(code, original_formatter)
732
+ elsif code != ""
733
+ format_coded.call(code, original_formatter)
734
+ else
735
+ original_formatter
736
+ end
737
+
738
+ FIELDS[k] = v.merge(format: final_formatter)
739
+ end
740
+
741
+
742
+ end