oai 0.0.1 → 1.3.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 (99) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +128 -0
  3. data/Rakefile +64 -0
  4. data/bin/oai +66 -0
  5. data/examples/models/file_model.rb +63 -0
  6. data/examples/providers/dublin_core.rb +474 -0
  7. data/lib/oai/{get_record.rb → client/get_record.rb} +5 -1
  8. data/lib/oai/client/header.rb +18 -0
  9. data/lib/oai/{identify.rb → client/identify.rb} +2 -0
  10. data/lib/oai/{list_identifiers.rb → client/list_identifiers.rb} +1 -0
  11. data/lib/oai/{list_records.rb → client/list_records.rb} +6 -5
  12. data/lib/oai/{list_sets.rb → client/list_sets.rb} +7 -6
  13. data/lib/oai/client/record.rb +27 -0
  14. data/lib/oai/client/response.rb +53 -0
  15. data/lib/oai/client/resumable.rb +42 -0
  16. data/lib/oai/client.rb +313 -108
  17. data/lib/oai/constants.rb +34 -0
  18. data/lib/oai/exception.rb +80 -1
  19. data/lib/oai/harvester/config.rb +41 -0
  20. data/lib/oai/harvester/harvest.rb +162 -0
  21. data/lib/oai/harvester/logging.rb +68 -0
  22. data/lib/oai/harvester/mailer.rb +17 -0
  23. data/lib/oai/harvester/shell.rb +337 -0
  24. data/lib/oai/harvester.rb +43 -0
  25. data/lib/oai/provider/metadata_format/oai_dc.rb +28 -0
  26. data/lib/oai/provider/metadata_format.rb +119 -0
  27. data/lib/oai/provider/model/activerecord_caching_wrapper.rb +129 -0
  28. data/lib/oai/provider/model/activerecord_wrapper.rb +217 -0
  29. data/lib/oai/provider/model.rb +80 -0
  30. data/lib/oai/provider/partial_result.rb +18 -0
  31. data/lib/oai/provider/response/error.rb +16 -0
  32. data/lib/oai/provider/response/get_record.rb +27 -0
  33. data/lib/oai/provider/response/identify.rb +37 -0
  34. data/lib/oai/provider/response/list_identifiers.rb +30 -0
  35. data/lib/oai/provider/response/list_metadata_formats.rb +39 -0
  36. data/lib/oai/provider/response/list_records.rb +47 -0
  37. data/lib/oai/provider/response/list_sets.rb +23 -0
  38. data/lib/oai/provider/response/record_response.rb +103 -0
  39. data/lib/oai/provider/response.rb +133 -0
  40. data/lib/oai/provider/resumption_token.rb +155 -0
  41. data/lib/oai/provider.rb +487 -0
  42. data/lib/oai/set.rb +14 -5
  43. data/lib/oai/xpath.rb +59 -5
  44. data/lib/oai.rb +7 -13
  45. data/lib/test.rb +25 -0
  46. data/test/activerecord_provider/config/connection.rb +30 -0
  47. data/test/activerecord_provider/database/0001_oaipmh_tables.rb +70 -0
  48. data/test/activerecord_provider/fixtures/dc.yml +1501 -0
  49. data/test/activerecord_provider/helpers/providers.rb +68 -0
  50. data/test/activerecord_provider/helpers/set_provider.rb +24 -0
  51. data/test/activerecord_provider/helpers/transactional_test_case.rb +35 -0
  52. data/test/activerecord_provider/models/dc_field.rb +15 -0
  53. data/test/activerecord_provider/models/dc_lang.rb +3 -0
  54. data/test/activerecord_provider/models/dc_set.rb +7 -0
  55. data/test/activerecord_provider/models/exclusive_set_dc_field.rb +17 -0
  56. data/test/activerecord_provider/models/oai_token.rb +3 -0
  57. data/test/activerecord_provider/tc_activerecord_wrapper.rb +63 -0
  58. data/test/activerecord_provider/tc_ar_provider.rb +199 -0
  59. data/test/activerecord_provider/tc_ar_sets_provider.rb +159 -0
  60. data/test/activerecord_provider/tc_caching_paging_provider.rb +50 -0
  61. data/test/activerecord_provider/tc_simple_paging_provider.rb +71 -0
  62. data/test/activerecord_provider/test_helper_ar_provider.rb +7 -0
  63. data/test/client/helpers/provider.rb +65 -0
  64. data/test/client/helpers/test_wrapper.rb +6 -0
  65. data/test/client/tc_exception.rb +36 -0
  66. data/test/client/tc_get_record.rb +39 -0
  67. data/test/client/tc_http_client.rb +129 -0
  68. data/test/client/tc_identify.rb +13 -0
  69. data/test/client/tc_libxml.rb +61 -0
  70. data/test/{tc_list_identifiers.rb → client/tc_list_identifiers.rb} +28 -9
  71. data/test/{tc_list_metadata_formats.rb → client/tc_list_metadata_formats.rb} +4 -1
  72. data/test/client/tc_list_records.rb +26 -0
  73. data/test/client/tc_list_sets.rb +34 -0
  74. data/test/client/tc_low_resolution_dates.rb +14 -0
  75. data/test/client/tc_utf8_escaping.rb +19 -0
  76. data/test/client/tc_xpath.rb +26 -0
  77. data/test/client/test_helper_client.rb +5 -0
  78. data/test/harvester/tc_harvest.rb +42 -0
  79. data/test/harvester/test_helper_harvester.rb +6 -0
  80. data/test/provider/models.rb +246 -0
  81. data/test/provider/tc_exceptions.rb +81 -0
  82. data/test/provider/tc_functional_tokens.rb +53 -0
  83. data/test/provider/tc_instance_provider.rb +41 -0
  84. data/test/provider/tc_provider.rb +136 -0
  85. data/test/provider/tc_resumption_tokens.rb +52 -0
  86. data/test/provider/tc_simple_provider.rb +138 -0
  87. data/test/provider/test_helper_provider.rb +62 -0
  88. data/test/test.xml +22 -0
  89. metadata +238 -56
  90. data/lib/oai/header.rb +0 -12
  91. data/lib/oai/record.rb +0 -11
  92. data/lib/oai/response.rb +0 -20
  93. data/test/tc_get_record.rb +0 -27
  94. data/test/tc_identify.rb +0 -8
  95. data/test/tc_list_records.rb +0 -9
  96. data/test/tc_list_sets.rb +0 -17
  97. data/test.rb +0 -12
  98. /data/lib/oai/{list_metadata_formats.rb → client/list_metadata_formats.rb} +0 -0
  99. /data/lib/oai/{metadata_format.rb → client/metadata_format.rb} +0 -0
