talis 0.12.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +12 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +2 -0
  5. data/.travis.yml +24 -0
  6. data/.yardopts +1 -0
  7. data/CONTRIBUTING.md +28 -0
  8. data/Gemfile +4 -0
  9. data/Guardfile +5 -0
  10. data/README.md +76 -0
  11. data/Rakefile +8 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +8 -0
  14. data/lib/talis.rb +25 -0
  15. data/lib/talis/analytics.rb +31 -0
  16. data/lib/talis/analytics/event.rb +67 -0
  17. data/lib/talis/authentication.rb +14 -0
  18. data/lib/talis/authentication/client.rb +82 -0
  19. data/lib/talis/authentication/login.rb +169 -0
  20. data/lib/talis/authentication/public_key.rb +53 -0
  21. data/lib/talis/authentication/token.rb +172 -0
  22. data/lib/talis/bibliography.rb +52 -0
  23. data/lib/talis/bibliography/ebook.rb +50 -0
  24. data/lib/talis/bibliography/manifestation.rb +141 -0
  25. data/lib/talis/bibliography/result_set.rb +34 -0
  26. data/lib/talis/bibliography/work.rb +164 -0
  27. data/lib/talis/constants.rb +9 -0
  28. data/lib/talis/errors.rb +10 -0
  29. data/lib/talis/errors/authentication_failed_error.rb +4 -0
  30. data/lib/talis/errors/client_errors.rb +19 -0
  31. data/lib/talis/errors/server_communication_error.rb +4 -0
  32. data/lib/talis/errors/server_error.rb +4 -0
  33. data/lib/talis/extensions/object.rb +11 -0
  34. data/lib/talis/feeds.rb +8 -0
  35. data/lib/talis/feeds/annotation.rb +129 -0
  36. data/lib/talis/feeds/feed.rb +58 -0
  37. data/lib/talis/hierarchy.rb +9 -0
  38. data/lib/talis/hierarchy/asset.rb +265 -0
  39. data/lib/talis/hierarchy/node.rb +200 -0
  40. data/lib/talis/hierarchy/resource.rb +159 -0
  41. data/lib/talis/oauth_service.rb +18 -0
  42. data/lib/talis/resource.rb +68 -0
  43. data/lib/talis/user.rb +112 -0
  44. data/lib/talis/version.rb +3 -0
  45. data/talis.gemspec +39 -0
  46. metadata +327 -0
