sp-job 0.2.3 → 0.3.22

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/VERSION +1 -1
  3. data/bin/configure +40 -0
  4. data/lib/sp-job.rb +21 -2
  5. data/lib/sp/job/back_burner.rb +350 -68
  6. data/lib/sp/job/broker.rb +18 -16
  7. data/lib/sp/job/broker_http_client.rb +80 -20
  8. data/lib/sp/job/broker_oauth2_client.rb +12 -4
  9. data/lib/sp/job/common.rb +876 -62
  10. data/lib/sp/job/configure/configure.rb +640 -0
  11. data/lib/sp/job/curl_http_client.rb +100 -0
  12. data/lib/sp/job/easy_http_client.rb +94 -0
  13. data/lib/sp/job/http_client.rb +51 -0
  14. data/lib/sp/job/job_db_adapter.rb +38 -36
  15. data/lib/sp/job/jsonapi_error.rb +31 -74
  16. data/lib/sp/job/jwt.rb +55 -5
  17. data/lib/sp/job/mail_queue.rb +9 -2
  18. data/lib/sp/job/manticore_http_client.rb +94 -0
  19. data/lib/sp/job/pg_connection.rb +90 -10
  20. data/lib/sp/job/query_params.rb +45 -0
  21. data/lib/sp/job/rfc822.rb +13 -0
  22. data/lib/sp/job/session.rb +239 -0
  23. data/lib/sp/job/unique_file.rb +37 -1
  24. data/lib/sp/job/uploaded_image_converter.rb +27 -19
  25. data/lib/sp/job/worker.rb +51 -1
  26. data/lib/sp/job/worker_thread.rb +22 -7
  27. data/lib/sp/jsonapi.rb +24 -0
  28. data/lib/sp/jsonapi/adapters/base.rb +177 -0
  29. data/lib/sp/jsonapi/adapters/db.rb +26 -0
  30. data/lib/sp/jsonapi/adapters/raw_db.rb +96 -0
  31. data/lib/sp/jsonapi/exceptions.rb +54 -0
  32. data/lib/sp/jsonapi/model/base.rb +31 -0
  33. data/lib/sp/jsonapi/model/concerns/attributes.rb +91 -0
  34. data/lib/sp/jsonapi/model/concerns/model.rb +39 -0
  35. data/lib/sp/jsonapi/model/concerns/persistence.rb +212 -0
  36. data/lib/sp/jsonapi/model/concerns/serialization.rb +57 -0
  37. data/lib/sp/jsonapi/parameters.rb +54 -0
  38. data/lib/sp/jsonapi/service.rb +96 -0
  39. data/lib/tasks/configure.rake +2 -496
  40. data/sp-job.gemspec +3 -2
  41. metadata +24 -2
data/lib/sp/job/jwt.rb CHANGED
@@ -25,11 +25,61 @@ module SP
25
25
  module Job
26
26
  class JWTHelper
27
27
 
28
- # encode & sign jwt
29
- def self.encode(key:, payload:)
30
- rsa_private = OpenSSL::PKey::RSA.new( File.read( key ) )
31
- return JWT.encode payload, rsa_private, 'RS256', { :typ => "JWT" }
32
- end #self.encodeJWT
28
+ # encode & sign jwt
29
+ def self.encode(key:, payload:)
30
+ rsa_private = OpenSSL::PKey::RSA.new( File.read( key ) )
31
+ return JWT.encode payload, rsa_private, 'RS256', { :typ => "JWT" }
32
+ end #self.encodeJWT
33
+
34
+ # key: Path of the private key to be used on encoding
35
+ # jwt_validity: Must be set in hours
36
+ # tube: Name of the tube
37
+ # ttr: Job max execution time in seconds
38
+ # payload: Data to be used on the job
39
+ def self.jobify(key:, jwt_validity: 24, tube:, ttr: 8600, payload:)
40
+ # UTC timestamp
41
+ now = Time.now.getutc.to_i
42
+ # Expire
43
+ exp_offset = jwt_validity * 60 * 60
44
+ exp = now + exp_offset
45
+ # Issued At
46
+ iat = now
47
+ # Not before
48
+ nbf = now
49
+
50
+ job_payload = { tube: tube }
51
+ job_payload.merge!(payload)
52
+
53
+ self.encode(key: key, payload: {
54
+ action: 'job',
55
+ exp: exp, # Data de expiração
56
+ iat: iat, # Issued at
57
+ nbf: nbf, # Not before
58
+ job: {
59
+ tube: tube,
60
+ ttr: ttr,
61
+ payload: job_payload
62
+ }
63
+ })
64
+ end
65
+
66
+ # Submit a jwt for a job
67
+ def self.submit (url:, jwt:)
68
+ response = HttpClient.get_klass.post(
69
+ url: url,
70
+ headers: {
71
+ 'Content-Type' => 'application/text'
72
+ },
73
+ body: jwt,
74
+ expect: {
75
+ code: 200,
76
+ content: {
77
+ type: 'application/json'
78
+ }
79
+ }
80
+ )
81
+ response
82
+ end
33
83
 
