talis 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
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