@@ -0,0 +1,53 @@
1
+ require 'active_support'
2
+ require 'talis/authentication/token'
3
+
4
+ module Talis
5
+ module Authentication
6
+ # Provides the ability to fetch a public key to verify tokens.
7
+ # There is no need to use this class directly as it is used by the Token
8
+ # class to verify tokens.
9
+ class PublicKey < Talis::Resource
10
+ base_uri Token.base_uri
11
+
12
+ # Construct an empty public key object.
13
+ # @param cache_store [ActiveSupport::Cache::MemoryStore] A cache
14
+ # store to use to fetch locally cached keys before trying remotely.
15
+ def initialize(cache_store)
16
+ @cache_store = cache_store
17
+ end
18
+
19
+ # Fetch a public key for use with token verification, either from
20
+ # the provided cache or remotely.
21
+ # @param request_id [String] (uuid) unique ID for the remote request.
22
+ # @return [String] the public key.
23
+ def fetch(request_id: self.class.new_req_id)
24
+ # Token base URI may have changed after the class was loaded.
25
+ self.class.base_uri(Token.base_uri)
26
+ public_key = @cache_store.fetch(cache_key, cache_options) do
27
+ opts = { format: :plain, headers: { 'X-Request-Id' => request_id } }
28
+ response = self.class.get('/oauth/keys', opts)
29
+ self.class.handle_response(response)
30
+ end
31
+ OpenSSL::PKey.read(public_key)
32
+ end
33
+
34
+ private
35
+
36
+ def digest_data(data)
37
+ md4 = OpenSSL::Digest::MD4.new
38
+ Base64.encode64(md4.digest(data))
39
+ end
40
+
41
+ def cache_key
42
+ "public_key:#{digest_data(self.class.base_uri)}"
43
+ end
44
+
45
+ def cache_options
46
+ {
47
+ expires_in: ENV.fetch('PUBLIC_KEY_EXPIRY_SECONDS', 7.minutes),
48
+ race_condition_ttl: 10.seconds
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,172 @@
1
+ require 'active_support'
2
+ require 'jwt'
3
+ require 'openssl'
4
+
5
+ module Talis
6
+ module Authentication
7
+ # Represents a JWT-based OAuth access token.
8
+ #
9
+ # Optionally configure an ActiveSupport-based cache store for caching the
10
+ # public key and tokens. The default cache used is an in-memory one.
11
+ # See http://api.rubyonrails.org/classes/ActiveSupport/Cache.html for
12
+ # supported cache types.
13
+ # @example Using an in-memory cache.
14
+ # store = ActiveSupport::Cache::MemoryStore.new
15
+ # Talis::Authentication::Token.cache_store = store
16
+ class Token < Talis::Resource
17
+ base_uri Talis::PERSONA_HOST
18
+ cattr_accessor :cache_store
19
+ Token.cache_store = ActiveSupport::Cache::MemoryStore.new
20
+
21
+ # Create a new token object from an existing JWT.
22
+ # @param jwt [String] the encoded JWT.
23
+ # @param public_key [PublicKey] (nil) Only used in unit tests.
24
+ def initialize(jwt:, public_key: nil)
25
+ @jwt = jwt
26
+ @public_key = public_key || PublicKey.new(Token.cache_store)
27
+ end
28
+
29
+ # Validate the token, optionally against one or more required scopes.
30
+ #
31
+ # Scope validation is performed locally unless there are too many tokens
32
+ # to list inside the token payload. When this is the case, a remote
33
+ # request is performed to validate the token against the scopes.
34
+ #
35
+ # The validation error returned can be one of the following:
36
+ # - `:expired_token` if the token has expired.
37
+ # - `:insufficient_scope` if the provided scopes are not in the token.
38
+ # - `:invalid_token` if the token could not be verified by the public
39
+ # key.
40
+ # - `:invalid_token` if the token could not be decoded.
41
+ # - `:invalid_key` if the public key is corrupt.
42
+ # @param request_id [String] (uuid) unique ID for the remote request.
43
+ # @param scopes [Array] Scope(s) that the token needs in order to be
44
+ # valid.
45
+ # @param all [Boolean] (true) Whether or not all scopes must be present
46
+ # within the token for validation to pass. If false, only one matching
47
+ # scope is required.
48
+ # @return [Symbol, Nil] nil iff the token is valid else a symbol error.
49
+ # @raise [Talis::ServerError] if the sever cannot validate the
50
+ # scope.
51
+ # @raise [Talis::ServerCommunicationError] for network issues.
52
+ def validate(request_id: self.class.new_req_id, scopes: [], all: true)
53
+ decoded = JWT.decode(@jwt, p_key(request_id), true, algorithm: 'RS256')
54
+ validate_scopes(request_id, scopes, decoded[0], all)
55
+ rescue JWT::ExpiredSignature
56
+ return :expired_token
57
+ rescue JWT::VerificationError, JWT::DecodeError
58
+ return :invalid_token
59
+ rescue NoMethodError
60
+ return :invalid_key
61
+ rescue Talis::ClientError
62
+ :insufficient_scope
63
+ end
64
+
65
+ # @return [String] the encoded version of the token - a JWT string.
66
+ def to_s
67
+ @jwt
68
+ end
69
+
70
+ private
71
+
72
+ def p_key(req_id)
73
+ @public_key.fetch(request_id: req_id)
74
+ end
75
+
76
+ def validate_scopes(request_id, wanted_scopes, token, all_must_match)
77
+ # The existence of this key means there are too many scopes to fit
78
+ # into an encoded token, it must be fetched from the server
79
+ token = fetch_token(request_id) if token.key? 'scopeCount'
80
+
81
+ return :invalid_token unless token.key? 'scopes'
82
+ provided_scopes = token['scopes']
83
+ return nil if wanted_scopes.empty?
84
+ return nil if provided_scopes.include? 'su'
85
+ compare_scope_intersect(wanted_scopes, provided_scopes, all_must_match)
86
+ end
87
+
88
+ def compare_scope_intersect(wanted_scope, provided_scope, all_must_match)
89
+ intersect_scope = (wanted_scope & provided_scope)
90
+ if (all_must_match && intersect_scope != wanted_scope) ||
91
+ intersect_scope.empty?
92
+ :insufficient_scope
93
+ end
94
+ end
95
+
96
+ def fetch_token(request_id)
97
+ token_url = "/oauth/tokens/#{@jwt}"
98
+ headers = { headers: { 'X-Request-Id' => request_id } }
99
+ self.class.handle_response(self.class.get(token_url, headers))
100
+ end
101
+
102
+ class << self
103
+ # Generate a new token for the given client.
104
+ # If a previous token has been generated for the client and has not
105
+ # expired then this will be returned from the cache.
106
+ # @param request_id [String] ('uuid') unique ID for the remote request.
107
+ # @param client_id [String] the client for whom this token is for.
108
+ # @param client_secret [String] secret belonging to the client.
109
+ # @param host [String] Optional persona host override for service
110
+ # @return [Talis::Authentication::Token] the generated or cached token.
111
+ # @raise [Talis::ClientError] if the client ID/secret are
112
+ # invalid.
113
+ # @raise [Talis::ServerError] if the generation failed on the
114
+ # server.
115
+ # @raise [Talis::ServerCommunicationError] for network issues.
116
+ def generate(request_id: new_req_id, client_id:, client_secret:,
117
+ host: base_uri)
118
+ token = cached_token(client_id, host)
119
+ if token
120
+ new(jwt: token)
121
+ else
122
+ generate_remote_token(request_id, client_id, client_secret, host)
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def generate_remote_token(request_id, client_id, client_secret, host)
129
+ response = create_token(request_id, client_id, client_secret, host)
130
+ parsed_response = handle_response(response)
131
+ cache_token(parsed_response, client_id, host)
132
+ new(jwt: parsed_response['access_token'])
133
+ end
134
+
135
+ def digest_data(data)
136
+ md4 = OpenSSL::Digest::MD4.new
137
+ Base64.encode64(md4.digest(data))
138
+ end
139
+
140
+ def cache_key(client_id, host)
141
+ "token:#{digest_data(client_id)}_#{digest_data(host)}"
142
+ end
143
+
144
+ def cache_token(data, client_id, host)
145
+ access_token = data['access_token']
146
+ # Expire the cache slightly before the token expires to cater for
147
+ # communication delay between server issuing and client receiving.
148
+ expiry_time = data['expires_in'].to_i - 5.seconds
149
+ Token.cache_store.write(cache_key(client_id, host), access_token,
150
+ expires_in: expiry_time)
151
+ end
152
+
153
+ def cached_token(client_id, host)
154
+ key = cache_key(client_id, host)
155
+ Token.cache_store.fetch(key) if Token.cache_store.exist?(key)
156
+ end
157
+
158
+ def create_token(request_id, client_id, client_secret, host)
159
+ post(host + '/oauth/tokens',
160
+ headers: { 'X-Request-Id' => request_id },
161
+ body: {
162
+ client_id: client_id,
163
+ client_secret: client_secret,
164
+ grant_type: 'client_credentials'
165
+ })
166
+ rescue
167
+ raise Talis::ServerCommunicationError
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,52 @@
1
+ require_relative 'bibliography/result_set'
2
+ require_relative 'bibliography/work'
3
+ require_relative 'bibliography/manifestation'
4
+ require_relative 'bibliography/ebook'
5
+
6
+ module Talis
7
+ # Encompasses all classes associated with bibliographic resources
8
+ module Bibliography
9
+ # Exposes the underlying Metatron API client.
10
+ # @param request_id [String] ('uuid') unique ID for remote requests.
11
+ # @return MetatronClient::DefaultApi
12
+ def api_client(request_id = new_req_id)
13
+ configure_metatron
14
+
15
+ api_client = MetatronClient::ApiClient.new
16
+ api_client.default_headers = {
17
+ 'X-Request-Id' => request_id,
18
+ 'User-Agent' => "talis-ruby-client/#{Talis::VERSION} "\
19
+ "ruby/#{RUBY_VERSION}"
20
+ }
21
+
22
+ MetatronClient::DefaultApi.new(api_client)
23
+ end
24
+
25
+ private
26
+
27
+ def configure_metatron
28
+ MetatronClient.configure do |config|
29
+ config.scheme = base_uri[/https?/]
30
+ config.host = base_uri
31
+ # Non-production environments have a base path
32
+ if ENV['METATRON_BASE_PATH']
33
+ config.base_path = ENV['METATRON_BASE_PATH']
34
+ end
35
+ config.api_key_prefix['Authorization'] = 'Bearer'
36
+ end
37
+ end
38
+
39
+ def empty_result_set(klass, meta_properties)
40
+ meta = OpenStruct.new(meta_properties)
41
+ klass.new(data: [], meta: meta).extend(ResultSet)
42
+ end
43
+
44
+ def escape_query(query_string)
45
+ # TODO: are all of these necessary?
46
+ pattern = %r{
47
+ (\+|\-|\=|\&\&|\|\||\>|\<|\!|\(|\)|\{|\}|\[|\]|\^|\"|\~|\*|\?|\:|\\|\/)
48
+ }x
49
+ query_string.gsub(pattern) { |match| "\\#{match}" }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,50 @@
1
+ require 'metatron_ruby_client'
2
+ require 'forwardable'
3
+
4
+ module Talis
5
+ module Bibliography
6
+ # Represents an eBook which is a type of asset associated with
7
+ # works and their manifestations.
8
+ #
9
+ # In order to perform remote operations, the client must be configured
10
+ # with a valid OAuth client that is allowed to query nodes:
11
+ #
12
+ # Talis::Authentication.client_id = 'client_id'
13
+ # Talis::Authentication.client_secret = 'client_secret'
14
+ #
15
+ class EBook < Talis::Resource
16
+ extend Forwardable, Talis::OAuthService, Talis::Bibliography
17
+ base_uri Talis::METATRON_HOST
18
+ attr_accessor :id, :vbid, :title, :author, :format, :digital_list_price,
19
+ :publisher_list_price, :store_price
20
+ private_class_method :new
21
+
22
+ def initialize(asset_data)
23
+ attrs = asset_data.try(:attributes) || {}
24
+ @id = asset_data.id
25
+ @vbid = attrs[:vbid]
26
+ @title = attrs[:title]
27
+ @author = attrs[:author]
28
+ @format = attrs[:'book-format']
29
+ price_list = attrs.fetch(:pricelist, {})
30
+ @digital_list_price = price_list[:'digital-list-price']
31
+ @publisher_list_price = price_list[:'publisher-list-price']
32
+ @store_price = price_list.fetch(:'store-price', {})[:value]
33
+ end
34
+
35
+ class << self
36
+ def find_by_manifestation_id(manifestation_id, request_id = new_req_id)
37
+ id = manifestation_id.gsub('nbd:', '')
38
+ begin
39
+ set = api_client(request_id).get_manifestation_assets(token, id)
40
+ rescue MetatronClient::ApiError => error
41
+ return [] if error.code == 404
42
+ handle_response(error)
43
+ end
44
+ set.data.select { |asset| asset.type == Talis::EBOOK_TYPE }
45
+ .map { |asset| new(asset) }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,141 @@
1
+ require 'metatron_ruby_client'
2
+ require 'forwardable'
3
+
4
+ module Talis
5
+ module Bibliography
6
+ # Represents bibliographic manifestations API operations provided by the
7
+ # Metatron gem
8
+ # {https://github.com/talis/metatron_rb}
9
+ #
10
+ # In order to perform remote operations, the client must be configured
11
+ # with a valid OAuth client that is allowed to query nodes:
12
+ #
13
+ # Talis::Authentication.client_id = 'client_id'
14
+ # Talis::Authentication.client_secret = 'client_secret'
15
+ #
16
+ class Manifestation < Talis::Resource
17
+ extend Forwardable, Talis::OAuthService, Talis::Bibliography
18
+ base_uri Talis::METATRON_HOST
19
+ attr_reader :contributors, :assets, :manifestation_data, :work
20
+ attr_accessor :id, :type, :title
21
+ def_delegators :@manifestation_data, :id, :type
22
+
23
+ # rubocop:disable Metrics/LineLength
24
+ class << self
25
+ # Search for bibliographic manifestations
26
+ # @param request_id [String] ('uuid') unique ID for the remote request.
27
+ # @param opts [Hash] the query parameters: currently supported: work_id and isbn
28
+ # see {https://github.com/talis/metatron_rb/blob/metatron-swagger-updates/docs/DefaultApi.md#manifestation}
29
+ # @return [MetatronClient::ManifestationResultSet] containing data and meta attributes.
30
+ # The structure is as follows:
31
+ # {
32
+ # data: [manifestation1, manifestation2, manifestation3],
33
+ # meta: { count: 3 }
34
+ # included: [contributor1]
35
+ # }
36
+ # where manifestations are of type Talis::Bibliography::Manifestation, which are also available
37
+ # directly via the Enumerable methods: each, find, find_all, first, last
38
+ # @raise [Talis::ClientError] if the request was invalid.
39
+ # @raise [Talis::ServerError] if the search failed on the
40
+ # server.
41
+ # @raise [Talis::ServerCommunicationError] for network issues.
42
+ def find(request_id: new_req_id, opts: {})
43
+ api_client(request_id).manifestation(token, opts)
44
+ .extend(ResultSet).hydrate
45
+ rescue MetatronClient::ApiError => error
46
+ begin
47
+ handle_response(error)
48
+ rescue Talis::NotFoundError
49
+ empty_result_set(MetatronClient::ManifestationResultSet, count: 0)
50
+ end
51
+ end
52
+
53
+ # Fetch a single work by id
54
+ # @param request_id [String] ('uuid') unique ID for the remote request.
55
+ # @param id [String] the ID of the work to fetch.
56
+ # @return Talis::Bibliography::Work or nil if the work cannot be found.
57
+ # @raise [Talis::ClientError] if the request was invalid.
58
+ # @raise [Talis::ServerError] if the fetch failed on the
59
+ # server.
60
+ # @raise [Talis::ServerCommunicationError] for network issues.
61
+ def get(request_id: new_req_id, id:)
62
+ new api_client(request_id).get_manifestation(token, id).data
63
+ rescue MetatronClient::ApiError => error
64
+ begin
65
+ handle_response(error)
66
+ rescue Talis::NotFoundError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+
72
+ def initialize(manifestation_data = nil)
73
+ if manifestation_data.is_a? MetatronClient::ManifestationData
74
+ parse_manifestation_data manifestation_data
75
+ else
76
+ @manifestation_data = MetatronClient::ManifestationData.new
77
+ end
78
+ end
79
+
80
+ def contributors
81
+ @contributors ||= []
82
+ end
83
+
84
+ # TODO: call assets route if not set
85
+ def assets
86
+ @assets ||= []
87
+ end
88
+
89
+ # By default, the metatron client returns generic ResourceLink objects
90
+ # as the related resources. When passed an array of Metatron::ResourceData
91
+ # objects, it will replace the ResourceLink objects with more appropriately
92
+ # typed objects
93
+ # @param resources [Array] an array of Metatron::ResourceData objects
94
+ def hydrate_relationships(included_resources)
95
+ contributors.map! do |contributor|
96
+ find_relationship_in_included(contributor,
97
+ included_resources)
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def find_relationship_in_included(resource_data, included)
104
+ included.find do |resource|
105
+ resource.id == resource_data.id && resource.type == resource_data.type
106
+ end
107
+ end
108
+
109
+ def parse_manifestation_data(manifestation_data)
110
+ @manifestation_data = manifestation_data
111
+ @title = manifestation_data.try(:attributes).try(:title)
112
+
113
+ unless manifestation_data.relationships.nil?
114
+ add_relationships(manifestation_data)
115
+ end
116
+ end
117
+
118
+ def add_relationships(manifestation_data)
119
+ [:contributors, :work].each do |rel|
120
+ next unless manifestation_data.relationships.try(rel).try(:data)
121
+ if rel == :contributors
122
+ add_related_contributors(manifestation_data)
123
+ else
124
+ add_related_work(manifestation_data)
125
+ end
126
+ end
127
+ end
128
+
129
+ def add_related_contributors(manifestation_data)
130
+ @contributors ||= []
131
+ @contributors += manifestation_data.relationships.contributors.data
132
+ end
133
+
134
+ def add_related_work(manifestation_data)
135
+ @work = MetatronClient::WorkData.new(
136
+ manifestation_data.relationships.work.data.to_hash
137
+ )
138
+ end
139
+ end
140
+ end
141
+ end