alula-ruby 1.7.0 → 1.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ba30ce4a88b334fd10633b735f401d59b041cfdf2298a1298b5136e283d4b1d
4
- data.tar.gz: '09475f21199c2182b86e3a775b88ed1df2f6617566bb2e980cd0d109d6536630'
3
+ metadata.gz: a508fa6a1f2b9e59136e9e188458c4ed4e21cb82242dddf5f05523876a391c8d
4
+ data.tar.gz: d71c8ac244ce5c84d3078aa456f70358a1b8824afd27794b6347b46ff43086e1
5
5
  SHA512:
6
- metadata.gz: 7266449db3159f70c5a4d77ae25ae59355b7d4dc5e34d8cae838b10292b51c592da29ef192bd363b230f8a65d34195b30d1bfe617db3c25e6595a36f0d133e5b
7
- data.tar.gz: eca19d6a1412ba2b51c4e4ba6514b47fd05fff1445b8fd3f6a9868eca43d0148d3c5498fd076eeb8230adbd4cbe5a31922eff061466c068f8eab5602e4245b6b
6
+ metadata.gz: d281008db754b5774489cb19e00a8a076403ee7319a15d0a4e16991416c570b115fd0ef1b37aacc6d5b16fc65c8f7636670884245385150411d6b1551574eb58
7
+ data.tar.gz: e04bf33ee07bdc96869782224b336152caeef7ad5169823ef947e6d5d27bec5482698f3249a13cf4038972b8c9d312a3df4c9f249ebd3f8e442909ea60980c75
data/VERSION.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  | Version | Date | Description |
4
4
  | ------- | --------- | --------------------------------------------------------------------------- |
5
+ | v1.8.1 | 2024-06-25 | Update client to accept impersonator authorization to change admin user langauge preference from dealer app |
6
+ | v1.8.0 | 2024-06-19 | Use Video API endpoints instead of core API passthrough |
5
7
  | v1.7.0 | 2024-06-13 | Add DELETE, PATCH/POST, unregister capabilities for cameras |
6
8
  | v1.6.0 | 2024-01-26 | Add user language attribute |
7
9
  | v1.5.0 | 2024-01-09 | Add more known errors that use a different format |
@@ -13,6 +13,7 @@ module Alula
13
13
  attributes: as_patchable_json
14
14
  }
15
15
  }
16
+ payload[:data].delete(:id) if video_request?(resource_url)
16
17
 
17
18
  response = Alula::Client.request(:patch, resource_url, payload, {})
18
19
 
@@ -79,6 +80,10 @@ module Alula
79
80
  raise Alula::UnknownError, "Unknown HTTP response code, aborting. Code: #{response.http_status}"
80
81
  end
81
82
  end
83
+
84
+ def video_request?(resource_path)
85
+ resource_path.match(%r{^/video})
86
+ end
82
87
  end
83
88
  end
84
89
  end
data/lib/alula/client.rb CHANGED
@@ -7,7 +7,7 @@ module Alula
7
7
  class << self
8
8
  DEFAULT_CUSTOM_OPTIONS = {
9
9
  omitRelationships: true
10
- }
10
+ }.freeze
11
11
 
12
12
  def config
13
13
  @_config ||= Alula::ClientConfiguration.new
@@ -18,13 +18,13 @@ module Alula
18
18
  end
19
19
 
20
20
  def request(http_method, resource_path, filters = {}, opts = {})
21
- unless resource_path.match(%r{^/public/v1})
22
- validate_request!(http_method, resource_path)
23
- end
24
-
21
+ validate_request!(http_method, resource_path) unless resource_path.match(%r{^/public/v1})
25
22
  request_opts = build_options(http_method, resource_path, filters, opts)
23
+ api_url = video_request?(resource_path) ? config.video_api_url : config.api_url
24
+ request_resource_path = video_request?(resource_path) ? resource_path.gsub(%r{^/video}, '') : resource_path
25
+
26
26
  # TODO: Handle network failures