34
84
  end # end class 'JWT'
35
85
  end # module Job
@@ -44,11 +44,18 @@ module SP
44
44
  body: job[:body],
45
45
  template: job[:template],
46
46
  to: job[:to],
47
+ cc: job[:cc],
47
48
  reply_to: job[:reply_to],
48
49
  subject: job[:subject],
49
- attachments: job[:attachments]
50
+ attachments: job[:attachments],
51
+ session: {
52
+ user_id: job[:user_id],
53
+ entity_id: job[:entity_id],
54
+ role_mask: job[:role_mask],
55
+ module_mask: job[:module_mask]
56
+ }
50
57
  )
51
- logger.info "mailto: #{job[:to]} - #{job[:subject]}"
58
+ logger.info "mail - to: #{job[:to]} cc: #{job[:cc]} subject: #{job[:subject]}"
52
59
  end
53
60
 
54
61
  def self.on_failure (e, job)
@@ -0,0 +1,94 @@
1
+ #
2
+ # Copyright (c) 2011-2016 Cloudware S.A. All rights reserved.
3
+ #
4
+ # This file is part of sp-job.
5
+ #
6
+ # sp-job is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Affero General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # sp-job is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Affero General Public License
17
+ # along with sp-job. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ # encoding: utf-8
20
+ #
21
+
22
+ #
23
+ # A helper class to do HTTP request without session management.
24
+ #
25
+
26
+ require 'manticore'
27
+ require_relative 'easy_http_client'
28
+
29
+ module SP
30
+ module Job
31
+ class ManticoreHTTPClient < EasyHttpClient
32
+ def self.post(url:, headers:, body:, expect:, conn_options: {})
33
+ conn_options[:connection_timeout] ||= 10
34
+ conn_options[:request_timeout] ||= 60
35
+ client = ::Manticore::Client.new(socket_timeout: conn_options[:connection_timeout], request_timeout: conn_options[:request_timeout])
36
+ nr = self.normalize_response(response: client.post(url, body: body, headers: headers))
37
+
38
+ # compare status code
39
+ if nr[:code] != expect[:code]
40
+ if 401 == nr[:code]
41
+ raise ::SP::Job::JSONAPI::Error.new(status: nr[:code], code: 'A01', detail: nil)
42
+ else
43
+ raise ::SP::Job::JSONAPI::Error.new(status: nr[:code], code: 'B01', detail: nil)
44
+ end
45
+ end
46
+
47
+ # compare content-type
48
+ if nr[:content][:type] != expect[:content][:type]
49
+ raise ::SP::Job::JSONAPI::Error.new(status: 500, code: 'I01', detail: "Unexpected 'Content-Type': #{nr[:content][:type]}, expected #{expect[:content][:type]}!")
50
+ end
51
+
52
+ # done
53
+ nr
54
+ end
55
+
56
+ def self.get(url:)
57
+ client = ::Manticore::Client.new
58
+ self.normalize_response(response: client.get(url))
59
+ end
60
+
61
+ def self.delete(url:, headers:)
62
+ client = ::Manticore::Client.new
63
+ self.normalize_response(response: client.delete(url, headers: headers))
64
+ end
65
+
66
+ private
67
+
68
+ def self.normalize_response(response:)
69
+ o = {
70
+ code: response.code,
71
+ body: response.body,
72
+ description: http_reason(code: response.code),
73
+ content: {
74
+ type: nil,
75
+ length: 0
76
+ }
77
+ }
78
+
79
+ response.headers.each do |key, value|
80
+ case key
81
+ when 'content-type'
82
+ o[:content][:type] = value
83
+ when 'content-length'
84
+ o[:content][:length] = value
85
+ end
86
+ end
87
+
88
+ o
89
+ end
90
+
91
+
92
+ end
93
+ end
94
+ end
@@ -31,6 +31,7 @@ module SP
31
31
  # Public Attributes