@@ -0,0 +1,39 @@
1
+ module OAI::Provider::Response
2
+ class ListMetadataFormats < RecordResponse
3
+ valid_parameters :identifier
4
+
5
+ def to_xml
6
+ # Get a list of all the formats the provider understands.
7
+ formats = provider.formats.values
8
+
9
+ # if it's a doc-specific request
10
+ if options.include?(:identifier)
11
+ id = extract_identifier(options[:identifier])
12
+ unless record = provider.model.find(id, options)
13
+ raise OAI::IdException.new
14
+ end
15
+
16
+ # Remove any format that this particular record can't be provided in.
17
+ formats.reject! { |f| !record_supports(record, f.prefix) }
18
+ end
19
+ response do |r|
20
+ r.ListMetadataFormats do
21
+ formats.each do |format|
22
+ r.metadataFormat do
23
+ r.metadataPrefix format.prefix
24
+ r.schema format.schema
25
+ r.metadataNamespace format.namespace
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ def record_supports(record, prefix)
33
+ prefix == 'oai_dc' or
34
+ record.respond_to?("to_#{prefix}") or
35
+ record.respond_to?("map_#{prefix}")
36
+ end
37
+
38
+ end
39
+ end
@@ -0,0 +1,47 @@
1
+ module OAI::Provider::Response
2
+
3
+ class ListRecords < RecordResponse
4
+ required_parameters :metadata_prefix
5
+
6
+ def valid?
7
+ super && matching_granularity?
8
+ end
9
+
10
+ def matching_granularity?
11
+ if options[:from].nil? == false && options[:until].nil? == false && options[:from].class.name != options[:until].class.name
12
+ raise OAI::ArgumentException.new, "The 'from' and 'until' options specified must have the same granularity"
13
+ else
14
+ true
15
+ end
16
+ end
17
+
18
+ def to_xml
19
+ result = provider.model.find(:all, options)
20
+ # result may be an array of records, or a partial result
21
+ records = result.respond_to?(:records) ? result.records : result
22
+
23
+ raise OAI::NoMatchException.new if records.nil? or records.empty?
24
+
25
+ response do |r|
26
+ r.ListRecords do
27
+ records.each do |rec|
28
+ r.record do
29
+ header_for rec
30
+ data_for rec unless deleted?(rec)
31
+ about_for rec unless deleted?(rec)
32
+ end
33
+ end
34
+
35
+ # append resumption token for getting next group of records
36
+ if result.respond_to?(:token)
37
+ r.target! << result.token.to_xml
38
+ end
39
+
40
+ end
41
+ end
42
+ end
43
+
44
+ end
45
+
46
+ end
47
+
@@ -0,0 +1,23 @@
1
+ module OAI::Provider::Response
2
+
3
+ class ListSets < Base
4
+
5
+ def to_xml
6
+ raise OAI::SetException.new unless provider.model.sets
7
+
8
+ response do |r|
9
+ r.ListSets do
10
+ provider.model.sets.each do |set|
11
+ r.set do
12
+ r.setSpec set.spec
13
+ r.setName set.name
14
+ r.setDescription(set.description) if set.respond_to?(:description)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ end
22
+
23
+ end
@@ -0,0 +1,103 @@
1
+ module OAI::Provider::Response
2
+ class RecordResponse < Base
3
+ def self.inherited(klass)
4
+ klass.valid_parameters :metadata_prefix, :from, :until, :set
5
+ klass.default_parameters :metadata_prefix => "oai_dc",
6
+ :from => method(:default_from).to_proc,
7
+ :until => method(:default_until).to_proc
8
+ end
9
+
10
+ def self.default_from(response)
11
+ value = Time.parse(response.provider.model.earliest.to_s).utc
12
+ if response.options[:until]
13
+ u = parse_date(response.options[:until])
14
+ value = value.to_date if u.is_a? Date
15
+ end
16
+ value
17
+ end
18
+
19
+ def self.default_until(response)
20
+ value = Time.parse(response.provider.model.latest.to_s).utc
21
+ if response.options[:from]
22
+ f = parse_date(response.options[:from])
23
+ value = value.to_date if f.is_a? Date
24
+ end
25
+ value
26
+ end
27
+
28
+ # emit record header
29
+ def header_for(record)
30
+ param = Hash.new
31
+ param[:status] = 'deleted' if deleted?(record)
32
+ @builder.header param do
33
+ @builder.identifier identifier_for(record)
34
+ @builder.datestamp timestamp_for(record)
35
+ sets_for(record).each do |set|
36
+ @builder.setSpec set.spec
37
+ end
38
+ end
39
+ end
40
+ # metadata - core routine for delivering metadata records
41
+ #
42
+ def data_for(record)
43
+ @builder.metadata do
44
+ @builder.target! << provider.format(requested_format).encode(provider.model, record)
45
+ end
46
+ end
47
+
48
+ # about - core routine for delivering about records
49
+ #
50
+ def about_for(record)
51
+ return unless provider.model.respond_to? :about
52
+
53
+ about = provider.model.about(record)
54
+ return if about.nil?
55
+
56
+ unless about.is_a? Array
57
+ about = [about]
58
+ end
59
+
60
+ about.each do |a|
61
+ @builder.about do
62
+ @builder.target! << a
63
+ end
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # Namespace syntax suggested in http://www.openarchives.org/OAI/2.0/guidelines-oai-identifier.htm
70
+ def identifier_for(record)
71
+ "#{provider.prefix}:#{record.send( provider.model.identifier_field )}"
72
+ end
73
+
74
+ def timestamp_for(record)
75
+ record.send(provider.model.timestamp_field).utc.xmlschema
76
+ end
77
+
78
+ def sets_for(record)
79
+ return [] unless record.respond_to?(:sets) and record.sets
80
+ record.sets.respond_to?(:each) ? record.sets : [record.sets]
81
+ end
82
+
83
+ def requested_format
84
+ format =
85
+ if options[:metadata_prefix]
86
+ options[:metadata_prefix]
87
+ elsif options[:resumption_token]
88
+ OAI::Provider::ResumptionToken.extract_format(options[:resumption_token])
89
+ end
90
+ raise OAI::FormatException.new unless provider.format_supported?(format)
91
+
92
+ format
93
+ end
94
+
95
+ def deleted?(record)
96
+ return record.deleted? if record.respond_to?(:deleted?)
97
+ return record.deleted if record.respond_to?(:deleted)
98
+ return record.deleted_at if record.respond_to?(:deleted_at)
99
+ false
100
+ end
101
+
102
+ end
103
+ end
@@ -0,0 +1,133 @@
1
+ require 'builder' unless defined?(Builder)
2
+ module OAI
3
+ module Provider
4
+ module Response
5
+
6
+ class Base
7
+ attr_reader :provider, :options
8
+
9
+ class << self
10
+ attr_reader :valid_options, :default_options, :required_options
11
+ def valid_parameters(*args)
12
+ @valid_options ||= []
13
+ @valid_options = (@valid_options + args.dup).uniq
14
+ end
15
+
16
+ def default_parameters(options = {})
17
+ @default_options ||= {}
18
+ @default_options.merge! options.dup
19
+ end
20
+
21
+ def required_parameters(*args)
22
+ valid_parameters(*args)
23
+ @required_options ||= []
24
+ @required_options = (@required_options + args.dup).uniq
25
+ end
26
+
27
+ end
28
+ def initialize(provider, options = {})
29
+ @provider = provider
30
+ @request_options = options.dup
31
+ @options = internalize(options)
32
+ raise OAI::ArgumentException.new unless valid?
33
+ end
34
+ def response
35
+ @builder = Builder::XmlMarkup.new
36
+ @builder.instruct! :xml, :version=>"1.0", :encoding=>"UTF-8"
37
+ @builder.tag!('OAI-PMH', header) do
38
+ @builder.responseDate Time.now.utc.xmlschema
39
+ @builder.request(provider.url, (@request_options.merge(:verb => verb) unless self.class == Error))
40
+ yield @builder
41
+ end
42
+ end
43
+
44
+ protected
45
+
46
+ def self.parse_date(value)
47
+ return value if value.respond_to?(:strftime)
48
+
49
+ if value[-1] == "Z"
50
+ Time.strptime(value, "%Y-%m-%dT%H:%M:%S%Z").utc
51
+ else
52
+ Date.strptime(value, "%Y-%m-%d")
53
+ end
54
+ rescue ArgumentError => e
55
+ raise OAI::ArgumentException.new, "unparsable date: '#{value}'"
56
+ end
57
+
58
+ private
59
+
60
+ def header
61
+ {
62
+ 'xmlns' => "http://www.openarchives.org/OAI/2.0/",
63
+ 'xmlns:xsi' => "http://www.w3.org/2001/XMLSchema-instance",
64
+ 'xsi:schemaLocation' => %{http://www.openarchives.org/OAI/2.0/
65
+ http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd}.gsub(/\s+/, ' ')
66
+ }
67
+ end
68
+ def extract_identifier(id)
69
+ id.sub("#{provider.prefix}:", '')
70
+ end
71
+
72
+ def valid?
73
+ return true if resumption?
74
+
75
+ return true if self.class.valid_options.nil? and options.empty?
76
+
77
+ # check if the request includes an argument and there are no valid
78
+ # arguments for that verb (Identify, for example).
79
+ raise OAI::ArgumentException.new if self.class.valid_options.nil? && !options.empty?
80
+
81
+ if self.class.required_options
82
+ return false unless (self.class.required_options - @options.keys).empty?
83
+ end
84
+ return false unless (@options.keys - self.class.valid_options).empty?
85
+ populate_defaults
86
+ end
87
+
88
+ def populate_defaults
89
+ self.class.default_options.each do |k,v|
90
+ @options[k] = v.respond_to?(:call) ? v.call(self) : v if not @options[k]
91
+ end
92
+ end
93
+
94
+ def resumption?
95
+ if @options.keys.include?(:resumption_token)
96
+ return true if 1 == @options.keys.size
97
+ raise OAI::ArgumentException.new
98
+ end
99
+ end
100
+
101
+ # Convert our internal representations back into standard OAI options
102
+ def externalize(value)
103
+ value.to_s.gsub(/_[a-z]/) { |m| m.sub("_", '').capitalize }
104
+ end
105
+
106
+ def parse_date(value)
107
+ self.class.parse_date(value)
108
+ end
109
+
110
+ def internalize(hash = {})
111
+ internal = {}
112
+ hash.keys.each do |key|
113
+ internal[key.to_s.gsub(/([A-Z])/, '_\1').downcase.intern] = hash[key].dup
114
+ end
115
+
116
+ # Convert date formated strings into internal time values
117
+ # Convert date formated strings in dates.
118
+ internal[:from] = parse_date(internal[:from]) if internal[:from]
119
+ internal[:until] = parse_date(internal[:until]) if internal[:until]
120
+
121
+ internal
122
+ end
123
+
124
+ def verb
125
+ self.class.to_s.split('::').last
126
+ end
127
+
128
+ end
129
+
130
+ end
131
+ end
132
+ end
133
+
@@ -0,0 +1,155 @@
1
+ require 'time'
2
+ require File.dirname(__FILE__) + "/partial_result"
3
+
4
+ module OAI::Provider
5
+ # = OAI::Provider::ResumptionToken
6
+ #
7
+ # The ResumptionToken class forms the basis of paging query results. It
8
+ # provides several helper methods for dealing with resumption tokens.
9
+ #
10
+ # OAI-PMH spec does not specify anything about resumptionToken format, they can
11
+ # be purely opaque tokens.
12
+ #
13
+ # Our implementation however encodes everything needed to construct the next page
14
+ # inside the resumption token.
15
+ #
16
+ # == The 'last' component: offset or ID/pk to resume from
17
+ #
18
+ # The `#last` component is an offset or ID to resume from. In the case of it being
19
+ # an ID to resume from, this assumes that ID's are sortable and results are returned
20
+ # in ID order, so that the 'last' ID can be used as the place to resume from.
21
+ #
22
+ # Originally it was assumed that #last was always an integer, but since existing
23
+ # implementations (like ActiveRecordWrapper) used it as an ID, and identifiers and
24
+ # primary keys are _not_ always integers (can be UUID etc), we have expanded to allow
25
+ # any string value.
26
+ #
27
+ # However, for backwards compatibility #last always returns an integer (sometimes 0 if
28
+ # actual last component is not an integer), and #last_str returns the full string version.
29
+ # Trying to change #last itself to be string broke a lot of existing code in this gem
30
+ # in mysterious ways.
31
+ #
32
+ # Also beware that in some cases the value 0/"0" seems to be a special value used
33
+ # to signify some special case. A lot of "code archeology" going on here after significant
34
+ # period of no maintenance to this gem.
35
+ class ResumptionToken
36
+ attr_reader :prefix, :set, :from, :until, :last, :last_str, :expiration, :total
37
+
38
+ # parses a token string and returns a ResumptionToken
39
+ def self.parse(token_string, expiration = nil, total = nil)
40
+ begin
41
+ options = {}
42
+ matches = /(.+):([^ :]+)$/.match(token_string)
43
+ options[:last] = matches.captures[1]
44
+
45
+ parts = matches.captures[0].split('.')
46
+ options[:metadata_prefix] = parts.shift
47
+ parts.each do |part|
48
+ case part
49
+ when /^s/
50
+ options[:set] = part.sub(/^s\(/, '').sub(/\)$/, '')
51
+ when /^f/
52
+ options[:from] = Time.parse(part.sub(/^f\(/, '').sub(/\)$/, '')).localtime
53
+ when /^u/
54
+ options[:until] = Time.parse(part.sub(/^u\(/, '').sub(/\)$/, '')).localtime
55
+ end
56
+ end
57
+ self.new(options, expiration, total)
58
+ rescue => err
59
+ raise OAI::ResumptionTokenException.new
60
+ end
61
+ end
62
+
63
+ # extracts the metadata prefix from a token string
64
+ def self.extract_format(token_string)
65
+ return token_string.split('.')[0]
66
+ end
67
+
68
+ def initialize(options, expiration = nil, total = nil)
69
+ @prefix = options[:metadata_prefix]
70
+ @set = options[:set]
71
+ self.last = options[:last]
72
+ @from = options[:from] if options[:from]
73
+ @until = options[:until] if options[:until]
74
+ @expiration = expiration if expiration
75
+ @total = total if total
76
+ end
77
+
78
+ # convenience method for setting the offset of the next set of results
79
+ def next(last)
80
+ self.last = last
81
+ self
82
+ end
83
+
84
+ def ==(other)
85
+ prefix == other.prefix and set == other.set and from == other.from and
86
+ self.until == other.until and last == other.last and
87
+ expiration == other.expiration and total == other.total
88
+ end
89
+
90
+ # output an xml resumption token
91
+ def to_xml
92
+ xml = Builder::XmlMarkup.new
93
+ xml.resumptionToken(encode_conditions, hash_of_attributes)
94
+ xml.target!
95
+ end
96
+
97
+ # return a hash containing just the model selection parameters
98
+ def to_conditions_hash
99
+ conditions = {:metadata_prefix => self.prefix }
100
+ conditions[:set] = self.set if self.set
101
+ conditions[:from] = self.from if self.from
102
+ conditions[:until] = self.until if self.until
103
+ conditions
104
+ end
105
+
106
+ # return the a string representation of the token minus the offset/ID
107
+ #
108
+ # Q: Why does it eliminate the offset/id "last" on the end? Doesn't fully
109
+ # represent state without it, which is confusing. Not sure, but
110
+ # other code seems to rely on it, tests break if not.
111
+ def to_s
112
+ encode_conditions.gsub(/:\w+?$/, '')
113
+ end
114
+
115
+ private
116
+
117
+ # take care of our logic to store an integer and a str version, for backwards
118
+ # compat where it was assumed to be an integer, as well as supporting string.
119
+ def last=(value)
120
+ @last = value.to_i
121
+ @last_str = value.to_s
122
+ end
123
+
124
+ def encode_conditions
125
+ return "" if last_str.nil? || last_str.to_s.strip.eql?("")
126
+
127
+ encoded_token = @prefix.to_s.dup
128
+ encoded_token << ".s(#{set})" if set
129
+ if self.from
130
+ if self.from.respond_to?(:utc)
131
+ encoded_token << ".f(#{self.from.utc.xmlschema})"
132
+ else
133
+ encoded_token << ".f(#{self.from.xmlschema})"
134
+ end
135
+ end
136
+ if self.until
137
+ if self.until.respond_to?(:utc)
138
+ encoded_token << ".u(#{self.until.utc.xmlschema})"
139
+ else
140
+ encoded_token << ".u(#{self.until.xmlschema})"
141
+ end
142
+ end
143
+ encoded_token << ":#{last_str}"
144
+ end
145
+
146
+ def hash_of_attributes
147
+ attributes = {}
148
+ attributes[:completeListSize] = self.total if self.total
149
+ attributes[:expirationDate] = self.expiration.utc.xmlschema if self.expiration
150
+ attributes
151
+ end
152
+
153
+ end
154
+
155
+ end