27
- response = make_request(http_method, config.api_url + resource_path, request_opts)
27
+ response = make_request(http_method, api_url + request_resource_path, request_opts)
28
28
 
29
29
  begin
30
30
  resp = AlulaResponse.from_httparty_response(response)
@@ -41,7 +41,11 @@ module Alula
41
41
  def validate_request!(http_method, resource_path)
42
42
  config.ensure_api_key_set
43
43
  config.ensure_api_url_set
44
- config.ensure_customer_id_set if resource_path.match(%r{^/video})
44
+ if video_request?(resource_path)
45
+ config.ensure_video_api_url_set
46
+ config.ensure_video_api_key_set
47
+ config.ensure_customer_id_set
48
+ end
45
49
  ensure_method_allowable(http_method)
46
50
  config.ensure_role_set if %i[patch post put].include? http_method
47
51
  end
@@ -51,18 +55,24 @@ module Alula
51
55
  end
52
56
 
53
57
  def ensure_method_allowable(http_method)
54
- unless %i[get post put patch delete].include?(http_method)
55
- raise "Unable to send a request with #{http_method} http method"
56
- end
58
+ return if %i[get post put patch delete].include?(http_method)
59
+
60
+ raise "Unable to send a request with #{http_method} http method"
61
+ end
62
+
63
+ def video_request?(resource_path)
64
+ resource_path.match(%r{^/video})
57
65
  end
58
66
 
59
67
  def build_options(http_method, resource_path, filters = {}, opts = {})
68
+ api_key = Alula::Client.config.impersonator_api_key || Alula::Client.config.api_key
60
69
  request_opts = {
61
70
  headers: {
62
- 'Authorization': "Bearer #{Alula::Client.config.api_key}",
71
+ 'Authorization': "Bearer #{api_key}",
63
72
  'User-Agent': "#{Alula::Client.config.user_agent || 'No Agent Set'}/alula-ruby v#{Alula::VERSION}"
64
73
  }
65
74
  }.merge(opts)
75
+ handle_video_request(request_opts) if video_request?(resource_path) # Add token for all video requests
66
76
 
67
77
  request_opts[:headers]['Content-Type'] = 'application/json' unless opts[:multipart]
68
78
  case http_method
@@ -70,11 +80,7 @@ module Alula
70
80
  :post
71
81
  request_opts[:body] = opts[:multipart] ? filters : JSON.generate(filters)
72
82
  when :get
73
- if resource_path.match(%r{^/(rest|rpc)})
74
- request_opts = request_opts.merge build_rest_options(filters)
75
- elsif resource_path.match(%r{^/video})
76
- add_customer_id(request_opts)
77
- end
83
+ request_opts = request_opts.merge build_rest_options(filters) if resource_path.match(%r{^/(rest|rpc)})
78
84
  when :delete
79
85
  # Nothing special
80
86
  end
@@ -111,8 +117,17 @@ module Alula
111
117
  filters
112
118
  end
113
119
 
114
- def add_customer_id(request_opts)
115
- request_opts[:query] = { 'customerId' => config.customer_id }
120
+ def handle_video_request(request_opts)
121
+ if Alula::Client.config.internal
122
+ set_token(request_opts, { internal: true })
123
+ else
124
+ set_token(request_opts, { customerId: Alula::Client.config.customer_id })
125
+ end
126
+ end
127
+
128
+ def set_token(request_opts, payload)
129
+ jwt = TokenExchange.fetch_video_token(payload)
130
+ request_opts[:headers]['token'] = jwt
116
131
  end
117
132
  end
118
133
  end
@@ -1,7 +1,8 @@
1
1
  module Alula
2
2
  class ClientConfiguration
