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,34 @@
1
+ require 'forwardable'
2
+ module Talis
3
+ module Bibliography
4
+ # Provides convenience methods for working with MetatronClient::*ResultSets
5
+ # TODO: eventually this will be replaced with PaginatedJsonApiArray from
6
+ # Atlas
7
+ module ResultSet
8
+ include Enumerable
9
+ extend Forwardable
10
+ def_delegators :@data, :[], :each, :first, :last, :find, :find_all
11
+
12
+ # Replaces the MetatronClient objects with more intuitive DSL objects
13
+ # and hydrates the relationships from the included array, if available
14
+ def hydrate
15
+ model_data
16
+ self
17
+ end
18
+
19
+ private
20
+
21
+ def model_data
22
+ data.each_with_index do |data, i|
23
+ if is_a? MetatronClient::WorkResultSet
24
+ resource = Work.new data
25
+ elsif is_a? MetatronClient::ManifestationResultSet
26
+ resource = Manifestation.new data
27
+ end
28
+ resource.hydrate_relationships(included) if included
29
+ self.data[i] = resource
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,164 @@
1
+ require 'metatron_ruby_client'
2
+ require 'forwardable'
3
+
4
+ module Talis
5
+ module Bibliography
6
+ # Represents bibliographic works API operations provided by the Metatron gem
7
+ # {https://github.com/talis/metatron_rb}
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 Work < Talis::Resource
16
+ extend Forwardable, Talis::OAuthService, Talis::Bibliography
17
+ base_uri Talis::METATRON_HOST
18
+ attr_reader :manifestations, :assets, :work_data
19
+ attr_accessor :id, :type, :title
20
+ def_delegators :@work_data, :id, :type
21
+
22
+ # rubocop:disable Metrics/LineLength
23
+ class << self
24
+ # Search for bibliographic works
25
+ # @param request_id [String] ('uuid') unique ID for the remote request.
26
+ # @param query [String] the query to filter works on
27
+ # @param include [Array] the related resources to associate with each work
28
+ # see {https://github.com/talis/metatron_rb/blob/master/docs/DefaultApi.md#2_works_get}
29
+ # @param opts [Hash] Can include key/value pairs for offset (default: 0),
30
+ # limit (default 20), and escape_query (boolean), which will escape any
31
+ # reserved characters from the query parameter
32
+ # @return [MetatronClient::WorkResultSet] containing data and meta attributes.
33
+ # The structure is as follows:
34
+ # {
35
+ # data: [work1, work2, work3, work4, work5],
36
+ # meta: { offset: 0, count: 20, limit: 5 }
37
+ # included: [manifestation|assset1, manifestation|assset2]
38
+ # }
39
+ # where works are of type Talis::Bibliography::Work, which are also available
40
+ # directly via the Enumerable methods: each, find, find_all, first, last
41
+ # @raise [Talis::ClientError] if the request was invalid.
42
+ # @raise [Talis::ServerError] if the search failed on the
43
+ # server.
44
+ # @raise [Talis::ServerCommunicationError] for network issues.
45
+ def find(request_id: new_req_id, query:, include: [], opts: {})
46
+ query = escape_query(query) if opts[:escape_query]
47
+ offset = opts[:offset] || 0
48
+ limit = opts[:limit] || 20
49
+ search_works(request_id, query, offset, limit, include)
50
+ rescue MetatronClient::ApiError => error
51
+ begin
52
+ handle_response(error)
53
+ rescue Talis::NotFoundError
54
+ empty_result(offset, limit)
55
+ end
56
+ end
57
+
58
+ # Fetch a single work by id
59
+ # @param request_id [String] ('uuid') unique ID for the remote request.
60
+ # @param id [String] the ID of the work to fetch.
61
+ # @return Talis::Bibliography::Work or nil if the work cannot be found.
62
+ # @raise [Talis::ClientError] if the request was invalid.
63
+ # @raise [Talis::ServerError] if the fetch failed on the
64
+ # server.
65
+ # @raise [Talis::ServerCommunicationError] for network issues.
66
+ def get(request_id: new_req_id, id:)
67
+ new api_client(request_id).works_work_id_assets_get(id, token).data
68
+ rescue MetatronClient::ApiError => error
69
+ begin
70
+ handle_response(error)
71
+ rescue Talis::NotFoundError
72
+ nil
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def search_works(request_id, query, offset, limit, include)
79
+ api_client(request_id).work(token, query, limit,
80
+ offset, include: include)
81
+ .extend(ResultSet).hydrate
82
+ end
83
+
84
+ def empty_result(offset, limit)
85
+ empty_result_set(MetatronClient::WorkResultSet,
86
+ offset: offset, limit: limit, count: 0)
87
+ end
88
+ end
89
+
90
+ def initialize(work_data = nil)
91
+ if work_data.is_a? MetatronClient::WorkData
92
+ parse_work_data work_data
93
+ else
94
+ @work_data = MetatronClient::WorkData.new
95
+ end
96
+ end
97
+
98
+ # TODO: call manifestation route if not set
99
+ def manifestations
100
+ @manifestations ||= []
101
+ end
102
+
103
+ # TODO: call assets route if not set
104
+ def assets
105
+ @assets ||= []
106
+ end
107
+
108
+ # By default, the metatron client returns generic ResourceLink objects
109
+ # as the related resources. When passed an array of Metatron::ResourceData
110
+ # objects, it will replace the ResourceLink objects with more appropriately
111
+ # typed objects
112
+ # @param resources [Array] an array of Metatron::ResourceData objects
113
+ def hydrate_relationships(resources)
114
+ manifestations.map! do |m|
115
+ resource = find_relationship_in_included m.manifestation_data.to_hash,
116
+ resources
117
+ return m unless resource
118
+ hydrate_manifestation_assets resource, resources
119
+ Manifestation.new(MetatronClient::ManifestationData.new(
120
+ resource.to_hash
121
+ ))
122
+ end
123
+ nil
124
+ end
125
+
126
+ private
127
+
128
+ def hydrate_manifestation_assets(manifestation, resources)
129
+ if manifestation_has_assets? manifestation
130
+ manifestation.relationships[:assets][:data].each do |asset_data|
131
+ asset = find_relationship_in_included asset_data.to_hash, resources
132
+ assets << MetatronClient::AssetData.new(asset.to_hash) if asset
133
+ end
134
+ end
135
+ nil
136
+ end
137
+
138
+ def manifestation_has_assets?(manifestation)
139
+ manifestation.try(:relationships) &&
140
+ manifestation.relationships[:assets] &&
141
+ manifestation.relationships[:assets][:data]
142
+ end
143
+
144
+ def find_relationship_in_included(resource_data, included)
145
+ included.find do |resource|
146
+ resource.id == resource_data[:id] && resource.type == resource_data[:type]
147
+ end
148
+ end
149
+
150
+ def parse_work_data(work_data)
151
+ @work_data = work_data
152
+ @title = work_data.try(:attributes).try(:titles)
153
+
154
+ if work_data.try(:relationships).try(:manifestations).try(:data)
155
+ work_data.relationships.manifestations.data.each do |manifestation|
156
+ manifestations << Manifestation.new(
157
+ MetatronClient::ManifestationData.new(manifestation.to_hash)
158
+ )
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,9 @@
1
+ module Talis
2
+ BABEL_HOST = 'https://feeds.talis.com'.freeze
3
+ BLUEPRINT_HOST = 'https://hierarchy.talis.com'.freeze
4
+ ECHO_HOST = 'https://analytics.talis.com'.freeze
5
+ METATRON_HOST = 'https://bibliography.talis.com'.freeze
6
+ PERSONA_HOST = 'https://users.talis.com'.freeze
7
+
8
+ EBOOK_TYPE = 'ebook_inventory'.freeze
9
+ end
@@ -0,0 +1,10 @@
1
+ module Talis
2
+ # All errors will extend from this base class.
3
+ class Error < StandardError
4
+ end
5
+ end
6
+
7
+ require_relative 'errors/authentication_failed_error'
8
+ require_relative 'errors/client_errors'
9
+ require_relative 'errors/server_error'
10
+ require_relative 'errors/server_communication_error'
@@ -0,0 +1,4 @@
1
+ module Talis
2
+ class AuthenticationFailedError < Talis::Error
3
+ end
4
+ end
@@ -0,0 +1,19 @@
1
+ module Talis
2
+ class ClientError < Talis::Error
3
+ end
4
+
5
+ class BadRequestError < ClientError
6
+ end
7
+
8
+ class UnauthorizedError < ClientError
9
+ end
10
+
11
+ class ForbiddenError < ClientError
12
+ end
13
+
14
+ class NotFoundError < ClientError
15
+ end
16
+
17
+ class ConflictError < ClientError
18
+ end
19
+ end
@@ -0,0 +1,4 @@
1
+ module Talis
2
+ class ServerCommunicationError < Talis::Error
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Talis
2
+ class ServerError < Talis::Error
3
+ end
4
+ end
@@ -0,0 +1,11 @@
1
+ # Helper functions for objects
2
+ class Object
3
+ def blank?
4
+ subject = respond_to?(:trim) ? strip : self
5
+ subject.respond_to?(:empty?) ? subject.empty? : !subject
6
+ end
7
+
8
+ def present?
9
+ !blank?
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ require_relative 'feeds/annotation'
2
+ require_relative 'feeds/feed'
3
+
4
+ module Talis
5
+ # Represents a collection of classes used to work with annotations and feeds.
6
+ module Feed
7
+ end
8
+ end
@@ -0,0 +1,129 @@
1
+ module Talis
2
+ module Feeds
3
+ # Represents an annotation, for example, a video resource comment.
4
+ class Annotation < Talis::Resource
5
+ base_uri Talis::BABEL_HOST
6
+ attr_reader :id, :body, :target, :annotated_by, :motivated_by,
7
+ :expires_at, :user
8
+
9
+ # Creates a new annotation object. For internal use only,
10
+ # use {Annotation.create}.
11
+ # @param data [Hash] The incoming annotation data to construct
12
+ # a new annotation object with.
13
+ # @param user [Talis::User](nil) The user that created the annotation.
14
+ # This will be nil for an annotation that has not been hydrated.
15
+ # Hydrated data is provided by feeds.
16
+ def initialize(data, user: nil)
17
+ # Symbolise all keys for consistency between what was provided
18
+ # and what is returned.
19
+ data = JSON.parse(JSON[data], symbolize_names: true)
20
+ @id = data[:_id]
21
+ @body = data[:hasBody]
22
+ @target = data[:hasTarget].map { |target| underscore(target) }
23
+ @annotated_by = data[:annotatedBy]
24
+ @motivated_by = data[:motivatedBy]
25
+ @expires_at = data[:expiresAt]
26
+ @user = user
27
+ end
28
+
29
+ private
30
+
31
+ def underscore(payload)
32
+ payload.map { |key, value| [key.to_s.underscore.to_sym, value] }.to_h
33
+ end
34
+
35
+ class << self
36
+ # Create a new annotation that is persisted.
37
+ # @param opts [Hash] The annotation data used to create the annotation:
38
+ # {
39
+ # request_id: 'optional unique ID for the remote request'
40
+ # body: {
41
+ # format: 'e.g: text/plain',
42
+ # type: 'e.g: Text',
43
+ # chars: 'annotation content',
44
+ # details: {} # optional hash to provide additional details.
45
+ # },
46
+ # target: [
47
+ # {
48
+ # uri: 'location of target being annotated',
49
+ # as_referenced_by: 'optional reference location',
50
+ # fragment: 'optional fragment within location'
51
+ # }
52
+ # ]
53
+ # annotated_by: 'Talis user GUID performing the annotation',
54
+ # motivated_by: 'optional motivation string',
55
+ # expires_at: 'optional ISO 8601 date time string'
56
+ # }
57
+ # @return [Talis::Feeds::Annotation] The persisted annotation.
58
+ # @raise [Talis::ClientError] if the request was invalid.
59
+ # @raise [Talis::ServerError] if there was a problem with the request.
60
+ # @raise [Talis::ServerCommunicationError] for network issues.
61
+ # @raise [ArgumentError] for validation errors against opts.
62
+ def create(opts)
63
+ validate_annotation opts
64
+ new handle_response(post_annotation(opts))
65
+ rescue SocketError
66
+ raise Talis::ServerCommunicationError
67
+ end
68
+
69
+ private
70
+
71
+ def validate_annotation(annotation)
72
+ min_required = 'annotation must contain body, target and annotated_by'
73
+ required = [:body, :target, :annotated_by]
74
+ provided = annotation.select { |attr| required.include? attr }.keys
75
+ raise ArgumentError, min_required unless required == provided
76
+ validate_targets(annotation)
77
+ validate_expiry(annotation)
78
+ end
79
+
80
+ def validate_targets(annotation)
81
+ targets = annotation[:target]
82
+ raise ArgumentError,
83
+ 'annotation target must be an array' unless targets.is_a? Array
84
+ targets.each do |target|
85
+ raise ArgumentError,
86
+ 'annotation targets must contain uri' unless target[:uri]
87
+ raise ArgumentError,
88
+ 'target uri must be a string' unless target[:uri].is_a? String
89
+ end
90
+ end
91
+
92
+ def validate_expiry(annotation)
93
+ expiry = annotation[:expires_at]
94
+ Time.iso8601(expiry) if expiry
95
+ rescue StandardError
96
+ raise ArgumentError, 'expired_at must be a valid ISO 8601 date'
97
+ end
98
+
99
+ def post_annotation(opts)
100
+ request_id = opts[:request_id] || new_req_id
101
+ post('/annotations',
102
+ headers: {
103
+ 'Content-Type' => 'application/json',
104
+ 'X-Request-Id' => request_id,
105
+ 'Authorization' => "Bearer #{token}"
106
+ },
107
+ body: body(opts).to_json)
108
+ end
109
+
110
+ def body(opts)
111
+ min_body = {
112
+ hasBody: opts[:body],
113
+ hasTarget: opts[:target].map { |target| camelize(target) },
114
+ annotatedBy: opts[:annotated_by]
115
+ }
116
+ min_body[:expiresAt] = opts[:expires_at] if opts[:expires_at]
117
+ min_body[:motivatedBy] = opts[:motivated_by] if opts[:motivated_by]
118
+ min_body
119
+ end
120
+
121
+ def camelize(payload)
122
+ payload.map do |key, value|
123
+ [key.to_s.camelize(:lower).to_sym, value]
124
+ end.to_h
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,58 @@
1
+ module Talis
2
+ module Feeds
3
+ # Represents a feed, as a way of bringing back a collection of annotations.
4
+ class Feed < Talis::Resource
5
+ base_uri Talis::BABEL_HOST
6
+
7
+ class << self
8
+ # Returns a collection of annotations matching the provided target.
9
+ # @param request_id [String] ('uuid') unique ID for the remote request.
10
+ # @param target_uri [String] The URI uniquely identifying the target.
11
+ # @return [Array<Talis::Feeds::Annotation>] An array of annotations
12
+ # or an empty array if the feed was not found.
13
+ # @raise [Talis::ClientError] if the request was invalid.
14
+ # @raise [Talis::ServerError] if there was a problem with the request.
15
+ # @raise [Talis::ServerCommunicationError] for network issues.
16
+ def find(request_id: new_req_id, target_uri:)
17
+ md5_target_uri = Digest::MD5.hexdigest(target_uri)
18
+ response = fetch_feed(request_id, md5_target_uri)
19
+ begin
20
+ build handle_response(response)
21
+ rescue Talis::NotFoundError
22
+ []
23
+ end
24
+ rescue SocketError
25
+ raise Talis::ServerCommunicationError
26
+ end
27
+
28
+ private
29
+
30
+ def fetch_feed(request_id, md5_target_uri)
31
+ get("/feeds/targets/#{md5_target_uri}/activity/annotations/hydrate",
32
+ headers: {
33
+ 'Content-Type' => 'application/json',
34
+ 'X-Request-Id' => request_id,
35
+ 'Authorization' => "Bearer #{token}"
36
+ })
37
+ end
38
+
39
+ def build(response)
40
+ annotations = response['annotations']
41
+ annotations.map do |annotation|
42
+ Annotation.new(annotation, user: hydrate_user(annotation, response))
43
+ end
44
+ end
45
+
46
+ def hydrate_user(annotation_data, feed_data)
47
+ profiles = feed_data['userProfiles']
48
+ return unless profiles
49
+ guid = annotation_data['annotatedBy']
50
+ user = profiles[guid]
51
+ return unless user
52
+ Talis::User.build(guid: guid, first_name: user['first_name'],
53
+ surname: user['surname'], email: user['email'])
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end