aspire 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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +59 -0
  3. data/.rbenv-gemsets +1 -0
  4. data/.travis.yml +5 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Dockerfile +20 -0
  7. data/Gemfile +4 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +851 -0
  10. data/Rakefile +10 -0
  11. data/aspire.gemspec +40 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +8 -0
  14. data/entrypoint.sh +11 -0
  15. data/exe/build-cache +13 -0
  16. data/lib/aspire.rb +11 -0
  17. data/lib/aspire/api.rb +2 -0
  18. data/lib/aspire/api/base.rb +198 -0
  19. data/lib/aspire/api/json.rb +195 -0
  20. data/lib/aspire/api/linked_data.rb +214 -0
  21. data/lib/aspire/caching.rb +4 -0
  22. data/lib/aspire/caching/builder.rb +356 -0
  23. data/lib/aspire/caching/cache.rb +365 -0
  24. data/lib/aspire/caching/cache_entry.rb +296 -0
  25. data/lib/aspire/caching/cache_logger.rb +63 -0
  26. data/lib/aspire/caching/util.rb +210 -0
  27. data/lib/aspire/cli/cache_builder.rb +123 -0
  28. data/lib/aspire/cli/command.rb +20 -0
  29. data/lib/aspire/enumerator/base.rb +29 -0
  30. data/lib/aspire/enumerator/json_enumerator.rb +130 -0
  31. data/lib/aspire/enumerator/linked_data_uri_enumerator.rb +32 -0
  32. data/lib/aspire/enumerator/report_enumerator.rb +64 -0
  33. data/lib/aspire/exceptions.rb +36 -0
  34. data/lib/aspire/object.rb +7 -0
  35. data/lib/aspire/object/base.rb +155 -0
  36. data/lib/aspire/object/digitisation.rb +43 -0
  37. data/lib/aspire/object/factory.rb +87 -0
  38. data/lib/aspire/object/list.rb +590 -0
  39. data/lib/aspire/object/module.rb +36 -0
  40. data/lib/aspire/object/resource.rb +371 -0
  41. data/lib/aspire/object/time_period.rb +47 -0
  42. data/lib/aspire/object/user.rb +46 -0
  43. data/lib/aspire/properties.rb +20 -0
  44. data/lib/aspire/user_lookup.rb +103 -0
  45. data/lib/aspire/util.rb +185 -0
  46. data/lib/aspire/version.rb +3 -0
  47. data/lib/retry.rb +197 -0
  48. metadata +274 -0
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,40 @@
1
+ # coding: utf-8
2
+
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'aspire/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'aspire'
9
+ spec.version = Aspire::VERSION
10
+ spec.authors = ['Lancaster University Library']
11
+ spec.email = ['library.dit@lancaster.ac.uk']
12
+
13
+ spec.summary = 'Ruby interface to the Talis Aspire API'
14
+ spec.description = 'This gem provides a Ruby interface for working with' \
15
+ 'the Talis Aspire API.'
16
+ spec.homepage = 'https://github.com/lulibrary/aspire'
17
+ spec.license = 'MIT'
18
+
19
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
20
+ f.match(%r{^(test|spec|features)/})
21
+ end
22
+ spec.bindir = 'exe'
23
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ['lib']
25
+
26
+ spec.add_dependency 'logglier', '~> 0.2'
27
+ spec.add_dependency 'loofah', '~> 2.0'
28
+ spec.add_dependency 'rest-client', '~> 2.0'
29
+ spec.add_dependency 'sentry-raven', '~> 2.6'
30
+ spec.add_dependency 'clamp', '~> 1.1'
31
+ spec.add_dependency 'dotenv', '~> 2.2'
32
+
33
+ spec.add_development_dependency 'byebug', '~> 9.0'
34
+ spec.add_development_dependency 'bundler', '~> 1.14'
35
+ spec.add_development_dependency 'dotenv', '~> 2.2'
36
+ spec.add_development_dependency 'rake', '~> 10.0'
37
+ spec.add_development_dependency 'rubocop', '~> 0.49'
38
+ spec.add_development_dependency 'minitest', '~> 5.0'
39
+ spec.add_development_dependency 'minitest-reporters', '~> 1.1'
40
+ end
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "aspire"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+
3
+ TIME_PERIOD=${TIME_PERIOD:-''}
4
+ STATUS=${STATUS:-''}
5
+ PRIVACY_CONTROL=${PRIVACY_CONTROL:-''}
6
+ LIST_URL=${LIST_URL:-''}
7
+ LANG=en_US.UTF-8
8
+ LANGUAGE=en_US.UTF-8
9
+ LC_ALL=en_US.UTF-8
10
+
11
+ build-cache -t "${TIME_PERIOD}" -s "${STATUS}" -p "${PRIVACY_CONTROL}" -l "${LIST_URL}" -f
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'aspire'
5
+ require 'raven'
6
+
7
+ unless ENV['SENTRY_DSN'].nil? || ENV['SENTRY_DSN'].empty?
8
+ Raven.configure do |config|
9
+ config.dsn = ENV['SENTRY_DSN']
10
+ end
11
+ end
12
+
13
+ Aspire::CLI::CacheBuilder.run
@@ -0,0 +1,11 @@
1
+ require 'aspire/api'
2
+ require 'aspire/caching'
3
+ require 'aspire/object'
4
+ require 'aspire/util'
5
+ require 'aspire/version'
6
+ require 'aspire/cli/cache_builder'
7
+
8
+
9
+ module Aspire
10
+ # Your code goes here...
11
+ end
@@ -0,0 +1,2 @@
1
+ require 'aspire/api/json'
2
+ require 'aspire/api/linked_data'
@@ -0,0 +1,198 @@
1
+ require 'json'
2
+ require 'uri'
3
+
4
+ require 'rest-client'
5
+
6
+ require 'retry'
7
+
8
+ module Aspire
9
+ module API
10
+ # The base class for Aspire API wrappers
11
+ class Base
12
+ # Domain names
13
+ TALIS_DOMAIN = 'talis.com'.freeze
14
+ ASPIRE_DOMAIN = "rl.#{TALIS_DOMAIN}".freeze
15
+ ASPIRE_AUTH_DOMAIN = "users.#{TALIS_DOMAIN}".freeze
16
+
17
+ # The default URL scheme
18
+ SCHEME = 'http'.freeze
19
+
20
+ # SSL options
21
+ SSL_OPTS = %i[ssl_ca_file ssl_ca_path ssl_cert_store].freeze
22
+
23
+ # @!attribute [rw] logger
24
+ # @return [Logger] a logger for activity logging
25
+ attr_accessor :logger
26
+
27
+ # @!attribute [rw] ssl
28
+ # @return [Hash] SSL options
29
+ attr_accessor :ssl
30
+
31
+ # @!attribute [rw] tenancy_code
32
+ # @return [String] the Aspire short tenancy code
33
+ attr_accessor :tenancy_code
34
+
35
+ # @!attribute [rw] timeout
36
+ # @return [Integer] the timeout period in seconds for API calls
37
+ attr_accessor :timeout
38
+
39
+ # Initialises a new API instance
40
+ # @param tenancy_code [String]
41
+ # @option opts [Logger] :logger the API activity logger
42
+ # @option opts [Integer] :timeout the API timeout in seconds
43
+ # @option opts [String] :ssl_ca_file the certificate authority filename
44
+ # @option opts [String] :ssl_ca_path the certificate authority directory
45
+ # @option opts [String] :ssl_cert_store the certificate authority store
46
+ def initialize(tenancy_code, **opts)
47
+ self.logger = opts[:logger]
48
+ self.tenancy_code = tenancy_code
49
+ self.timeout = opts[:timeout] || 0
50
+ # Retry options
51
+ initialize_retry(opts)
52
+ # SSL options
53
+ initialize_ssl(opts)
54
+ # Set the RestClient logger
55
+ RestClient.log = logger if logger
56
+ end
57
+
58
+ # Returns a full API URL from a partial path
59
+ # @abstract Subclasses must implement this method
60
+ # @param path [String] a full or partial API URL
61
+ # @return [String] the full API URL
62
+ def api_url(path)
63
+ path
64
+ end
65
+
66
+ private
67
+
68
+ # Calls an Aspire API endpoint and processes the response.
69
+ # Keyword parameters are passed directly to the REST client
70
+ # @return [(RestClient::Response, Hash)] the REST client response and
71
+ # parsed JSON data from the response
72
+ def call_api(**rest_options)
73
+ @retry.do do
74
+ res = RestClient::Request.execute(**rest_options)
75
+ json = res && !res.empty? ? ::JSON.parse(res.to_s) : nil
76
+ call_api_response(res) if respond_to?(:call_api_response)
77
+ [res, json]
78
+ end
79
+ end
80
+
81
+ # Returns the HTTP headers for an API call
82
+ # @param headers [Hash] optional headers to add to the API call
83
+ # @param params [Hash] query string parameters for the API call
84
+ # @return [Hash] the HTTP headers
85
+ def call_rest_headers(headers, params)
86
+ rest_headers = {}.merge(headers || {})
87
+ rest_headers[:params] = params if params && !params.empty?
88
+ rest_headers
89
+ end
90
+
91
+ # Returns the REST client options for an API call
92
+ # @param path [String] the path of the API call
93
+ # @param headers [Hash<String, String>] HTTP headers for the API call
94
+ # @param options [Hash<String, Object>] options for the REST client
95
+ # @param params [Hash<String, String>] query string parameters
96
+ # @param payload [String, nil] the data to post to the API call
97
+ # @return [Hash] the REST client options
98
+ def call_rest_options(path, headers: nil, options: nil, params: nil,
99
+ payload: nil)
100
+ rest_headers = call_rest_headers(headers, params)
101
+ rest_options = {
102
+ headers: rest_headers,
103
+ url: api_url(path)
104
+ }
105
+ common_rest_options(rest_options)
106
+ rest_options[:payload] = payload if payload
107
+ rest_options.merge!(options) if options
108
+ rest_options[:method] ||= payload ? :post : :get
109
+ rest_options
110
+ end
111
+
112
+ # Sets the REST client options common to all API calls
113
+ # @param rest_options [Hash] the REST client options
114
+ # @return [Hash] the REST client options
115
+ def common_rest_options(rest_options)
116
+ SSL_OPTS.each { |opt| rest_options[opt] = ssl[opt] if ssl[opt] }
117
+ rest_options[:timeout] = timeout > 0 ? timeout : nil
118
+ rest_options
119
+ end
120
+
121
+ # Initialises retry options
122
+ # @param opts [Hash] the options hash
123
+ # @return [void]
124
+ def initialize_retry(opts)
125
+ @retry = Retry::Engine.new(delay: opts[:retry_delay] || 5,
126
+ exceptions: initialize_retry_exceptions,
127
+ handlers: initialize_retry_handlers,
128
+ tries: opts[:retries] || 5)
129
+ end
130
+
131
+ # Returns a hash of retriable exceptions
132
+ # @return [Hash<Exception|Symbol, Boolean>] the retriable exceptions
133
+ def initialize_retry_exceptions
134
+ [
135
+ RestClient::ExceptionWithResponse,
136
+ RestClient::ServerBrokeConnection,
137
+ RestClient::Exceptions::Timeout
138
+ ].push(*Retry::Exceptions::SOCKET_EXCEPTIONS)
139
+ end
140
+
141
+ # Returns a hash of retry handlers
142
+ # @return [Hash<Exception|Symbol, Proc>] the retry handlers
143
+ def initialize_retry_handlers
144
+ {
145
+ :default => proc { |e, _t| log_exception(e) },
146
+ :retry => proc { |_e, t| logger.debug("Retrying (#{t} tries left)") },
147
+ RestClient::ExceptionWithResponse => proc do |e, _t|
148
+ log_exception(e, debug: "Response: #{e}")
149
+ # json = ::JSON.parse(response.to_s) if response && !response.empty?
150
+ raise Retry::StopRetry.new([e.response, nil])
151
+ end
152
+ }
153
+ end
154
+
155
+ # Sets the SSL options
156
+ # @param opts [Hash] the options hash
157
+ def initialize_ssl(opts)
158
+ self.ssl = {}
159
+ SSL_OPTS.each { |opt| ssl[opt] = opts[opt] }
160
+ end
161
+
162
+ # Logs an exception
163
+ # @param e [Exception] the exception
164
+ # @param debug [String] extra debugging message
165
+ def log_exception(e, debug: nil)
166
+ return unless logger
167
+ logger.error(e.to_s)
168
+ logger.debug(e.backtrace.join('\n'))
169
+ logger.debug(debug) if debug
170
+ end
171
+
172
+ # Returns a URI instance for a URL, treating URLs without schemes or path
173
+ # components as host names
174
+ # @param url [String] the URL
175
+ # @return [URI] the URI instance
176
+ def uri(url)
177
+ url = URI.parse(url) unless url.is_a?(URI)
178
+ if url.host.nil? && url.scheme.nil?
179
+ url.host = url.path
180
+ url.path = ''
181
+ end
182
+ url.scheme ||= 'http'
183
+ url
184
+ rescue URI::InvalidComponentError, URI::InvalidURIError
185
+ nil
186
+ end
187
+
188
+ # Returns the host name of a URL, treating URLs without schemes or path
189
+ # components as host names
190
+ # @param url [String] the URL
191
+ # @return [String] the host name of the URL
192
+ def uri_host(url)
193
+ url = uri(url)
194
+ url ? url.host : nil
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,195 @@
1
+ require 'base64'
2
+
3
+ require_relative 'base'
4
+
5
+ module Aspire
6
+ module API
7
+ # A wrapper class for the Aspire JSON API
8
+ class JSON < Base
9
+ # The default API root URL
10
+ API_ROOT = "https://#{ASPIRE_DOMAIN}".freeze
11
+
12
+ # The default authentication API root URL
13
+ API_ROOT_AUTH = "https://#{ASPIRE_AUTH_DOMAIN}/1/oauth/tokens".freeze
14
+
15
+ # @!attribute [rw] api_root
16
+ # @return [String] the base URL of the Aspire JSON APIs
17
+ attr_accessor :api_root
18
+
19
+ # @!attribute [rw] api_root_auth
20
+ # @return [String] the base URL of the Aspire Persona authentication API
21
+ attr_accessor :api_root_auth
22
+
23
+ # @!attribute [rw] api_version
24
+ # @return [Integer] the version of the Aspire JSON APIs
25
+ attr_accessor :api_version
26
+
27
+ # @!attribute [rw] rate_limit
28
+ # @return [Integer] the rate limit value from the most recent API call
29
+ attr_accessor :rate_limit
30
+
31
+ # @!attribute [rw] rate_remaining
32
+ # @return [Integer] the rate remaining value from the most recent API
33
+ # call (the number of calls remaining within the current limit period)
34
+ attr_accessor :rate_remaining
35
+
36
+ # @!attribute [rw] rate_reset
37
+ # @return [Integer] the rate reset value from the most recent API call
38
+ # (the time in seconds since the Epoch until the next limit period)
39
+ attr_accessor :rate_reset
40
+
41
+ # Initialises a new API instance
42
+ # @param api_client_id [String] the API client ID
43
+ # @param api_secret [String] the API secret associated with the client ID
44
+ # @param tenancy_code [String] the Aspire short tenancy code
45
+ # @param opts [Hash] API customisation options
46
+ # @option opts [String] :api_root the base URL of the Aspire JSON APIs
47
+ # @option opts [String] :api_root_auth the base URL of the Aspire Persona
48
+ # authentication API
49
+ # @option opts [Integer] :api_version the version of the Aspire JSON APIs
50
+ # @option opts [Logger] :logger a logger for activity logging
51
+ # @option opts [Integer] :timeout the API call timeout period in seconds
52
+ # @return [void]
53
+ def initialize(api_client_id = nil, api_secret = nil, tenancy_code = nil,
54
+ **opts)
55
+ super(tenancy_code, **opts)
56
+ @api_client_id = api_client_id
57
+ @api_secret = api_secret
58
+ @api_token = nil
59
+ self.api_root = opts[:api_root] || API_ROOT
60
+ self.api_root_auth = opts[:api_root_auth] || API_ROOT_AUTH
61
+ self.api_version = opts[:api_version] || 2
62
+ rate_limit
63
+ end
64
+
65
+ # Returns a full Aspire JSON API URL. Full URLs are returned as-is,
66
+ # partial endpoint paths are expanded with the API root, version and
67
+ # tenancy code.
68
+ # @param path [String] the full URL or partial endpoint path
69
+ # @return [String] the full JSON API URL
70
+ def api_url(path)
71
+ return path if path.include?('//')
72
+ "#{api_root}/#{api_version}/#{tenancy_code}/#{path}"
73
+ end
74
+
75
+ # Calls an Aspire JSON API method and returns the parsed JSON response
76
+ # Additional keyword parameters are passed as query string parameters
77
+ # to the API call.
78
+ # @param path [String] the path of the API call
79
+ # @param headers [Hash<String, String>] HTTP headers for the API call
80
+ # @param options [Hash<String, Object>] options for the REST client
81
+ # @param payload [String, nil] the data to post to the API call
82
+ # @return [Hash] the parsed JSON content from the API response
83
+ # @yield [response, data] Passes the REST client response and parsed JSON
84
+ # hash to the block
85
+ # @yieldparam [RestClient::Response] the REST client response
86
+ # @yieldparam [Hash] the parsed JSON data from the response
87
+ def call(path, headers: nil, options: nil, payload: nil, **params)
88
+ rest_options = call_rest_options(path,
89
+ headers: headers, options: options,
90
+ payload: payload, params: params)
91
+ response, data = call_api_with_auth(**rest_options)
92
+ yield(response, data) if block_given?
93
+ data
94
+ end
95
+
96
+ private
97
+
98
+ # Returns an Aspire OAuth API token. New tokens are retrieved from the
99
+ # Aspire Persona API and cached for subsequent API calls.
100
+ # @param refresh [Boolean] if true, force retrieval of a new token
101
+ # @return [String] the API token
102
+ def api_token(refresh = false)
103
+ # Return the cached token unless forcing a refresh
104
+ return @api_token unless @api_token.nil? || refresh
105
+ # Set the token to nil to indicate that there is no current valid token
106
+ # in case an exception is thrown by the API call.
107
+ @api_token = nil
108
+ # Get and return the API token
109
+ _response, data = call_api(**api_token_rest_options)
110
+ @api_token = data['access_token']
111
+ end
112
+
113
+ # Returns the HTTP Basic authentication token
114
+ # @return [String] the Basic authentication token
115
+ def api_token_authorization
116
+ Base64.strict_encode64("#{@api_client_id}:#{@api_secret}")
117
+ end
118
+
119
+ # Returns the HTTP headers for the token retrieval API call
120
+ def api_token_rest_headers
121
+ {
122
+ Authorization: "basic #{api_token_authorization}",
123
+ 'Content-Type'.to_sym => 'application/x-www-form-urlencoded'
124
+ }
125
+ end
126
+
127
+ def api_token_rest_options
128
+ rest_options = {
129
+ headers: api_token_rest_headers,
130
+ payload: { grant_type: 'client_credentials' },
131
+ url: api_root_auth
132
+ }
133
+ common_rest_options(rest_options)
134
+ rest_options[:method] = :post
135
+ rest_options
136
+ end
137
+
138
+ # Returns true if the HTTP response is an authentication failure
139
+ # @param response [RestClient::Response] the REST client response
140
+ # @return [Boolean] true on authentication failure, false otherwise
141
+ def auth_failed(response)
142
+ response && response.code == 401
143
+ end
144
+
145
+ # Performs custom HTTP response processing
146
+ # @param response [RestClient::Response] the REST client response
147
+ # @return [void]
148
+ def call_api_response(response)
149
+ rate_limit(headers: response.headers)
150
+ end
151
+
152
+ # Calls an authenticated Aspire API endpoint and processes the response.
153
+ # The call is made first with the currently-cached authentication token.
154
+ # If this fails due to authentication, the token is refreshed and the call
155
+ # is repeated once with the new token.
156
+ # @see (#call_api)
157
+ def call_api_with_auth(**rest_options)
158
+ refresh = false
159
+ loop do
160
+ token = api_token(refresh)
161
+ rest_options[:headers]['Authorization'] = "Bearer #{token}"
162
+ response, data = call_api(**rest_options)
163
+ # Stop if we have a valid response or we've already tried to refresh
164
+ # the token.
165
+ return response, data unless auth_failed(response) && !refresh
166
+ # The API token may have expired, try one more time with a new token
167
+ refresh = true
168
+ end
169
+ end
170
+
171
+ # Sets API rate-limit parameters
172
+ # @param headers [Hash] the HTTP response headers
173
+ # @param limit [Integer] the default rate limit
174
+ # @param remaining [Integer] the default remaining count
175
+ # @param reset [Integer] the default reset period timestamp
176
+ # @return [nil]
177
+ def rate_limit(headers: {}, limit: nil, remaining: nil, reset: nil)
178
+ reset = rate_limit_header(:reset, headers, reset)
179
+ self.rate_limit = rate_limit_header(:limit, headers, limit)
180
+ self.rate_remaining = rate_limit_header(:remaining, headers, remaining)
181
+ self.rate_reset = reset ? Time(reset) : nil
182
+ end
183
+
184
+ # Returns the numeric value of a rate-limit header
185
+ # @param header [String, Symbol] the header (minus x_ratelimit_ prefix)
186
+ # @param headers [Hash] the HTTP response headers
187
+ # @param default [Integer, nil] the default value if the header is missing
188
+ # @return [Integer, nil] the numeric value of the header
189
+ def rate_limit_header(header, headers, default)
190
+ value = headers["x_ratelimit_#{header}".to_sym]
191
+ value ? value.to_i : default
192
+ end
193
+ end
194
+ end
195
+ end