3
- attr_accessor :api_url, :debug, :user_agent
4
- REQUEST_ATTRIBUTES = %i[api_key customer_id]
3
+ attr_accessor :api_url, :video_api_url, :debug, :user_agent
4
+
5
+ REQUEST_ATTRIBUTES = %i[api_key video_api_key customer_id internal impersonator_api_key]
5
6
 
6
7
  # Using RequestStore so we're thread safe even with our non-thread-safe architecture :(
7
8
  REQUEST_ATTRIBUTES.each do |prop|
@@ -42,11 +43,25 @@ module Alula
42
43
  end
43
44
  end
44
45
 
46
+ def ensure_video_api_url_set
47
+ return unless video_api_url.nil?
48
+
49
+ raise Alula::NotConfiguredError,
50
+ 'did you forget to set the Alula::Client.config.video_api_url config option?'
51
+ end
52
+
53
+ def ensure_video_api_key_set
54
+ return if video_api_key
55
+
56
+ raise Alula::NotConfiguredError,
57
+ 'Set your video API access token before making requests to the video API'
58
+ end
59
+
45
60
  def ensure_customer_id_set
46
- unless customer_id
47
- raise Alula::NotConfiguredError,
48
- 'Set your customer_id before making requests to the video API'
49
- end
61
+ return if internal || customer_id
62
+
63
+ raise Alula::NotConfiguredError,
64
+ 'Set your customer_id before making requests to the video API'
50
65
  end
51
66
 
52
67
  def ensure_role_set
data/lib/alula/errors.rb CHANGED
@@ -87,6 +87,8 @@ module Alula
87
87
  NotFoundError.new(message)
88
88
  when 'Device Not Found'
89
89
  NotFoundError.new(message)
90
+ when 'Unable to unregister device'
91
+ NotFoundError.new(message)
90
92
  else
91
93
  Alula.logger.error response
92
94
  raise NotImplementedError, "Unable to derive error for #{response.data['error'].to_json}"
@@ -128,6 +130,8 @@ module Alula
128
130
  NotFoundError.new(message)
129
131
  when 'Device Not Found'
130
132
  NotFoundError.new(message)
133
+ when 'Unable to unregister device'
134
+ NotFoundError.new(message)
131
135
  else
132
136
  Alula.logger.error response
133
137
  raise NotImplementedError, "Unable to derive error for #{response.data['errors'].to_json}"
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+ require 'base64'
3
+ require 'openssl'
4
+
5
+ module Alula
6
+ # Helper class to build JWT tokens
7
+ # Used for internal video API calls or video API calls with a customer ID
8
+ module JwtHelper
9
+ ONE_HOUR = 60 * 60
10
+ class << self
11
+ def build_jwt_token(jwt_payload)
12
+ jwt_secret = Alula::Client.config.video_api_key
13
+
14
+ header = build_jwt_header
15
+
16
+ token_data = add_timestamp_to_payload(jwt_payload)
17
+
18
+ encoded_header = convert_to_base64(JSON.generate(header))
19
+ encoded_data = convert_to_base64(JSON.generate(token_data))
20
+
21
+ token = "#{encoded_header}.#{encoded_data}"
22
+ signature = sign_token(token, jwt_secret)
23
+
24
+ "#{token}.#{signature}"
25
+ end
26
+
27
+ def build_jwt_header
28
+ {
29
+ 'typ' => 'JWT',
30
+ 'alg' => 'HS256'
31
+ }
32
+ end
33
+
34
+ def add_timestamp_to_payload(payload)
35
+ current_timestamp = Time.now.to_i
36
+ payload.merge(
37
+ {
38
+ 'iat' => current_timestamp,
39
+ 'exp' => current_timestamp + ONE_HOUR # expiry time is 60 minutes from time of creation
40
+ }
41
+ )
42
+ end
43
+
44
+ def sign_token(token, secret)
45
+ signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), secret, token)
46
+ convert_to_base64(signature)
47
+ end
48
+
49
+ def convert_to_base64(source)
50
+ # Encode in classical base64,
51
+ encoded_source = Base64.strict_encode64(source)
52
+
53
+ # Remove padding equal characters,
54
+ encoded_source = encoded_source.gsub('=', '')
55
+
56
+ # Replace characters according to base64url specifications,
57
+ encoded_source.tr('+/', '-_')
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,17 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/jwt_helper'
4
+
1
5
  module Alula