32
32
  #
33
33
  attr_accessor :connection
34
+ attr_reader :config
34
35
 
35
36
  #
36
37
  # Prepare database connection configuration.
@@ -58,6 +59,7 @@ module SP
58
59
  @treshold = new_min.to_i
59
60
  end
60
61
  end
62
+ @transaction_open = false
61
63
  end
62
64
 
63
65
  #
@@ -87,7 +89,7 @@ module SP
87
89
  # @param args all the args for the query
88
90
  # @return query result.
89
91
  #
90
- def exec (query, *args)
92
+ def execp (query, *args)
91
93
  @mutex.synchronize {
92
94
  if nil == @connection
93
95
  _connect()
@@ -95,7 +97,24 @@ module SP
95
97
  _check_life_span()
96
98
  unless @id_cache.has_key? query
97
99
  id = "p#{Digest::MD5.hexdigest(query)}"
98
- @connection.prepare(id, query)
100
+ begin
101
+ @connection.prepare(id, query)
102
+ rescue PG::DuplicatePstatement => ds
103
+ tmp_debug_str = ""
104
+ @id_cache.each do | k, v |
105
+ if v == id || k == query
106
+ tmp_debug_str += "#{v}: #{k}\n"
107
+ break
108
+ end
109
+ end
110
+ if 0 == tmp_debug_str.length
111
+ tmp_debug_str = "~~~\nAll Entries:\n" + @id_cache.to_s
112
+ else
113
+ tmp_debug_str = "~~~\nCached Entry:\n#{tmp_debug_str}"
114
+ end
115
+ tmp_debug_str += "~~~\nNew Entry: #{id}:#{query}\n"
116
+ raise "#{ds.message}\n#{tmp_debug_str}"
117
+ end
99
118
  @id_cache[query] = id
100
119
  else
101
120
  id = @id_cache[query]
@@ -105,14 +124,21 @@ module SP
105
124
  end
106
125
 
107
126
  #
108
- # Execute a query,
127
+ # Execute a normal SQL statement.
109
128
  #
110
- # @param query
129
+ # @param query the SQL query with data binding
130
+ # @param args all the args for the query
131
+ # @return query result.
111
132
  #
112
- def query (query:)
133
+ def exec (query, *args)
113
134
  @mutex.synchronize {
114
- unless query.nil?
115
- _check_life_span()
135
+ if nil == @connection
136
+ _connect()
137
+ end
138
+ _check_life_span()
139
+ if args.length > 0
140
+ @connection.exec(sprintf(query, *args))
141
+ else
116
142
  @connection.exec(query)
117
143
  end
118
144
  }
@@ -134,20 +160,71 @@ module SP
134
160
  @config[:conn_str]
135
161
  end
136
162
 
163
+ #
164
+ # Call this to open a transaction
165
+ #
166
+ def begin
167
+ @mutex.synchronize {
168
+ if nil == @connection
169
+ _connect()
170
+ end
171
+ _check_life_span()
172
+ r = @connection.exec("BEGIN;")
173
+ if PG::PGRES_COMMAND_OK != r.result_status
174
+ raise "Unable to BEGIN a new transaction!"
175
+ end
176
+ @transaction_open = true
177
+ }
178
+ end
179
+
180
+ #
181
+ # Call this to commit the currently open transaction
182
+ #
183
+ def commit
184
+ @mutex.synchronize {
185
+ if nil != @connection && true == @transaction_open
186
+ r = @connection.exec("COMMIT;")
187
+ if PG::PGRES_COMMAND_OK != r.result_status
188
+ raise "Unable to COMMIT a transaction!"
189
+ end
190
+ @transaction_open = false
191
+ end
192
+ }
193
+ end
194
+
195
+ #
196
+ # Call this to open a transaction
197
+ #
198
+ def rollback
199
+ @mutex.synchronize {
200
+ if nil != @connection && true == @transaction_open
201
+ r = @connection.exec("ROLLBACK;")
202
+ if PG::PGRES_COMMAND_OK != r.result_status
203
+ raise "Unable to ROLLBACK a transaction!"
204
+ end
205
+ @transaction_open = false
206
+ end
207
+ }
208
+
209
+ end
210
+
137
211
  private
138
212
 
139
- def _connect ()
213
+ def _connect ()
140
214
  _disconnect()
141
215
  @connection = PG.connect(@config[:conn_str])
142
216
  end
143
217
 
144
218
  def _disconnect ()
219
+ @transaction_open = false
145
220
  if @connection.nil?
146
221
  return
147
222
  end
148
223
 
149
- @connection.exec("DEALLOCATE ALL")
150
- @id_cache = {}
224
+ if @id_cache.size
225
+ @connection.exec("DEALLOCATE ALL")
226
+ @id_cache = {}
227
+ end
151
228
 
152
229
  @connection.close
153
230
  @connection = nil
@@ -158,6 +235,9 @@ module SP
158
235
  # Check connection life span
159
236
  #
160
237
  def _check_life_span ()
238
+ if true == @transaction_open
239
+ return
240
+ end
161
241
  return unless @treshold > 0
162
242
  @counter += 1
163
243
  if @counter > @treshold
@@ -0,0 +1,45 @@
1
+ #
2
+ # Copyright (c) 2011-2016 Cloudware S.A. All rights reserved.
3
+ #
4
+ # This file is part of sp-job.
5
+ #
6
+ # sp-job is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Affero General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # sp-job is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Affero General Public License
17
+ # along with sp-job. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ # encoding: utf-8
20
+ #
21
+
22
+ require 'cgi'
23
+
24
+ module SP
25
+ module Job
26
+ module QueryParams
27
+
28
+ def self.encode(value, key = nil)
29
+ case value
30
+ when Hash then value.map { |k,v| encode(v, append_key(key,k)) }.join('&')
31
+ when Array then value.map { |v| encode(v, "#{key}[]") }.join('&')
32
+ when nil then ''
33
+ else
34
+ "#{key}=#{CGI.escape(value.to_s)}"
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def self.append_key(root_key, key)
41
+ root_key.nil? ? key : "#{root_key}[#{key.to_s}]"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,13 @@
1
+ module RFC822
2
+ module Patterns
3
+ ATOM = "[^\\x00-\\x20\\x22\\x28\\x29\\x2c\\x2e\\x3a-\\x3c\\x3e\\x40\\x5b-\\x5d\\x7f-\\u00ff]+"
4
+ QTEXT = "[^\\x0d\\x22\\x5c\\u0080-\\u00ff]"
5
+ QPAIR = "\\x5c[\\x00-\\x7f]"
6
+ QSTRING = "\\x22(?:#{QTEXT}|#{QPAIR})*\\x22"
7
+ WORD = "(?:#{ATOM}|#{QSTRING})"
8
+ LOCAL_PT = "#{WORD}(?:\\x2e#{WORD})*"
9
+ ADDRESS = "#{LOCAL_PT}\\x40(?:#{URI::REGEXP::PATTERN::HOSTNAME})?#{ATOM}"
10
+ end
11
+
12
+ EMAIL = /\A#{Patterns::ADDRESS}\z/
13
+ end
@@ -0,0 +1,239 @@
1
+ #
2
+ # Copyright (c) 2017-2018 Cloudware S.A. All rights reserved.
3
+ #
4
+ # This file is part of sp-job.
5
+ #
6
+ # sp-job is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU Affero General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # sp-job is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU Affero General Public License
17
+ # along with sp-job. If not, see <http://www.gnu.org/licenses/>.
18
+ #
19
+ # encoding: utf-8
20
+ #
21
+
22
+ #
23
+ # Helper class to create simplifed OAUTH sessions suitable for jobs and casper applications, for the full blown OAUTH scene check casper-nginx-broker
24
+ #
25
+
26
+ module SP
27
+ module Job
28
+
29
+ class Session
30
+
31
+ attr_reader :access_ttl
32
+ attr_reader :refresh_ttl
33
+
34
+ def initialize (configuration:, serviceId:, multithread: false, programName:, redis:)
35
+ @sid = serviceId
36
+ @access_ttl = configuration[:oauth2][:access_ttl] || (1 * 3600) # Duration of the access tokens
37
+ @refresh_ttl = configuration[:oauth2][:refresh_ttl] || (2 * 3600) # Duration of the refresh tokens
38
+ @tolerance_ttl = configuration[:oauth2][:deleted_ttl] || 30 # Time a deleted token will remain "alive"
39
+ @redis = redis
40
+ @session_base = {
41
+ patched_by: programName,
42
+ client_id: configuration[:oauth2][:client_id],
43
+ redirect_uri: configuration[:oauth2][:redirect_uri],
44
+ scope: configuration[:oauth2][:scope],
45
+ issuer: programName
46
+ }
47
+
48
+ if multithread
49
+ raise 'Multithreading is not supported in MRI/CRuby' unless RUBY_ENGINE == 'jruby'
50
+ @redis_mutex = Mutex.new
51
+ else
52
+ @redis_mutex = nil
53
+ end
54
+ end
55
+
56
+ #
57
+ # Thread safe redis driver, pass a block to execute a generic redis operation
58
+ #
59
+ def redis
60
+ # callback is not optional
61
+ if @redis_mutex.nil?
62
+ yield(@redis)
63
+ else
64
+ # ... to enforce safe usage!
65
+ @redis_mutex.synchronize {
66
+ yield(@redis)
67
+ }
68
+ end
69
+ end
70
+
71
+ #
72
+ # Create a brand new access token with optional refresh token
73
+ #
74
+ # @param patch symbolicated hash with session data
75
+ # @param with_refresh when true the refresh token is also created, when false just access
76
+ # @return access_token or access_token and refresh token
77
+ #
78
+ def create (patch:, with_refresh: false)
79
+ session = patch.merge(@session_base)
80
+ session[:created_at] = Time.new.iso8601
81
+ if with_refresh
82
+ refresh_token = create_token(session: session, refresh_token: true)
83
+ session[:refresh_token] = refresh_token
84
+ else
85
+ session.delete(:refresh_token)
86
+ refresh_token = nil
87
+ end
88
+ access_token = create_token(session: session)
89
+ return access_token, refresh_token
90
+ end
91
+
92
+ #
93
+ # Create an access token by clonning a refresh token
94
+ #
95
+ # @param refresh_token the id of the refresh token
96
+ # @param session symbolicated session data
97
+ #
98
+ def create_from_refresh (refresh_token:, session:)
99
+ session[:created_at] = Time.new.iso8601
100
+ session[:refresh_token] = refresh_token
101
+ return create_token(session: session)
102
+ end
103
+
104
+ #
105
+ # Retrieve session hash from redis, keys are symbolicated
106
+ #
107
+ # @param token The access or refresh token
108
+ # @param refresh true for refresh, false for access_token
109
+ #
110
+ def get (token:, refresh: false)
111
+ key = "#{@sid}:oauth:#{refresh ? 'refresh_token' : 'access_token'}:#{token}"
112
+ session = nil
113
+ redis do |r|
114
+ session = r.hgetall(key)
115
+ end
116
+ rv = Hash.new
117
+ session.each do |key,value|
118
+ rv[key.to_sym] = value
119
+ end
120
+ return rv
121
+ end
122
+
123
+ #
124
+ # Create a token pair session by merging an existing access_token with the given patch
125
+ #
126
+ # @param token The access token to retrieve the original session hash
127
+ # @param patch a symbolicated hash that will overide existing keys and/or add new ones
128
+ #
129
+ # @note Use null values on the patch to delete keys from the original session
130
+ #
131
+ def patch (token:, patch:)
132
+ session = get(token: token)
133
+ refresh_token = session[:refresh_token]
134
+ patch.each do |key, value|
135
+ if value.nil?
136
+ session.delete(key)
137
+ else
138
+ session[key] = value
139
+ end
140
+ end
141
+ at, rt = create(patch: session, with_refresh: refresh_token != nil)
142
+ dispose(token: token, refresh_token: refresh_token)
143
+ return at,rt
144
+ end
145
+
146
+ #
147
+ # Cross patch, creates a new token on the current cluster by patching a source token from another cluster
148
+ #
149
+ # @param source session handler from which the original token is read
150
+ # @param token id of the original token on the source cluster
151
+ # @param patch symbolicated hash that is fused into source cluster
152
+ # @return fresh pait of access_token and refresh_token
153
+ #
154
+ def x_patch (source:, token:, patch:)
155
+ session = source.get(token: token)
156
+ refresh_token = session[:refresh_token]
157
+ patch.each do |key, value|
158
+ if value.nil?
159
+ session.delete(key)
160
+ else
161
+ session[key] = value
162
+ end
163
+ end
164
+ at, rt = create(patch: session, with_refresh: refresh_token != nil)
165
+ source.dispose(token: token, refresh_token: refresh_token)
166
+ return at,rt
167
+ end
168
+
169
+ #
170
+ # Delete tokens, immediately or after a grace period.
171
+ #
172
+ # @param token access token to dispose
173
+ # @param refresh_token (optional) refresh token to dispose
174
+ # @param timeleft grace period to keep the token alive, 0 to dispose immediately
175
+ #
176
+ # @note if the refresh token is not supplied attempts to retrive it from the access token
177
+ #
178
+ def dispose (token:, refresh_token: nil, timeleft: nil)
179
+ timeleft ||= @tolerance_ttl
180
+ key = "#{@sid}:oauth:access_token:#{token}"
181
+ redis do |r|
182
+ if refresh_token.to_s.size == 0
183
+ refresh_token = r.hget(key, 'refresh_token')
184
+ end
185
+ if refresh_token.to_s.size != 0
186
+ rkey = "#{@sid}:oauth:refresh_token:#{refresh_token}"
187
+ r.expire(rkey, timeleft)
188
+ end
189
+ r.expire(key, timeleft)
190
+ end
191
+ end
192
+
193
+ #
194
+ # Extend the life of a token by timetolive seconds
195
+ #
196
+ # @param token the token to preserve
197
+ # @param refresh true it's a refresh token, false for access
198
+ # @param timetolive new duration in seconds
199
+ #
200
+ def extend (token:, refresh: false, timetolive:)
201
+ key = "#{@sid}:oauth:#{refresh ? 'refresh_token' : 'access_token'}:#{token}"
202
+ rv = 0
203
+ redis do |r|
204
+ rv = r.expire(key, timetolive)
205
+ end
206
+ return rv
207
+ end
208
+
209
+ protected
210
+
211
+ def create_token (session:, refresh_token: false)
212
+ token = nil
213
+ 3.times do
214
+ token = SecureRandom.hex(32)
215
+ key = "#{@sid}:oauth:#{refresh_token ? 'refresh_token' : 'access_token'}:#{token}"
216
+ hset = []
217
+ session.each do |key, value|
218
+ unless value.nil?
219
+ hset << key
220
+ hset << value.to_s
221
+ end
222
+ end
223
+ redis do |r|
224
+ unless r.exists(key)
225
+ r.pipelined do
226
+ r.hmset(key, hset)
227
+ r.expire(key, refresh_token ? @refresh_ttl : @access_ttl)
228
+ end
229
+ return token
230
+ end
231
+ end
232
+ end
233
+ return nil
234
+ end
235
+
236
+ end
237
+
238
+ end
239
+ end