d2l-valence 0.0.1

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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +22 -0
  3. data/.gitignore +17 -0
  4. data/.rubocop.yml +13 -0
  5. data/Gemfile +4 -0
  6. data/Gemfile.lock +71 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +266 -0
  9. data/Rakefile +6 -0
  10. data/d2l-valence.gemspec +30 -0
  11. data/lib/d2l/valence.rb +15 -0
  12. data/lib/d2l/valence/app_context.rb +49 -0
  13. data/lib/d2l/valence/auth_tokens.rb +60 -0
  14. data/lib/d2l/valence/encrypt.rb +34 -0
  15. data/lib/d2l/valence/host.rb +43 -0
  16. data/lib/d2l/valence/request.rb +104 -0
  17. data/lib/d2l/valence/response.rb +66 -0
  18. data/lib/d2l/valence/timestamp_error.rb +42 -0
  19. data/lib/d2l/valence/user_context.rb +58 -0
  20. data/lib/d2l/valence/version.rb +5 -0
  21. data/spec/d2l/valence/app_context_spec.rb +14 -0
  22. data/spec/d2l/valence/auth_tokens_spec.rb +30 -0
  23. data/spec/d2l/valence/host_spec.rb +64 -0
  24. data/spec/d2l/valence/request/authenticated_uri_spec.rb +75 -0
  25. data/spec/d2l/valence/request/execute/invalid_time_stamp_spec.rb +40 -0
  26. data/spec/d2l/valence/request/execute/lti_links/create_spec.rb +66 -0
  27. data/spec/d2l/valence/request/execute/lti_links/delete_spec.rb +39 -0
  28. data/spec/d2l/valence/request/execute/lti_links/list_spec.rb +41 -0
  29. data/spec/d2l/valence/request/execute/lti_links/update_spec.rb +73 -0
  30. data/spec/d2l/valence/request/execute/lti_links/view_spec.rb +41 -0
  31. data/spec/d2l/valence/request/execute/version_spec.rb +41 -0
  32. data/spec/d2l/valence/request/execute/whoami_spec.rb +40 -0
  33. data/spec/d2l/valence/response/code_spec.rb +34 -0
  34. data/spec/d2l/valence/response/server_skew_spec.rb +33 -0
  35. data/spec/d2l/valence/timestamp_error_spec.rb +21 -0
  36. data/spec/d2l/valence/user_context_spec.rb +6 -0
  37. data/spec/fixtures/vcr_cassettes/request/execute/create_lti_link.yml +71 -0
  38. data/spec/fixtures/vcr_cassettes/request/execute/delete_lti_link.yml +48 -0
  39. data/spec/fixtures/vcr_cassettes/request/execute/get_a_lti_link.yml +66 -0
  40. data/spec/fixtures/vcr_cassettes/request/execute/get_lti_links.yml +70 -0
  41. data/spec/fixtures/vcr_cassettes/request/execute/get_version.yml +66 -0
  42. data/spec/fixtures/vcr_cassettes/request/execute/get_whoami.yml +61 -0
  43. data/spec/fixtures/vcr_cassettes/request/execute/invalid_timestamp.yml +112 -0
  44. data/spec/fixtures/vcr_cassettes/request/execute/put_lti_link.yml +139 -0
  45. data/spec/spec_helper.rb +20 -0
  46. data/spec/support/common_context.rb +37 -0
  47. metadata +228 -0