6
+ # Helper class to generate OAuth access tokens (core API) and JWT tokens (VSP API)
2
7
  class TokenExchange
3
- def self.token_for_user(user_id)
4
- url = '/rest/v1/oauth/accesstokens'
5
- payload = { data: { attributes: { userId: user_id } } }
6
- opts = {}
7
-
8
- response = Alula::Client.request(:post, url, payload, opts)
9
-
10
- if response.ok?
11
- ImpersonatedToken.new(response.data['data']['attributes'])
12
- else
13
- error_class = AlulaError.for_response(response)
14
- raise error_class
8
+ class << self
9
+ def token_for_user(user_id)
10
+ url = '/rest/v1/oauth/accesstokens'
11
+ payload = { data: { attributes: { userId: user_id } } }
12
+ opts = {}
13
+
14
+ response = Alula::Client.request(:post, url, payload, opts)
15
+
16
+ if response.ok?
17
+ ImpersonatedToken.new(response.data['data']['attributes'])
18
+ else
19
+ error_class = AlulaError.for_response(response)
20
+ raise error_class
21
+ end
22
+ end
23
+
24
+ def fetch_video_token(payload)
25
+ cache_key = generate_cache_key(payload)
26
+ cached_token, expiry = retrieve_cached_token(cache_key)
27
+
28
+ if cached_token && Time.now.to_i < expiry
29
+ jwt_token = cached_token
30
+ else
31
+ jwt_token = build_jwt_token(payload)
32
+ expiry = Time.now.to_i + JwtHelper::ONE_HOUR # 1 hour expiry
33
+ store_token_in_cache(cache_key, jwt_token, expiry)
34
+ end
35
+
36
+ jwt_token
37
+ end
38
+
39
+ private
40
+
41
+ def build_jwt_token(payload)
42
+ JwtHelper.build_jwt_token(payload)
43
+ end
44
+
45
+ def store_token_in_cache(cache_key, jwt_token, expiry)
46
+ @token_cache ||= {}
47
+ @token_cache[cache_key] = [jwt_token, expiry]
48
+ end
49
+
50
+ def retrieve_cached_token(cache_key)
51
+ @token_cache ||= {}
52
+ @token_cache[cache_key]
53
+ end
54
+
55
+ def generate_cache_key(payload)
56
+ if payload[:internal]
57
+ 'jwt_token_internal'
58
+ elsif payload[:customerId]
59
+ "jwt_token_customer_#{payload[:customerId]}"
60
+ else
61
+ raise ArgumentError, 'Invalid payload'
62
+ end
15
63
  end
16
64
  end
17
65
 
@@ -33,7 +33,7 @@ module Alula
33
33
  api_name :video
34
34
 
35
35
  # Instance method
36
- def resource_url(id = nil)
36
+ def resource_url(id = self.id)
37
37
  "/#{self.class.api_name}/v1/#{self.class.resource_name}/#{id}"
38
38
  end
39
39
 
data/lib/alula/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Alula
4
- VERSION = '1.7.0'
4
+ VERSION = '1.8.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: alula-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Titus Johnson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-06-17 00:00:00.000000000 Z
11
+ date: 2024-06-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httparty
@@ -210,6 +210,7 @@ files:
210
210
  - lib/alula/errors.rb
211
211
  - lib/alula/filter_builder.rb
212
212
  - lib/alula/helpers/device_attribute_translations.rb
213
+ - lib/alula/helpers/jwt_helper.rb
213
214
  - lib/alula/list_object.rb
214
215
  - lib/alula/meta.rb
215
216
  - lib/alula/monkey_patches.rb