sp-job 0.1.17

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.
@@ -0,0 +1,342 @@
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
+ module SP
23
+ module Job
24
+
25
+ class BrokerHTTPClient
26
+
27
+ ### INNER CLASS(ES) ###
28
+
29
+ public
30
+
31
+ class Response
32
+
33
+ attr_accessor :code
34
+ attr_accessor :headers
35
+ attr_accessor :body
36
+
37
+ ### INSTANCE METHOD(S) ###
38
+
39
+ def initialize (a_curb_request)
40
+ http_response, *http_headers = a_curb_request.header_str.split(/[\r\n]+/).map(&:strip)
41
+ @code = a_curb_request.response_code
42
+ @headers = Response.symbolize_keys(Hash[http_headers.flat_map{ |s| s.scan(/^(\S+): (.+)/) }])
43
+ @body = a_curb_request.body
44
+ self
45
+ end
46
+
47
+ ### CLASS METHOD(S) ###
48
+
49
+ public
50
+
51
+ def self.symbolize_keys(a_hash)
52
+ a_hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v }
53
+ end
54
+
55
+ end
56
+
57
+ class WWWAuthenticateParser
58
+ class SchemeParsingError < StandardError
59
+ end
60
+ class SchemeParser
61
+ def parse(string)
62
+ scheme, attributes_string = split(string)
63
+ raise SchemeParsingError,
64
+ 'No attributes provided' if attributes_string.nil?
65
+ raise SchemeParsingError,
66
+ %(Unsupported scheme "#{scheme}") unless scheme == 'Bearer'
67
+ parse_attributes(attributes_string)
68
+ end
69
+
70
+ def split(string)
71
+ string.split(' ', 2)
72
+ end
73
+
74
+ def parse_attributes(string)
75
+ attributes = {}
76
+ string.scan(/(\w+)="([^"]*)"/).each do |group|
77
+ attributes[group[0].to_sym] = group[1]
78
+ end
79
+ attributes
80
+ end
81
+ end
82
+ end
83
+
84
+ #
85
+ # Current session data.
86
+ #
87
+ class Session
88
+
89
+ attr_accessor :is_new
90
+ attr_accessor :access_token
91
+ attr_accessor :expires_in
92
+ attr_accessor :refresh_token
93
+ attr_accessor :scope
94
+
95
+ #
96
+ # Initializer
97
+ #
98
+ # @param a_access_token
99
+ # @param a_refresh_token
100
+ # @param a_scope
101
+ # @param a_expires_in
102
+ #
103
+ def initialize(a_access_token, a_refresh_token, a_scope, a_expires_in = -1)
104
+ @is_new = ( nil == a_access_token )
105
+ @access_token = a_access_token
106
+ @expires_in = a_expires_in
107
+ @refresh_token = a_refresh_token
108
+ @scope = a_scope
109
+ self
110
+ end
111
+
112
+ end # class Session
113
+
114
+ ### METHOD(S) ###
115
+
116
+ public
117
+
118
+ def session
119
+ # Avoid exposing the original session
120
+ Session.new(
121
+ @session.access_token,
122
+ @session.refresh_token,
123
+ @session.scope,
124
+ @session.expires_in
125
+ )
126
+ end
127
+
128
+ #
129
+ # Initializer
130
+ #
131
+ # @param a_session
132
+ # @param a_config
133
+ # @param a_refreshed_callback
134
+ # @param a_auto_renew_refresh_token
135
+ #
136
+ def initialize(a_session, a_oauth2_client, a_refreshed_callback, a_auto_renew_refresh_token)
137
+ @session = a_session
138
+ @oauth2_client = a_oauth2_client
139
+ @refreshed_callback = a_refreshed_callback
140
+ @auto_renew_refresh_token = a_auto_renew_refresh_token
141
+ end
142
+
143
+ #
144
+ # Perfom an HTTP GET request and, if required, renew access token.
145
+ #
146
+ # @param a_uri
147
+ # @param a_content_type
148
+ #
149
+ def get(a_uri, a_content_type = 'application/vnd.api+json', a_auto_renew_token = true)
150
+ if true == a_auto_renew_token || nil == @session.access_token
151
+ response = call_and_try_to_recover do
152
+ do_http_get(a_uri, a_content_type)
153
+ end
154
+ else
155
+ do_http_get(a_uri, a_content_type)
156
+ end
157
+ end
158
+
159
+ #
160
+ # Perfom an HTTP POST request and, if required, renew access token.
161
+ #
162
+ # @param a_uri
163
+ # @param a_body
164
+ # @param a_content_type
165
+ #
166
+ def post(a_uri, a_body, a_content_type = 'application/vnd.api+json', a_auto_renew_token = true)
167
+ if true == a_auto_renew_token || nil == @session.access_token
168
+ response = call_and_try_to_recover do
169
+ do_http_post(a_uri, a_body, a_content_type)
170
+ end
171
+ else
172
+ do_http_post(a_uri, a_body, a_content_type)
173
+ end
174
+ end
175
+
176
+ #
177
+ # Perfom a HTTP PATCH request.
178
+ #
179
+ # @param a_uri
180
+ # @param a_body
181
+ # @param a_content_type
182
+ #
183
+ def patch(a_uri, a_body, a_content_type = 'application/vnd.api+json', a_auto_renew_token = true)
184
+ if true == a_auto_renew_token || nil == @session.access_token
185
+ response = call_and_try_to_recover do
186
+ do_http_patch(a_uri, a_body, a_content_type)
187
+ end
188
+ else
189
+ do_http_patch(a_uri, a_body, a_content_type)
190
+ end
191
+ end
192
+
193
+ #
194
+ # Perfom a HTTP DELETE request.
195
+ #
196
+ # @param a_uri
197
+ # @param a_body
198
+ # @param a_content_type
199
+ #
200
+ def delete(a_uri, a_body = nil, a_content_type = 'application/vnd.api+json', a_auto_renew_token = true)
201
+ if true == a_auto_renew_token || nil == @session.access_token
202
+ response = call_and_try_to_recover do
203
+ do_http_delete(a_uri, a_body, a_content_type)
204
+ end
205
+ else
206
+ do_http_delete(a_uri, a_body, a_content_type)
207
+ end
208
+ end
209
+
210
+ ### METHOD(S) ###
211
+
212
+ private
213
+
214
+ #
215
+ # Perfom a HTTP GET request.
216
+ #
217
+ # @param a_uri
218
+ # @param a_content_type
219
+ #
220
+ def do_http_get (a_uri, a_content_type = 'application/vnd.api+json')
221
+ http_request = Curl::Easy.http_get(a_uri) do |curl|
222
+ curl.headers['Content-Type'] = a_content_type;
223
+ curl.headers['Authorization'] = "Bearer #{@session.access_token}"
224
+ end
225
+ Response.new(http_request)
226
+ end
227
+
228
+ #
229
+ # Perfom a HTTP POST request.
230
+ #
231
+ # @param a_uri
232
+ # @param a_body
233
+ # @param a_content_type
234
+ #
235
+ def do_http_post (a_uri, a_body, a_content_type = 'application/vnd.api+json')
236
+ http_request = Curl::Easy.http_post(a_uri, a_body) do |curl|
237
+ curl.headers['Content-Type'] = a_content_type;
238
+ curl.headers['Authorization'] = "Bearer #{@session.access_token}"
239
+ end
240
+ Response.new(http_request)
241
+ end
242
+
243
+ #
244
+ # Perfom a HTTP PATCH request.
245
+ #
246
+ # @param a_uri
247
+ # @param a_body
248
+ # @param a_content_type
249
+ #
250
+ def do_http_patch (a_uri, a_body, a_content_type = 'application/vnd.api+json')
251
+ http_request = Curl.http(:PATCH, a_uri, a_body) do |curl|
252
+ curl.headers['Content-Type'] = a_content_type;
253
+ curl.headers['Authorization'] = "Bearer #{@session.access_token}"
254
+ end
255
+ Response.new(http_request)
256
+ end
257
+
258
+ #
259
+ # Perfom a HTTP DELETE request.
260
+ #
261
+ # @param a_uri
262
+ # @param a_body
263
+ # @param a_content_type
264
+ #
265
+ def do_http_delete (a_uri, a_body = nil, a_content_type = 'application/vnd.api+json')
266
+ http_request = Curl::Easy.http_delete(a_uri) do |curl|
267
+ curl.headers['Content-Type'] = a_content_type;
268
+ curl.headers['Authorization'] = "Bearer #{@session.access_token}"
269
+ end
270
+ Response.new(http_request)
271
+ end
272
+
273
+ #
274
+ # Perform an HTTP request an if 'invalid_token' is returned try to renew
275
+ # access_token and retry request.
276
+ #
277
+ # @param callback
278
+ #
279
+ def call_and_try_to_recover(*callback)
280
+ # pre-request check
281
+ if nil == @session.access_token
282
+ fetch_new_tokens()
283
+ end
284
+ # call http request
285
+ response = yield
286
+ if 401 == response.code && response.headers.has_key?(:'WWW-Authenticate')
287
+ # try to refresh access_token
288
+ tokens_response = @oauth2_client.refresh_access_token(@session.refresh_token, @session.scope)
289
+ if 200 == tokens_response[:http][:status_code] && tokens_response[:oauth2] && ! tokens_response[:oauth2][:error]
290
+ # success: keep track of new data
291
+ @session.is_new = false
292
+ @session.access_token = tokens_response[:oauth2][:access_token]
293
+ @session.refresh_token = tokens_response[:oauth2][:refresh_token]
294
+ @session.scope = tokens_response[:oauth2][:scope] || @session.scope
295
+ @session.expires_in = tokens_response[:oauth2][:expires_in] || -1
296
+ # notify owner
297
+ if nil != @refreshed_callback
298
+ @refreshed_callback.call(@session)
299
+ end
300
+ else
301
+ fetch_new_tokens()
302
+ end
303
+ # retry http request
304
+ response = yield
305
+ end
306
+ response
307
+ end
308
+
309
+ def fetch_new_tokens()
310
+ # this is only allower for server 2 server usage
311
+ # and when the client configuration has company data already set
312
+ if false == @auto_renew_refresh_token
313
+ raise ::SP::Job::BrokerOAuth2Client::UnauthorizedUser.new(nil)
314
+ end
315
+ # failure: request a new 'authorization code'
316
+ auth_code_response = @oauth2_client.get_authorization_code(
317
+ a_redirect_uri = nil,
318
+ a_scope = @session.scope
319
+ )
320
+ # success ?
321
+ if 302 == auth_code_response[:http][:status_code] && auth_code_response[:oauth2] && ! auth_code_response[:oauth2][:error]
322
+ # request new access and refresh tokens
323
+ tokens_response = @oauth2_client.exchange_auth_code_for_token(auth_code_response[:oauth2][:code])
324
+ if 200 == tokens_response[:http][:status_code] && tokens_response[:oauth2] && ! tokens_response[:oauth2][:error]
325
+ # success: keep track of new data
326
+ @session.is_new = true
327
+ @session.access_token = tokens_response[:oauth2][:access_token]
328
+ @session.refresh_token = tokens_response[:oauth2][:refresh_token]
329
+ @session.scope = tokens_response[:oauth2][:scope] || @session.scope
330
+ @session.expires_in = tokens_response[:oauth2][:expires_in] || -1
331
+ # notify owner
332
+ if nil != @refreshed_callback
333
+ @refreshed_callback.call(@session)
334
+ end
335
+ end
336
+ end
337
+ end
338
+
339
+ end
340
+
341
+ end # module Job
342
+ end #module SP
@@ -0,0 +1,378 @@
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
+ # https://github.com/tiabas/oauth2-client
23
+
24
+ require 'oauth2'
25
+ require 'oauth2-client'
26
+ require 'curb'
27
+
28
+ module SP
29
+ module Job
30
+
31
+ class BrokerOAuth2Client
32
+
33
+ public
34
+
35
+ #
36
+ # Configuration
37
+ #
38
+ # {
39
+ # "protocol": "",
40
+ # "host": "",
41
+ # "port": 0,
42
+ # "endpoints": {
43
+ # "authorization" : "",
44
+ # "token" : ""
45
+ # }
46
+ # }
47
+ class Config
48
+
49
+ @protocol = nil
50
+ @host = nil
51
+ @port = nil
52
+ @endpoints = nil
53
+ @path = nil
54
+ @base_url = nil
55
+ @client_id = nil
56
+ @client_secret = nil
57
+ @redirect_uri = nil
58
+ @scope = nil
59
+
60
+ attr_accessor :protocol
61
+ attr_accessor :host
62
+ attr_accessor :port
63
+ attr_accessor :endpoints
64
+ attr_accessor :path
65
+ attr_accessor :base_url
66
+
67
+ attr_accessor :client_id
68
+ attr_accessor :client_secret
69
+ attr_accessor :redirect_uri
70
+ attr_accessor :scope
71
+
72
+ def initialize(a_hash)
73
+ @protocol = a_hash[:protocol]
74
+ @host = a_hash[:host]
75
+ @port = a_hash[:port]
76
+ @path = a_hash[:path]
77
+ @endpoints = {
78
+ :authorization => a_hash[:endpoints][:authorization],
79
+ :token => a_hash[:endpoints][:token]
80
+ }
81
+ @path = nil
82
+ @base_url = "#{@protocol}://#{@host}"
83
+ if @port && 80 != @port
84
+ @base_url += ":#{@port}"
85
+ end
86
+ if @path
87
+ @base_url += "#{@path}"
88
+ end
89
+ @client_id = a_hash[:client_id]
90
+ @client_secret = a_hash[:client_secret]
91
+ @redirect_uri = a_hash[:redirect_uri]
92
+ @scope = a_hash[:scope]
93
+ end
94
+
95
+ end
96
+
97
+ private
98
+
99
+ #
100
+ # Generic error.
101
+ #
102
+ class Error < StandardError
103
+
104
+ @code = nil
105
+ @description = nil
106
+
107
+ attr_accessor :code
108
+ attr_accessor :description
109
+
110
+ def initialize(a_code, a_description)
111
+ @code = a_code
112
+ @description = a_description
113
+ end
114
+
115
+ def as_hash
116
+ { :oauth2 => { :error => @code, :error_description => @description } }
117
+ end
118
+
119
+ end
120
+
121
+ public
122
+
123
+ #
124
+ # Access denied error.
125
+ #
126
+ class AccessDenied < Error
127
+ def initialize(a_description)
128
+ super "access_denied", a_description
129
+ end
130
+ end
131
+
132
+ #
133
+ # Invalid e-mail or password error.
134
+ #
135
+ class InvalidEmailOrPassword < AccessDenied
136
+ def initialize(a_description="Invalid email or password!")
137
+ super a_description
138
+ end
139
+ end
140
+
141
+ #
142
+ # Unauthorized User
143
+ #
144
+ class UnauthorizedUser< Error
145
+ def initialize(a_description)
146
+ super "unauthorized_user", a_description
147
+ end
148
+ end
149
+
150
+
151
+ #
152
+ # Internal error.
153
+ #
154
+ class InternalError < Error
155
+ def initialize(a_description)
156
+ super "internal_error", a_description
157
+ end
158
+ end
159
+
160
+ #
161
+ #
162
+ #
163
+ class CurbConnectionClient
164
+
165
+ class Response
166
+
167
+ attr_accessor :code
168
+ attr_accessor :body
169
+
170
+ @code = nil
171
+ @body = nil
172
+
173
+ def initialize(code:, body:)
174
+ @code = code
175
+ @body = body
176
+ end
177
+
178
+ end
179
+
180
+ def initialize(site_url, connection_options={})
181
+ # set url and connection options
182
+ @site_url = site_url
183
+ @connection_options = connection_options
184
+ end
185
+
186
+ def base_url(path)
187
+ @site_url + path
188
+ end
189
+
190
+ def send_request(http_method, request_path, options={})
191
+
192
+ # options may contain optional arguments like http headers, request parameters etc
193
+ # send http request over the inter-webs
194
+
195
+ params = options[:params] || {}
196
+ headers = options[:headers]|| {}
197
+ url = base_url(request_path)
198
+ handle = Curl::Easy.new(url)
199
+ headers.each do |key, value|
200
+ handle.headers[key] = value
201
+ end
202
+
203
+ case http_method
204
+ when :get
205
+ handle.http_get()
206
+ return Response.new(code: handle.response_code.to_s, headers: nil)
207
+ when :post
208
+ args = []
209
+ params.each do |key, value|
210
+ args << Curl::PostField.content(key, value)
211
+ end
212
+ handle.http_post(args)
213
+ return Response.new(code: handle.response_code.to_s, body: handle.body_str)
214
+ when :delete
215
+ when :put
216
+ raise UnhandledHTTPMethodError.new("Unsupported HTTP method, #{http_method}")
217
+ else
218
+ raise UnhandledHTTPMethodError.new("Unsupported HTTP method, #{http_method}")
219
+ end
220
+ end
221
+ end
222
+
223
+
224
+ private
225
+
226
+ @client = nil
227
+ @redirect_uri = nil
228
+ @scope = nil
229
+
230
+ public
231
+
232
+ #
233
+ # Initializer
234
+ #
235
+ def initialize(protocol:, host:, port:, client_id:, client_secret:, redirect_uri:, scope:, options: {})
236
+ host = "#{protocol}://#{host}"
237
+ if ( 'https' == protocol && 443 != port ) || ( 'http' == protocol && 80 != port )
238
+ host += ":#{port}"
239
+ end
240
+ options.merge!({
241
+ :connection_client => CurbConnectionClient
242
+ })
243
+ @client = ::OAuth2Client::Client.new(host, client_id, client_secret, options)
244
+ @redirect_uri = redirect_uri
245
+ @scope = scope
246
+ @client.token_path = '/oauth/token'
247
+ @client.authorize_path = '/oauth/auth'
248
+ end
249
+
250
+
251
+ #
252
+ # Returns the authorization url, ready to be called.
253
+ #
254
+ def get_authorization_url(a_redirect_uri, a_scope = nil)
255
+ a_scope = @client.normalize_scope(a_scope, ',') if a_scope
256
+ @client.authorization_code.authorization_url({
257
+ redirect_uri: a_redirect_uri,
258
+ scope: a_scope
259
+ })
260
+ end
261
+
262
+ #
263
+ # Build and call the authorization url
264
+ #
265
+ # Returns CURL response object.
266
+ #
267
+ def call_authorization_url(a_redirect_uri, a_scope = nil)
268
+ url = get_authorization_url(a_redirect_uri, a_scope)
269
+ c = Curl::Easy.http_get(url) do |curl|
270
+ curl.headers['Content-Type'] = "application/json";
271
+ end
272
+ http_response, *http_headers = c.header_str.split(/[\r\n]+/).map(&:strip)
273
+ http_headers = Hash[http_headers.flat_map{ |s| s.scan(/^(\S+): (.+)/) }]
274
+ if 302 == c.response_code
275
+ if not http_headers.has_key?('Location')
276
+ raise InternalError.new("Response is missing 'Location' header!")
277
+ end
278
+ end
279
+ Curl::Easy.http_get(http_headers['Location'])
280
+ end
281
+
282
+ #
283
+ # Build and call the authorization url.
284
+ #
285
+ # Returns an hash with http data and oauth2 authorization code.
286
+ #
287
+ def get_authorization_code(a_redirect_uri, a_scope = nil)
288
+ url = get_authorization_url(a_redirect_uri || @redirect_uri, a_scope)
289
+ c = Curl::Easy.http_get(url) do |curl|
290
+ curl.headers['Content-Type'] = "application/json";
291
+ end
292
+ http_response, *http_headers = c.header_str.split(/[\r\n]+/).map(&:strip)
293
+ http_headers = Hash[http_headers.flat_map{ |s| s.scan(/^(\S+): (.+)/) }]
294
+ if 302 == c.response_code
295
+ if not http_headers.has_key?('Location')
296
+ raise InternalError.new("Response is missing 'Location' header!")
297
+ end
298
+ if false == http_headers['Location'].start_with?("#{a_redirect_uri}")
299
+ raise InternalError.new("Unable to parse 'Location'")
300
+ end
301
+ h = {
302
+ :http => {
303
+ :status_code => c.response_code,
304
+ :location => http_headers['Location'],
305
+ :params => Hash[ URI::decode_www_form(URI(http_headers['Location']).query).to_h.map { |k, v| [k.to_sym, v] }]
306
+ },
307
+ }
308
+ if not h[:http][:params][:code]
309
+ if not h[:http][:params][:error]
310
+ raise InternalError.new("Unable to retrieve an authorization code or error!")
311
+ else
312
+ h[:oauth2] = {
313
+ :error => h[:http][:params][:error]
314
+ }
315
+ if h[:http][:params][:error_description]
316
+ h[:oauth2][:error_description] = h[:http][:params][:error_description]
317
+ end
318
+ end
319
+ else
320
+ h[:oauth2] = {
321
+ :code => h[:http][:params][:code]
322
+ }
323
+ end
324
+ h
325
+ else
326
+ raise InternalError.new("Unable to retrieve an authorization code - unexpected HTTP status code #{c.response_code}!")
327
+ end
328
+ end
329
+
330
+ #
331
+ # Exchange an 'authorization code' for access ( and refresh ) token(s).
332
+ #
333
+ # @param a_code
334
+ # @param a_scope
335
+ #
336
+ def exchange_auth_code_for_token(a_code, a_scope = nil)
337
+ unless a_code
338
+ raise InternalError.new("Authorization code expected but was nil!")
339
+ end
340
+ opts = { authenticate: :headers }
341
+ if nil != a_scope
342
+ opts[:params] = { :scope => a_scope }
343
+ end
344
+ response = @client.authorization_code.get_token(a_code, opts)
345
+ h = {
346
+ :http => {
347
+ :status_code => response.code.to_i,
348
+ }
349
+ }
350
+ h[:oauth2] = Hash[ JSON.parse(response.body).to_h.map { |k, v| [k.to_sym, v] }]
351
+ h
352
+ end
353
+
354
+ #
355
+ # Refresh an 'access token'.
356
+ #
357
+ # @param a_refresh_token
358
+ # @param a_scope
359
+ #
360
+ def refresh_access_token (a_refresh_token, a_scope = nil)
361
+ unless a_refresh_token
362
+ raise InternalError.new("'refresh token' is expected but is nil!")
363
+ end
364
+ opts = nil != a_scope ? { :params => { :scope => a_scope } } : {}
365
+ response = @client.refresh_token.get_token(a_refresh_token, opts)
366
+ h = {
367
+ :http => {
368
+ :status_code => response.code.to_i,
369
+ }
370
+ }
371
+ h[:oauth2] = Hash[ JSON.parse(response.body).to_h.map { |k, v| [k.to_sym, v] }]
372
+ h
373
+ end
374
+
375
+ end # BrokerOAuth2Client
376
+
377
+ end # module 'Job'
378
+ end # module 'SP'