@@ -0,0 +1,15 @@
1
+ require 'uri'
2
+ require 'cgi'
3
+ require 'base64'
4
+ require 'openssl'
5
+ require 'restclient'
6
+
7
+ require 'd2l/valence/version'
8
+ require 'd2l/valence/host'
9
+ require 'd2l/valence/app_context'
10
+ require 'd2l/valence/encrypt'
11
+ require 'd2l/valence/timestamp_error'
12
+ require 'd2l/valence/response'
13
+ require 'd2l/valence/user_context'
14
+ require 'd2l/valence/auth_tokens'
15
+ require 'd2l/valence/request'
@@ -0,0 +1,49 @@
1
+ module D2L
2
+ module Valence
3
+ # == AppContext
4
+ # Class with contextual detail for the application (the ruby client)
5
+ class AppContext
6
+ APP_ID_PARAM = 'x_a'.freeze
7
+ AUTH_KEY_PARAM = 'x_b'.freeze
8
+ CALLBACK_URL_PARAM = 'x_target'.freeze
9
+ AUTH_SERVICE_URI_PATH = '/d2l/auth/api/token'.freeze
10
+
11
+ attr_reader :brightspace_host,
12
+ :app_id,
13
+ :app_key,
14
+ :api_version
15
+
16
+ # @param [Valence::Host] brightspace_host Authenticating D2L Brightspace Instance
17
+ # @param [String] app_id Application ID provided by your D2L admin
18
+ # @param [String] app_key Application Key provided by your D2L admin
19
+ # @param [String] api_version Version of the Valence API is use
20
+ def initialize(brightspace_host:, app_id:, app_key:, api_version: '1.0')
21
+ @brightspace_host = brightspace_host
22
+ @app_id = app_id
23
+ @app_key = app_key
24
+ @api_version = api_version
25
+ end
26
+
27
+ # Generates a URL for authentication
28
+ #
29
+ # @param [URI::Generic] callback_uri URI to redirect to post authentication
30
+ # @return [String] URL for authentication
31
+ def auth_url(callback_uri)
32
+ @brightspace_host.to_uri(
33
+ path: AUTH_SERVICE_URI_PATH,
34
+ query: query_params_using(callback_url: callback_uri.to_s)
35
+ ).to_s
36
+ end
37
+
38
+ private
39
+
40
+ def query_params_using(callback_url:)
41
+ {
42
+ APP_ID_PARAM => @app_id,
43
+ AUTH_KEY_PARAM => Encrypt.generate_from(@app_key, callback_url),
44
+ CALLBACK_URL_PARAM => CGI.escape(callback_url)
45
+ }.map { |p, v| "#{p}=#{v}" }.join('&')
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,60 @@
1
+ module D2L
2
+ module Valence
3
+ # == AuthTokens
4
+ # Class to generate authentication tokens for D2L Valance API calls
5
+ class AuthTokens
6
+ APP_ID_PARAM = 'x_a'.freeze
7
+ USER_ID_PARAM = 'x_b'.freeze
8
+ SIGNATURE_BY_APP_KEY_PARAM = 'x_c'.freeze
9
+ SIGNATURE_BY_USER_KEY_PARAM = 'x_d'.freeze
10
+ TIMESTAMP_PARAM = 'x_t'.freeze
11
+
12
+ # @param [D2L::Valence::Request] request the authenticated request that the auth tokens are for
13
+ def initialize(request:)
14
+ @call = request
15
+ @user_context = @call.user_context
16
+ @app_context = @user_context.app_context
17
+ end
18
+
19
+ # Generates the auth tokens as a Hash for inclusion in the final URI query string
20
+ # @return [Hash] tokens for authenticated call
21
+ def generate
22
+ @tokens = {}
23
+ add_app_tokens
24
+ add_user_tokens
25
+ add_timestamp_token
26
+ @tokens
27
+ end
28
+
29
+ # Generates a timestamp with time skew between server and client taken into consideration
30
+ #
31
+ # @return [Integer] Server skew adjusted timestamp in seconds
32
+ def adjusted_timestamp
33
+ @adjusted_timestamp ||= Time.now.to_f.to_i + @user_context.server_skew
34
+ end
35
+
36
+ private
37
+
38
+ def add_app_tokens
39
+ @tokens[APP_ID_PARAM] = @app_context.app_id
40
+ @tokens[SIGNATURE_BY_APP_KEY_PARAM] = Encrypt.generate_from(@app_context.app_key, signature)
41
+ end
42
+
43
+ def add_user_tokens
44
+ return if @user_context.user_id.nil?
45
+
46
+ @tokens[USER_ID_PARAM] = @user_context.user_id
47
+ @tokens[SIGNATURE_BY_USER_KEY_PARAM] = Encrypt.generate_from(@user_context.user_key, signature)
48
+ end
49
+
50
+ def add_timestamp_token
51
+ @tokens[TIMESTAMP_PARAM] = adjusted_timestamp
52
+ end
53
+
54
+ # Generates a signature requite by the D2L Valence API
55
+ def signature
56
+ @signature ||= "#{@call.http_method}&#{CGI.unescape(@call.path).downcase}&#{adjusted_timestamp}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,34 @@
1
+ module D2L
2
+ module Valence
3
+ # == Encrypt
4
+ # Class encrypt and encode data for transmission in a URL
5
+ class Encrypt
6
+ # Encrypt and encode data with provided key
7
+ #
8
+ # @param [String] key the key to encrypt with
9
+ # @param [String] data data to encrypt and encode
10
+ def self.generate_from(key, data)
11
+ encode(sign(key, data))
12
+ end
13
+
14
+ def self.sign(key, data)
15
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, data)
16
+ end
17
+
18
+ def self.encode(digest)
19
+ Base64.urlsafe_encode64(digest, padding: false).strip
20
+ rescue
21
+ old_encode digest
22
+ end
23
+
24
+ # support for older versions of ruby
25
+ def self.old_encode(digest)
26
+ remove_unwanted_chars Base64.encode64(digest).strip
27
+ end
28
+
29
+ def self.remove_unwanted_chars(string)
30
+ string.delete('=').tr('+', '-').tr('/', '_').strip
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,43 @@
1
+ module D2L
2
+ module Valence
3
+ # == Host
4
+ # Class for the creation of URIs for communication with the D2L Valence API
5
+ class Host
6
+ attr_accessor :scheme, :host, :port
7
+
8
+ # @param [Symbol] scheme URI scheme to be used (either :http or :https)
9
+ # @param [String] host host name for D2L server (e.g. d2l.myinstitution.com)
10
+ # @param [Integer] port specific port for transmission (optional)
11
+ def initialize(scheme:, host:, port: nil)
12
+ self.scheme = scheme
13
+ self.host = host
14
+ self.port = port
15
+ end
16
+
17
+ def host=(value)
18
+ raise 'Host cannot be nil' if value.nil?
19
+ @host = value
20
+ end
21
+
22
+ def scheme=(value)
23
+ return if value.nil?
24
+ value = value.downcase.to_sym if value.is_a? String
25
+ raise "#{value} is an unsupported scheme. Please use either HTTP or HTTPS" unless supported_scheme? value
26
+ @scheme = value
27
+ end
28
+
29
+ def to_uri(path: nil, query: nil)
30
+ {
31
+ http: URI::HTTP.build(host: host, port: port, path: path, query: query),
32
+ https: URI::HTTPS.build(host: host, port: port, path: path, query: query)
33
+ }[scheme]
34
+ end
35
+
36
+ private
37
+
38
+ def supported_scheme?(value)
39
+ [:http, :https].include? value
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,104 @@
1
+ module D2L
2
+ module Valence
3
+ # == Request
4
+ # Class for authenticated calls to the D2L Valence API
5
+ class Request
6
+ attr_reader :user_context,
7
+ :http_method,
8
+ :response
9
+
10
+ #
11
+ # == API routes
12
+ # See D2L::Valence::UserContext.api_call for details on creating routes and route_params
13
+ #
14
+ # @param [D2L::Valance::UserContext] user_context the user context created after authentication
15
+ # @param [String] http_method the HTTP Method for the call (i.e. PUT, GET, POST, DELETE)
16
+ # @param [String] route the API method route (e.g. /d2l/api/lp/:version/users/whoami)
17
+ # @param [Hash] route_params the parameters for the API method route (option)
18
+ # @param [Hash] query_params the query parameters for the method call
19
+ def initialize(user_context:, http_method:, route:, route_params: {}, query_params: {})
20
+ @user_context = user_context
21
+ @app_context = user_context.app_context
22
+ @http_method = http_method.upcase
23
+ @route = route
24
+ @route_params = route_params
25
+ @query_params = query_params
26
+
27
+ raise "HTTP Method #{@http_method} is unsupported" unless %w(GET PUT POST DELETE).include? @http_method
28
+ end
29
+
30
+ # Generates an authenticated URI for a the Valence API method
31
+ #
32
+ # @return [URI::Generic] URI for the authenticated method call
33
+ def authenticated_uri
34
+ @app_context.brightspace_host.to_uri(
35
+ path: path,
36
+ query: query
37
+ )
38
+ end
39
+
40
+ # Sends the authenticated call on the Valence API
41
+ #
42
+ # @return [D2L::Valence::Response] URI for the authenticated methof call
43
+ def execute
44
+ raise "HTTP Method #{@http_method} is not implemented" if params.nil?
45
+
46
+ @response = execute_call
47
+ @user_context.server_skew = @response.server_skew
48
+ @response
49
+ end
50
+
51
+ # Generates the final path for the authenticated call
52
+ #
53
+ # @return [String] path for the authenticated call
54
+ def path
55
+ return @path unless @path.nil?
56
+
57
+ substitute_keys_with(@route_params)
58
+ substitute_keys_with(known_params)
59
+ @path = @route
60
+ end
61
+
62
+ private
63
+
64
+ def execute_call
65
+ Response.new RestClient.send(@http_method.downcase, *params)
66
+ rescue RestClient::Exception => e
67
+ Response.new e.response
68
+ end
69
+
70
+ def params
71
+ {
72
+ 'GET' => [authenticated_uri.to_s],
73
+ 'POST' => [authenticated_uri.to_s, @query_params.to_json, content_type: :json],
74
+ 'PUT' => [authenticated_uri.to_s, @query_params.to_json, content_type: :json],
75
+ 'DELETE' => [authenticated_uri.to_s, content_type: :json]
76
+ }[@http_method]
77
+ end
78
+
79
+ def substitute_keys_with(params)
80
+ params.each { |param, value| @route.gsub!(":#{param}", value.to_s) }
81
+ end
82
+
83
+ def known_params
84
+ {
85
+ version: @user_context.app_context.api_version
86
+ }
87
+ end
88
+
89
+ def query
90
+ return to_query_params(authenticated_tokens) unless @http_method == 'GET'
91
+
92
+ to_query_params @query_params.merge(authenticated_tokens)
93
+ end
94
+
95
+ def to_query_params(hash)
96
+ hash.map { |k, v| "#{k}=#{v}" }.join('&')
97
+ end
98
+
99
+ def authenticated_tokens
100
+ D2L::Valence::AuthTokens.new(request: self).generate
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,66 @@
1
+ module D2L
2
+ module Valence
3
+ # == Response
4
+ # Class for interpreting the response from the D2L Valence API
5
+ class Response
6
+ attr_reader :http_response
7
+
8
+ # @param [RestClient::Response] http_response response from a request against the Valance API
9
+ def initialize(http_response)
10
+ @http_response = http_response
11
+ @server_skew = 0
12
+ end
13
+
14
+ # @return [String] Plain text response from the D2L server
15
+ def body
16
+ @http_response.body
17
+ end
18
+
19
+ # Generates a hash based on a valid JSON response from the D2L server. If the provided response is not in a
20
+ # value JSON format then an empty hash is returned
21
+ #
22
+ # @return [Hash] hash based on JSON body
23
+ def to_hash
24
+ @to_hash ||= JSON.parse(body)
25
+ rescue
26
+ @to_hash = {}
27
+ end
28
+
29
+ # @return [Symbol] the interpreted code for the Valance API response
30
+ def code
31
+ interpret_forbidden || http_code
32
+ end
33
+
34
+ # @return [Integer] the difference in D2L Valance API Server time and local time
35
+ def server_skew
36
+ return 0 unless timestamp_error.timestamp_out_of_range?
37
+
38
+ @server_skew = timestamp_error.server_skew
39
+ end
40
+
41
+ private
42
+
43
+ def http_code
44
+ "HTTP_#{@http_response.code}".to_sym
45
+ end
46
+
47
+ def interpret_forbidden
48
+ return unless @http_response.code == 403
49
+
50
+ invalid_timestamp || invalid_token
51
+ end
52
+
53
+ def invalid_timestamp
54
+ :INVALID_TIMESTAMP if timestamp_error.timestamp_out_of_range?
55
+ end
56
+
57
+ def timestamp_error
58
+ @timestamp_error ||= TimestampError.new(@http_response.body)
59
+ end
60
+
61
+ def invalid_token
62
+ :INVALID_TOKEN if @http_response.body.include? 'invalid token'
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,42 @@
1
+ module D2L
2
+ module Valence
3
+ # == TimestampError
4
+ # This class is aimed at parsing and providing diagnostics for time based issues
5
+ # between the D2L Brightspace Server and Ruby Client
6
+ class TimestampError
7
+ # @param [String] D2L Brightspace Server Error Message
8
+ def initialize(error_message)
9
+ @error_message = error_message
10
+ end
11
+
12
+ # @return [Integer] difference in D2L Server timestamp in seconds
13
+ def server_skew
14
+ return 0 if server_time_in_seconds.nil?
15
+
16
+ @server_skew ||= server_time_in_seconds - now_in_seconds
17
+ end
18
+
19
+ # @return [Integer] true if our timestamp is out of range with the D2L Server
20
+ def timestamp_out_of_range?
21
+ server_time_in_seconds != nil
22
+ end
23
+
24
+ private
25
+
26
+ # @return [Integer] D2L Server timestamp
27
+ def server_time_in_seconds
28
+ @server_timestamp ||= parse_timestamp
29
+ end
30
+
31
+ # @return [Integer] local timestamp now in seconds
32
+ def now_in_seconds
33
+ Time.now.to_f.to_i
34
+ end
35
+
36
+ def parse_timestamp
37
+ match = Regexp.new(/Timestamp out of range\s*(\d+)/).match(@error_message)
38
+ match[1].to_i if !match.nil? && match.length >= 2
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,58 @@
1
+ module D2L
2
+ module Valence
3
+ # == UserContext
4
+ # Instances of this class are used to make D2L Valance API calls with the current user credentials
5
+ class UserContext
6
+ attr_reader :app_context,
7
+ :user_id,
8
+ :user_key
9
+
10
+ attr_accessor :server_skew # in seconds
11
+
12
+ # @param [D2L::Valence::AppContext] app_context
13
+ # @param [String] user_id User ID returned from the D2L Server in authentication process
14
+ # @param [String] user_key User Key returned the D2L Server in authentication process
15
+ def initialize(app_context:, user_id:, user_key:)
16
+ @app_context = app_context
17
+ @user_id = user_id
18
+ @user_key = user_key
19
+ @server_skew = 0
20
+ end
21
+
22
+ # Calls a API method on the Valance API
23
+ #
24
+ # == API routes
25
+ # When providing the route you can also provide the variables for the parameters in the route. For example, the
26
+ # following route will require `{org_unit_id: 1, group_category_id: 23}` for `route_params`:
27
+ #
28
+ # /d2l/api/lp/:version/:org_unit_id/groupcategories/:group_category_id
29
+ #
30
+ # The `to_uri` method will place the parameters in the route to make the final path. For example:
31
+ #
32
+ # /d2l/api/lp/1.0/1/groupcategories/23
33
+ #
34
+ # There are known parameters such as `:version` which is provided when you create your initial AppContext
35
+ # instance. This will mean that some routes will require no parameters for example:
36
+ #
37
+ # /d2l/api/lp/:version/users/whoami
38
+ #
39
+ # which becomes
40
+ # /d2l/api/lp/1.0/users/whoami
41
+ #
42
+ # @param [String] http_method the HTTP Method for the call (i.e. PUT, GET, POST, DELETE)
43
+ # @param [String] route the API method route (e.g. /d2l/api/lp/:version/users/whoami)
44
+ # @param [Hash] route_params the parameters for the API method route (option)
45
+ # @param [Hash] query_params the query parameters for the method call
46
+ # @return [D2L::Valence::RequestResult] returns a request
47
+ def api_call(http_method:, route:, route_params:, query_params:)
48
+ D2L::Valence::Request.new(
49
+ user_context: self,
50
+ http_method: http_method,
51
+ route: route,
52
+ route_params: route_params,
53
+ query_params: query_params
54
+ ).execute
55
+ end
56
+ end
57
+ end
58
+ end