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.
- checksums.yaml +7 -0
- data/README.md +128 -0
- data/Rakefile +64 -0
- data/bin/oai +66 -0
- data/examples/models/file_model.rb +63 -0
- data/examples/providers/dublin_core.rb +474 -0
- data/lib/oai/{get_record.rb → client/get_record.rb} +5 -1
- data/lib/oai/client/header.rb +18 -0
- data/lib/oai/{identify.rb → client/identify.rb} +2 -0
- data/lib/oai/{list_identifiers.rb → client/list_identifiers.rb} +1 -0
- data/lib/oai/{list_records.rb → client/list_records.rb} +6 -5
- data/lib/oai/{list_sets.rb → client/list_sets.rb} +7 -6
- data/lib/oai/client/record.rb +27 -0
- data/lib/oai/client/response.rb +53 -0
- data/lib/oai/client/resumable.rb +42 -0
- data/lib/oai/client.rb +313 -108
- data/lib/oai/constants.rb +34 -0
- data/lib/oai/exception.rb +80 -1
- data/lib/oai/harvester/config.rb +41 -0
- data/lib/oai/harvester/harvest.rb +162 -0
- data/lib/oai/harvester/logging.rb +68 -0
- data/lib/oai/harvester/mailer.rb +17 -0
- data/lib/oai/harvester/shell.rb +337 -0
- data/lib/oai/harvester.rb +43 -0
- data/lib/oai/provider/metadata_format/oai_dc.rb +28 -0
- data/lib/oai/provider/metadata_format.rb +119 -0
- data/lib/oai/provider/model/activerecord_caching_wrapper.rb +129 -0
- data/lib/oai/provider/model/activerecord_wrapper.rb +217 -0
- data/lib/oai/provider/model.rb +80 -0
- data/lib/oai/provider/partial_result.rb +18 -0
- data/lib/oai/provider/response/error.rb +16 -0
- data/lib/oai/provider/response/get_record.rb +27 -0
- data/lib/oai/provider/response/identify.rb +37 -0
- data/lib/oai/provider/response/list_identifiers.rb +30 -0
- data/lib/oai/provider/response/list_metadata_formats.rb +39 -0
- data/lib/oai/provider/response/list_records.rb +47 -0
- data/lib/oai/provider/response/list_sets.rb +23 -0
- data/lib/oai/provider/response/record_response.rb +103 -0
- data/lib/oai/provider/response.rb +133 -0
- data/lib/oai/provider/resumption_token.rb +155 -0
- data/lib/oai/provider.rb +487 -0
- data/lib/oai/set.rb +14 -5
- data/lib/oai/xpath.rb +59 -5
- data/lib/oai.rb +7 -13
- data/lib/test.rb +25 -0
- data/test/activerecord_provider/config/connection.rb +30 -0
- data/test/activerecord_provider/database/0001_oaipmh_tables.rb +70 -0
- data/test/activerecord_provider/fixtures/dc.yml +1501 -0
- data/test/activerecord_provider/helpers/providers.rb +68 -0
- data/test/activerecord_provider/helpers/set_provider.rb +24 -0
- data/test/activerecord_provider/helpers/transactional_test_case.rb +35 -0
- data/test/activerecord_provider/models/dc_field.rb +15 -0
- data/test/activerecord_provider/models/dc_lang.rb +3 -0
- data/test/activerecord_provider/models/dc_set.rb +7 -0
- data/test/activerecord_provider/models/exclusive_set_dc_field.rb +17 -0
- data/test/activerecord_provider/models/oai_token.rb +3 -0
- data/test/activerecord_provider/tc_activerecord_wrapper.rb +63 -0
- data/test/activerecord_provider/tc_ar_provider.rb +199 -0
- data/test/activerecord_provider/tc_ar_sets_provider.rb +159 -0
- data/test/activerecord_provider/tc_caching_paging_provider.rb +50 -0
- data/test/activerecord_provider/tc_simple_paging_provider.rb +71 -0
- data/test/activerecord_provider/test_helper_ar_provider.rb +7 -0
- data/test/client/helpers/provider.rb +65 -0
- data/test/client/helpers/test_wrapper.rb +6 -0
- data/test/client/tc_exception.rb +36 -0
- data/test/client/tc_get_record.rb +39 -0
- data/test/client/tc_http_client.rb +129 -0
- data/test/client/tc_identify.rb +13 -0
- data/test/client/tc_libxml.rb +61 -0
- data/test/{tc_list_identifiers.rb → client/tc_list_identifiers.rb} +28 -9
- data/test/{tc_list_metadata_formats.rb → client/tc_list_metadata_formats.rb} +4 -1
- data/test/client/tc_list_records.rb +26 -0
- data/test/client/tc_list_sets.rb +34 -0
- data/test/client/tc_low_resolution_dates.rb +14 -0
- data/test/client/tc_utf8_escaping.rb +19 -0
- data/test/client/tc_xpath.rb +26 -0
- data/test/client/test_helper_client.rb +5 -0
- data/test/harvester/tc_harvest.rb +42 -0
- data/test/harvester/test_helper_harvester.rb +6 -0
- data/test/provider/models.rb +246 -0
- data/test/provider/tc_exceptions.rb +81 -0
- data/test/provider/tc_functional_tokens.rb +53 -0
- data/test/provider/tc_instance_provider.rb +41 -0
- data/test/provider/tc_provider.rb +136 -0
- data/test/provider/tc_resumption_tokens.rb +52 -0
- data/test/provider/tc_simple_provider.rb +138 -0
- data/test/provider/test_helper_provider.rb +62 -0
- data/test/test.xml +22 -0
- metadata +238 -56
- data/lib/oai/header.rb +0 -12
- data/lib/oai/record.rb +0 -11
- data/lib/oai/response.rb +0 -20
- data/test/tc_get_record.rb +0 -27
- data/test/tc_identify.rb +0 -8
- data/test/tc_list_records.rb +0 -9
- data/test/tc_list_sets.rb +0 -17
- data/test.rb +0 -12
- /data/lib/oai/{list_metadata_formats.rb → client/list_metadata_formats.rb} +0 -0
- /data/lib/oai/{metadata_format.rb → client/metadata_format.rb} +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
require 'singleton'
|
|
2
|
+
|
|
3
|
+
module OAI::Provider::Metadata
|
|
4
|
+
# == Metadata Base Class
|
|
5
|
+
#
|
|
6
|
+
# MetadataFormat is the base class from which all other format classes
|
|
7
|
+
# should inherit. Format classes provide mapping of record fields into XML.
|
|
8
|
+
#
|
|
9
|
+
# * prefix - contains the metadata_prefix used to select the format
|
|
10
|
+
# * schema - location of the xml schema
|
|
11
|
+
# * namespace - location of the namespace document
|
|
12
|
+
# * element_namespace - the namespace portion of the XML elements
|
|
13
|
+
# * fields - list of fields in this metadata format
|
|
14
|
+
#
|
|
15
|
+
# See OAI::Metadata::DublinCore for an example
|
|
16
|
+
#
|
|
17
|
+
class Format
|
|
18
|
+
include Singleton
|
|
19
|
+
|
|
20
|
+
attr_accessor :prefix, :schema, :namespace, :element_namespace, :fields
|
|
21
|
+
|
|
22
|
+
# Provided a model, and a record belonging to that model this method
|
|
23
|
+
# will return an xml represention of the record. This is the method
|
|
24
|
+
# that should be extended if you need to create more complex xml
|
|
25
|
+
# representations.
|
|
26
|
+
def encode(model, record)
|
|
27
|
+
if record.respond_to?("to_#{prefix}")
|
|
28
|
+
record.send("to_#{prefix}")
|
|
29
|
+
else
|
|
30
|
+
xml = Builder::XmlMarkup.new
|
|
31
|
+
map = model.respond_to?("map_#{prefix}") ? model.send("map_#{prefix}") : {}
|
|
32
|
+
xml.tag!("#{prefix}:#{element_namespace}", header_specification) do
|
|
33
|
+
fields.each do |field|
|
|
34
|
+
values = value_for(field, record, map)
|
|
35
|
+
if values.respond_to?(:each)
|
|
36
|
+
values.each do |value|
|
|
37
|
+
xml.tag! "#{element_namespace}:#{field}", value
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
xml.tag! "#{element_namespace}:#{field}", values
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
xml.target!
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# We try a bunch of different methods to get the data from the model.
|
|
51
|
+
#
|
|
52
|
+
# 1. Check if the model defines a field mapping for the field of
|
|
53
|
+
# interest.
|
|
54
|
+
# 2. Try calling the pluralized name method on the model.
|
|
55
|
+
# 3. Try calling the singular name method on the model
|
|
56
|
+
def value_for(field, record, map)
|
|
57
|
+
method = map[field] ? map[field].to_s : field.to_s
|
|
58
|
+
|
|
59
|
+
if record.respond_to?(pluralize(method))
|
|
60
|
+
record.send pluralize(method)
|
|
61
|
+
elsif method != 'type' and record.respond_to?(method)
|
|
62
|
+
# TODO: this calls type, which is deprecated, should be
|
|
63
|
+
record.send method
|
|
64
|
+
else
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Subclasses must override
|
|
70
|
+
def header_specification
|
|
71
|
+
raise NotImplementedError.new
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Shamelessly lifted form ActiveSupport. Thanks Rails community!
|
|
75
|
+
def pluralize(word)
|
|
76
|
+
# Use ActiveSupports pluralization if it's available.
|
|
77
|
+
return word.pluralize if word.respond_to?(:pluralize)
|
|
78
|
+
|
|
79
|
+
# Otherwise use our own simple pluralization rules.
|
|
80
|
+
result = word.to_s.dup
|
|
81
|
+
|
|
82
|
+
# Uncountable words
|
|
83
|
+
return result if %w(equipment information rice money species series fish sheep).include?(result)
|
|
84
|
+
|
|
85
|
+
# Irregular words
|
|
86
|
+
{ 'person' => 'people', 'man' => 'men', 'child' => 'children', 'sex' => 'sexes',
|
|
87
|
+
'move' => 'moves', 'cow' => 'kine' }.each { |k,v| return v if word == k }
|
|
88
|
+
|
|
89
|
+
rules.each { |(rule, replacement)| break if result.gsub!(rule, replacement) }
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def rules
|
|
94
|
+
[
|
|
95
|
+
[/$/, 's'],
|
|
96
|
+
[/s$/i, 's'],
|
|
97
|
+
[/(ax|test)is$/i, '\1es'],
|
|
98
|
+
[/(octop|vir)us$/i, '\1i'],
|
|
99
|
+
[/(alias|status)$/i, '\1es'],
|
|
100
|
+
[/(bu)s$/i, '\1ses'],
|
|
101
|
+
[/(buffal|tomat)o$/i, '\1oes'],
|
|
102
|
+
[/([ti])um$/i, '\1a'],
|
|
103
|
+
[/sis$/i, 'ses'],
|
|
104
|
+
[/(?:([^f])fe|([lr])f)$/i, '\1\2ves'],
|
|
105
|
+
[/(hive)$/i, '\1s'],
|
|
106
|
+
[/([^aeiouy]|qu)y$/i, '\1ies'],
|
|
107
|
+
[/(x|ch|ss|sh)$/i, '\1es'],
|
|
108
|
+
[/(matr|vert|ind)(?:ix|ex)$/i, '\1ices'],
|
|
109
|
+
[/([m|l])ouse$/i, '\1ice'],
|
|
110
|
+
[/^(ox)$/i, '\1en'],
|
|
111
|
+
[/(quiz)$/i, '\1zes']
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Dir.glob(File.dirname(__FILE__) + '/metadata_format/*.rb').each {|lib| require lib}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
require 'active_record'
|
|
2
|
+
|
|
3
|
+
module OAI::Provider
|
|
4
|
+
|
|
5
|
+
# ActiveRecord model class in support of the caching wrapper.
|
|
6
|
+
class OaiToken < ActiveRecord::Base
|
|
7
|
+
has_many :entries, -> { order("record_id ASC") },
|
|
8
|
+
:class_name => 'OaiEntry', :dependent => :destroy
|
|
9
|
+
|
|
10
|
+
validates_uniqueness_of :token
|
|
11
|
+
|
|
12
|
+
# Make sanitize_sql a public method so we can make use of it.
|
|
13
|
+
public
|
|
14
|
+
|
|
15
|
+
def self.sanitize_sql(*arg)
|
|
16
|
+
super(*arg)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def new_record_before_save?
|
|
20
|
+
@new_record_before_save
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# ActiveRecord model class in support of the caching wrapper.
|
|
26
|
+
class OaiEntry < ActiveRecord::Base
|
|
27
|
+
belongs_to :oai_token
|
|
28
|
+
|
|
29
|
+
validates_uniqueness_of :record_id, :scope => :oai_token
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# This class wraps an ActiveRecord model and delegates all of the record
|
|
33
|
+
# selection/retrieval to the AR model. It accepts options for specifying
|
|
34
|
+
# the update timestamp field, a timeout, and a limit. The limit option
|
|
35
|
+
# is used for doing pagination with resumption tokens. The timeout is
|
|
36
|
+
# used to expire old tokens from the cache. Default timeout is 12 hours.
|
|
37
|
+
#
|
|
38
|
+
# The difference between ActiveRecordWrapper and this class is how the
|
|
39
|
+
# pagination is accomplished. ActiveRecordWrapper encodes all the
|
|
40
|
+
# information in the token. That approach should work 99% of the time.
|
|
41
|
+
# If you have an extremely active respository you may want to consider
|
|
42
|
+
# the caching wrapper. The caching wrapper takes the entire result set
|
|
43
|
+
# from a request and caches it in another database table, well tables
|
|
44
|
+
# actually. So the result returned to the client will always be
|
|
45
|
+
# internally consistent.
|
|
46
|
+
#
|
|
47
|
+
class ActiveRecordCachingWrapper < ActiveRecordWrapper
|
|
48
|
+
|
|
49
|
+
attr_reader :model, :timestamp_field, :expire
|
|
50
|
+
|
|
51
|
+
def initialize(model, options={})
|
|
52
|
+
@expire = options.delete(:timeout) || 12.hours
|
|
53
|
+
super(model, options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def find(selector, options={})
|
|
57
|
+
sweep_cache
|
|
58
|
+
return next_set(options[:resumption_token]) if options[:resumption_token]
|
|
59
|
+
|
|
60
|
+
conditions = sql_conditions(options)
|
|
61
|
+
|
|
62
|
+
if :all == selector
|
|
63
|
+
total = model.where(conditions).count
|
|
64
|
+
if @limit && total > @limit
|
|
65
|
+
select_partial(
|
|
66
|
+
ResumptionToken.new(options.merge({:last => 0})))
|
|
67
|
+
else
|
|
68
|
+
model.where(conditions)
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
model.where(conditions).find(selector)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
protected
|
|
76
|
+
|
|
77
|
+
def next_set(token_string)
|
|
78
|
+
raise ResumptionTokenException.new unless @limit
|
|
79
|
+
|
|
80
|
+
token = ResumptionToken.parse(token_string)
|
|
81
|
+
select_partial(token)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# select a subset of the result set, and return it with a
|
|
85
|
+
# resumption token to get the next subset
|
|
86
|
+
def select_partial(token)
|
|
87
|
+
oaitoken = OaiToken.find_by(token: token.to_s)
|
|
88
|
+
|
|
89
|
+
if 0 == token.last && oaitoken.nil?
|
|
90
|
+
oaitoken = OaiToken.create!(token: token.to_s)
|
|
91
|
+
OaiToken.connection.execute("insert into " +
|
|
92
|
+
"#{OaiEntry.table_name} (oai_token_id, record_id) " +
|
|
93
|
+
"select #{oaitoken.id}, id from #{model.table_name} where " +
|
|
94
|
+
"#{OaiToken.sanitize_sql(token_conditions(token))}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
raise ResumptionTokenException.new unless oaitoken
|
|
98
|
+
|
|
99
|
+
total = model.where(token_conditions(token)).count
|
|
100
|
+
# token offset should be nil if this is the last set
|
|
101
|
+
offset = (token.last * @limit + @limit >= total) ? nil : token.last + 1
|
|
102
|
+
PartialResult.new(
|
|
103
|
+
hydrate_records(
|
|
104
|
+
oaitoken.entries.limit(@limit).offset(token.last * @limit)),
|
|
105
|
+
token.next(offset)
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def sweep_cache
|
|
110
|
+
OaiToken.where(["created_at < ?", Time.now - expire]).destroy_all
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def hydrate_records(records)
|
|
114
|
+
model.find(records.collect {|r| r.record_id })
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def token_conditions(token)
|
|
118
|
+
sql_conditions token.to_conditions_hash
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def expires_at(creation)
|
|
124
|
+
created = Time.parse(creation.strftime("%Y-%m-%d %H:%M:%S"))
|
|
125
|
+
created.utc + expire
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
require 'active_record'
|
|
2
|
+
|
|
3
|
+
module OAI::Provider
|
|
4
|
+
# This class wraps an ActiveRecord model and delegates all of the record
|
|
5
|
+
# selection/retrieval to the AR model. It accepts options for specifying
|
|
6
|
+
# the update timestamp field, a timeout, and a limit. The limit option
|
|
7
|
+
# is used for doing pagination with resumption tokens. The
|
|
8
|
+
# expiration timeout is ignored, since all necessary information is
|
|
9
|
+
# encoded in the token.
|
|
10
|
+
#
|
|
11
|
+
class ActiveRecordWrapper < Model
|
|
12
|
+
|
|
13
|
+
attr_reader :model, :timestamp_field, :identifier_field
|
|
14
|
+
|
|
15
|
+
# If custom 'timestamp_field' is used, be aware this will be an ActiveRecord
|
|
16
|
+
# attribute that we will limit on, so perhaps should be indexe appropriately.
|
|
17
|
+
#
|
|
18
|
+
# If custom `identifier_field` is used, be aware this will be an ActiveRecord
|
|
19
|
+
# attribute that we will sort on, and use in WHERE clauses with `=` as well as
|
|
20
|
+
# greater than/less than, so should be indexed appropriately.
|
|
21
|
+
def initialize(model, options={})
|
|
22
|
+
@model = model
|
|
23
|
+
@timestamp_field = options.delete(:timestamp_field) || 'updated_at'
|
|
24
|
+
@identifier_field = options.delete(:identifier_field) || model.primary_key || "id"
|
|
25
|
+
@limit = options.delete(:limit) || 100
|
|
26
|
+
|
|
27
|
+
unless options.empty?
|
|
28
|
+
raise ArgumentError.new(
|
|
29
|
+
"Unsupported options [#{options.keys.join(', ')}]"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def earliest
|
|
35
|
+
earliest_obj = model.order("#{model.base_class.table_name}.#{timestamp_field} asc").first
|
|
36
|
+
earliest_obj.nil? ? Time.at(0) : earliest_obj.send(timestamp_field)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def latest
|
|
40
|
+
latest_obj = model.order("#{model.base_class.table_name}.#{timestamp_field} desc").first
|
|
41
|
+
latest_obj.nil? ? Time.now : latest_obj.send(timestamp_field)
|
|
42
|
+
end
|
|
43
|
+
# A model class is expected to provide a method Model.sets that
|
|
44
|
+
# returns all the sets the model supports. See the
|
|
45
|
+
# activerecord_provider tests for an example.
|
|
46
|
+
def sets
|
|
47
|
+
model.respond_to?(:sets) ? model.sets : []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def find(selector, options={})
|
|
51
|
+
find_scope = find_scope(options)
|
|
52
|
+
return next_set(find_scope,
|
|
53
|
+
options[:resumption_token]) if options[:resumption_token]
|
|
54
|
+
conditions = sql_conditions(options)
|
|
55
|
+
if :all == selector
|
|
56
|
+
total = find_scope.where(conditions).count
|
|
57
|
+
if @limit && total > @limit
|
|
58
|
+
select_partial(find_scope,
|
|
59
|
+
ResumptionToken.new(options.merge({:last => 0})))
|
|
60
|
+
else
|
|
61
|
+
find_scope.where(conditions)
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
find_scope.where(conditions).where(identifier_field => selector).first
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def deleted?(record)
|
|
69
|
+
if record.respond_to?(:deleted_at)
|
|
70
|
+
return record.deleted_at
|
|
71
|
+
elsif record.respond_to?(:deleted)
|
|
72
|
+
return record.deleted
|
|
73
|
+
end
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def respond_to?(m, *args)
|
|
78
|
+
if m =~ /^map_/
|
|
79
|
+
model.respond_to?(m, *args)
|
|
80
|
+
else
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def method_missing(m, *args, &block)
|
|
86
|
+
if m =~ /^map_/
|
|
87
|
+
model.send(m, *args, &block)
|
|
88
|
+
else
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
protected
|
|
94
|
+
|
|
95
|
+
def find_scope(options)
|
|
96
|
+
return model unless options.key?(:set)
|
|
97
|
+
|
|
98
|
+
# Find the set or return an empty scope
|
|
99
|
+
set = find_set_by_spec(options[:set])
|
|
100
|
+
return model.limit(0) if set.nil?
|
|
101
|
+
|
|
102
|
+
# If the set has a backward relationship, we'll use it
|
|
103
|
+
if set.class.respond_to?(:reflect_on_all_associations)
|
|
104
|
+
set.class.reflect_on_all_associations.each do |assoc|
|
|
105
|
+
return set.send(assoc.name) if assoc.klass == model
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Search the attributes for 'set'
|
|
110
|
+
if model.column_names.include?('set')
|
|
111
|
+
# Scope using the set attribute as the spec
|
|
112
|
+
model.where(set: options[:set])
|
|
113
|
+
else
|
|
114
|
+
# Default to empty set, as we've tried everything else
|
|
115
|
+
model.none
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def find_set_by_spec(spec)
|
|
120
|
+
if sets.class == ActiveRecord::Relation
|
|
121
|
+
sets.find_by_spec(spec)
|
|
122
|
+
else
|
|
123
|
+
sets.detect {|set| set.spec == spec}
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Request the next set in this sequence.
|
|
128
|
+
def next_set(find_scope, token_string)
|
|
129
|
+
raise OAI::ResumptionTokenException.new unless @limit
|
|
130
|
+
|
|
131
|
+
token = ResumptionToken.parse(token_string)
|
|
132
|
+
select_partial(find_scope, token)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# select a subset of the result set, and return it with a
|
|
136
|
+
# resumption token to get the next subset
|
|
137
|
+
def select_partial(find_scope, token)
|
|
138
|
+
records = find_scope.where(token_conditions(token))
|
|
139
|
+
.limit(@limit)
|
|
140
|
+
.order("#{model.base_class.table_name}.#{identifier_field} asc")
|
|
141
|
+
raise OAI::ResumptionTokenException.new unless records
|
|
142
|
+
|
|
143
|
+
total = find_scope.where(token_conditions(token)).count
|
|
144
|
+
# token offset should be nil if this is the last set
|
|
145
|
+
offset = (@limit >= total) ? nil : records.last.send(identifier_field)
|
|
146
|
+
PartialResult.new(records, token.next(offset))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# build a sql conditions statement from the content
|
|
150
|
+
# of a resumption token. It is very important not to
|
|
151
|
+
# miss any changes as records may change scope as the
|
|
152
|
+
# harvest is in progress. To avoid loosing any changes
|
|
153
|
+
# the last 'id' of the previous set is used as the
|
|
154
|
+
# filter to the next set.
|
|
155
|
+
def token_conditions(token)
|
|
156
|
+
last_id = token.last_str
|
|
157
|
+
sql = sql_conditions token.to_conditions_hash
|
|
158
|
+
|
|
159
|
+
return sql if "0" == last_id
|
|
160
|
+
# Now add last id constraint
|
|
161
|
+
sql.first << " AND #{model.base_class.table_name}.#{identifier_field} > :id"
|
|
162
|
+
sql.last[:id] = last_id
|
|
163
|
+
|
|
164
|
+
return sql
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# build a sql conditions statement from an OAI options hash
|
|
168
|
+
def sql_conditions(opts)
|
|
169
|
+
sql = []
|
|
170
|
+
esc_values = {}
|
|
171
|
+
if opts.has_key?(:from)
|
|
172
|
+
sql << "#{model.base_class.table_name}.#{timestamp_field} >= :from"
|
|
173
|
+
esc_values[:from] = parse_to_local(opts[:from])
|
|
174
|
+
end
|
|
175
|
+
if opts.has_key?(:until)
|
|
176
|
+
# Handle databases which store fractions of a second by rounding up
|
|
177
|
+
sql << "#{model.base_class.table_name}.#{timestamp_field} < :until"
|
|
178
|
+
esc_values[:until] = parse_to_local(opts[:until]) { |t| t + 1 }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
return [sql.join(" AND "), esc_values]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def parse_to_local(time)
|
|
187
|
+
if time.respond_to?(:strftime)
|
|
188
|
+
time_obj = time
|
|
189
|
+
else
|
|
190
|
+
begin
|
|
191
|
+
if time[-1] == "Z"
|
|
192
|
+
time_obj = Time.strptime(time, "%Y-%m-%dT%H:%M:%S%Z")
|
|
193
|
+
else
|
|
194
|
+
time_obj = Date.strptime(time, "%Y-%m-%d")
|
|
195
|
+
end
|
|
196
|
+
rescue
|
|
197
|
+
raise OAI::ArgumentException.new, "unparsable date: '#{time}'"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
time_obj = yield(time_obj) if block_given?
|
|
202
|
+
|
|
203
|
+
if time_obj.kind_of?(Date)
|
|
204
|
+
time_obj.strftime("%Y-%m-%d")
|
|
205
|
+
else
|
|
206
|
+
# Convert to same as DB - :local => :getlocal, :utc => :getutc
|
|
207
|
+
if ActiveRecord::VERSION::MAJOR >= 7
|
|
208
|
+
tzconv = "get#{ActiveRecord.default_timezone.to_s}".to_sym
|
|
209
|
+
else
|
|
210
|
+
tzconv = "get#{model.default_timezone.to_s}".to_sym
|
|
211
|
+
end
|
|
212
|
+
time_obj.send(tzconv).strftime("%Y-%m-%d %H:%M:%S")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module OAI::Provider
|
|
2
|
+
# = OAI::Provider::Model
|
|
3
|
+
#
|
|
4
|
+
# Model implementers should subclass OAI::Provider::Model and override
|
|
5
|
+
# Model#earliest, Model#latest, and Model#find. Optionally Model#sets and
|
|
6
|
+
# Model#deleted? can be used to support sets and record deletions. It
|
|
7
|
+
# is also the responsibility of the model implementer to account for
|
|
8
|
+
# resumption tokens if support is required. Models that don't support
|
|
9
|
+
# resumption tokens should raise an exception if a limit is requested
|
|
10
|
+
# during initialization.
|
|
11
|
+
#
|
|
12
|
+
# earliest - should return the earliest update time in the repository.
|
|
13
|
+
# latest - should return the most recent update time in the repository.
|
|
14
|
+
# sets - should return an array of sets supported by the repository.
|
|
15
|
+
# deleted? - individual records returned should respond true or false
|
|
16
|
+
# when sent the deleted? message.
|
|
17
|
+
# available_formats - if overridden, individual records should return an
|
|
18
|
+
# array of prefixes for all formats in which that record is available,
|
|
19
|
+
# if other than ["oai_dc"]
|
|
20
|
+
# about - if overridden, should return a String or Array of XML Strings to
|
|
21
|
+
# insert into the OAI Record <about> chunks.
|
|
22
|
+
#
|
|
23
|
+
# == Resumption Tokens
|
|
24
|
+
#
|
|
25
|
+
# For examples of using resumption tokens see the
|
|
26
|
+
# ActiveRecordWrapper, and ActiveRecordCachingWrapper classes.
|
|
27
|
+
#
|
|
28
|
+
# There are several helper models for dealing with resumption tokens please
|
|
29
|
+
# see the ResumptionToken class for more details.
|
|
30
|
+
#
|
|
31
|
+
class Model
|
|
32
|
+
attr_reader :timestamp_field, :identifier_field, :limit
|
|
33
|
+
|
|
34
|
+
def initialize(limit = nil, timestamp_field = 'updated_at', identifier_field = 'id')
|
|
35
|
+
@limit = limit
|
|
36
|
+
@identifier_field = identifier_field
|
|
37
|
+
@timestamp_field = timestamp_field
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# should return the earliest timestamp available from this model.
|
|
41
|
+
def earliest
|
|
42
|
+
raise NotImplementedError.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# should return the latest timestamp available from this model.
|
|
46
|
+
def latest
|
|
47
|
+
raise NotImplementedError.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def sets
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# find is the core method of a model, it returns records from the model
|
|
55
|
+
# bases on the parameters passed in.
|
|
56
|
+
#
|
|
57
|
+
# <tt>selector</tt> can be a singular id, or the symbol :all
|
|
58
|
+
# <tt>options</tt> is a hash of options to be used to constrain the query.
|
|
59
|
+
#
|
|
60
|
+
# Valid options:
|
|
61
|
+
# * :from => earliest timestamp to be included in the results
|
|
62
|
+
# * :until => latest timestamp to be included in the results
|
|
63
|
+
# * :set => the set from which to retrieve the results
|
|
64
|
+
# * :metadata_prefix => type of metadata requested (this may be useful if
|
|
65
|
+
# not all records are available in all formats)
|
|
66
|
+
def find(selector, options={})
|
|
67
|
+
raise NotImplementedError.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def deleted?
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# can return a String or Array of XML Strings add as OAI Record <about> chunks.
|
|
75
|
+
def about record
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module OAI::Provider
|
|
2
|
+
# = OAI::Provider::PartialResult
|
|
3
|
+
#
|
|
4
|
+
# PartialResult is used for returning a set/page of results from a model
|
|
5
|
+
# that supports resumption tokens. It should contain and array of
|
|
6
|
+
# records, and a resumption token for getting the next set/page.
|
|
7
|
+
#
|
|
8
|
+
class PartialResult
|
|
9
|
+
attr_reader :records, :token
|
|
10
|
+
|
|
11
|
+
def initialize(records, token = nil)
|
|
12
|
+
@records = records
|
|
13
|
+
@token = token
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module OAI::Provider::Response
|
|
2
|
+
|
|
3
|
+
class GetRecord < RecordResponse
|
|
4
|
+
required_parameters :identifier, :metadata_prefix
|
|
5
|
+
|
|
6
|
+
def to_xml
|
|
7
|
+
id = extract_identifier(options.delete(:identifier))
|
|
8
|
+
unless record = provider.model.find(id, options)
|
|
9
|
+
raise OAI::IdException.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
response do |r|
|
|
13
|
+
r.GetRecord do
|
|
14
|
+
r.record do
|
|
15
|
+
header_for record
|
|
16
|
+
data_for record unless deleted?(record)
|
|
17
|
+
about_for record unless deleted?(record)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module OAI::Provider::Response
|
|
2
|
+
|
|
3
|
+
class Identify < Base
|
|
4
|
+
|
|
5
|
+
def to_xml
|
|
6
|
+
response do |r|
|
|
7
|
+
r.Identify do
|
|
8
|
+
r.repositoryName provider.name
|
|
9
|
+
r.baseURL provider.url
|
|
10
|
+
r.protocolVersion 2.0
|
|
11
|
+
if provider.email and provider.email.respond_to?(:each)
|
|
12
|
+
provider.email.each { |address| r.adminEmail address }
|
|
13
|
+
else
|
|
14
|
+
r.adminEmail provider.email.to_s
|
|
15
|
+
end
|
|
16
|
+
r.earliestDatestamp Time.parse(provider.model.earliest.to_s).utc.xmlschema
|
|
17
|
+
r.deletedRecord provider.delete_support.to_s
|
|
18
|
+
r.granularity provider.granularity
|
|
19
|
+
r.description do
|
|
20
|
+
r.tag! 'oai-identifier', 'xmlns' => 'http://www.openarchives.org/OAI/2.0/oai-identifier', 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:schemaLocation' => 'http://www.openarchives.org/OAI/2.0/oai-identifier http://www.openarchives.org/OAI/2.0/oai-identifier.xsd' do
|
|
21
|
+
r.scheme 'oai'
|
|
22
|
+
r.repositoryIdentifier provider.prefix.gsub(/oai:/, '')
|
|
23
|
+
r.delimiter ':'
|
|
24
|
+
r.sampleIdentifier "#{provider.prefix}:#{provider.identifier}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
if provider.description
|
|
28
|
+
r.target! << provider.description
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module OAI::Provider::Response
|
|
2
|
+
|
|
3
|
+
class ListIdentifiers < RecordResponse
|
|
4
|
+
|
|
5
|
+
def to_xml
|
|
6
|
+
result = provider.model.find(:all, options)
|
|
7
|
+
|
|
8
|
+
# result may be an array of records, or a partial result
|
|
9
|
+
records = result.respond_to?(:records) ? result.records : result
|
|
10
|
+
|
|
11
|
+
raise OAI::NoMatchException.new if records.nil? or records.empty?
|
|
12
|
+
|
|
13
|
+
response do |r|
|
|
14
|
+
r.ListIdentifiers do
|
|
15
|
+
records.each do |rec|
|
|
16
|
+
header_for rec
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# append resumption token for getting next group of records
|
|
20
|
+
if result.respond_to?(:token)
|
|
21
|
+
r.target! << result.token.to_xml
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
end
|