smartring 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.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httmultiparty'
4
+ require 'hipsterhash'
5
+
6
+ module Smartling
7
+ # Methods for authenticating a Smartling user with the API.
8
+ module Auth
9
+ attr_reader :user_id, :user_secret, :access_token, :refresh_token
10
+
11
+ def authenticate
12
+ refresh_token! if access_token&.expired? && refresh_token&.valid?
13
+ authenticate! unless access_token&.valid?
14
+ token = access_token.to_s
15
+ return token unless block_given?
16
+ yield token
17
+ end
18
+
19
+ public
20
+
21
+ def refresh_token!
22
+ path = '/auth-api/v2/authenticate/refresh'
23
+ headers = { 'Content-Type' => 'application/json' }
24
+ payload = { refreshToken: refresh_token.to_s }.to_json
25
+ resp = self.class.post(path, headers: headers, query: payload)
26
+ resp = HipsterHash[resp.parsed_response].response
27
+ raise(Failed, resp) unless resp.code == 'SUCCESS'
28
+ self.tokens = resp.data
29
+ true
30
+ end
31
+
32
+ def authenticate!
33
+ path = '/auth-api/v2/authenticate'
34
+ payload = { userIdentifier: user_id, userSecret: user_secret }.to_json
35
+ headers = { 'Content-Type' => 'application/json' }
36
+ resp = self.class.post(path, headers: headers, body: payload)
37
+ resp = HipsterHash[resp.parsed_response].response
38
+ raise(Failed, resp) unless resp.code == 'SUCCESS'
39
+ self.tokens = resp.data
40
+ true
41
+ end
42
+
43
+ def tokens=(data)
44
+ @access_token = Token.new(data.accessToken, data.expiresIn)
45
+ @refresh_token = Token.new(data.refreshToken, data.refreshExpiresIn)
46
+ end
47
+
48
+ Token = Class.new do
49
+ def initialize(token, expires_in)
50
+ @token = token
51
+ @expires_at = Time.now + expires_in
52
+ end
53
+ define_method(:expired?) { @expires_at < Time.now }
54
+ define_method(:valid?) { !expired? }
55
+ define_method(:to_s) { @token.to_s }
56
+ end
57
+
58
+ Error = Class.new(Exception)
59
+ Failed = Class.new(Error)
60
+ end
61
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'smartling/auth'
5
+ require 'smartling/files'
6
+ require 'smartling/contexts'
7
+ require 'smartling/strings'
8
+ require 'smartling/verbs'
9
+ require 'hipsterhash'
10
+ require 'forwardable'
11
+
12
+ module Smartling
13
+ # A fairly generic Smartling REST API client.
14
+ class Client
15
+ include HTTMultiParty
16
+ include Auth
17
+ include Files
18
+ include Strings
19
+ include Contexts
20
+ include Verbs
21
+
22
+ attr_accessor :project_id
23
+
24
+ base_uri 'https://api.smartling.com'
25
+ headers 'Accept' => 'application/json'
26
+ raise_on [404, 401, 500]
27
+
28
+ def initialize(user_id: ENV.fetch('SMARTLING_USER_ID'),
29
+ user_secret: ENV.fetch('SMARTLING_USER_SECRET'),
30
+ project_id: ENV.fetch('SMARTLING_PROJECT_ID', nil))
31
+ @user_id = user_id
32
+ @user_secret = user_secret
33
+ @project_id = project_id
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartling
4
+ # Methods for dealing wth the Smartling Contexts API
5
+ module Contexts
6
+ def contexts(project_id: @project_id, name_filter: nil, offset: nil,
7
+ type: nil)
8
+ path = "/context-api/v2/projects/#{project_id}/contexts"
9
+ query = {}
10
+ query[:nameFilter] = name_filter unless name_filter.nil?
11
+ query[:offset] = Integer(offset) unless offset.nil?
12
+ query[:type] = type.to_s.upcase unless type.nil?
13
+ get(path, query: query)
14
+ end
15
+
16
+ def context(project_id: @project_id, context_uid:)
17
+ path = "/context-api/v2/projects/#{project_id}/contexts/#{context_uid}"
18
+ get(path)
19
+ end
20
+
21
+ # Uploads a new context. Content must quack like an UploadIO.
22
+ def upload_context(project_id: @project_id, content:, name: nil)
23
+ path = "/context-api/v2/projects/#{project_id}/contexts"
24
+ raise(InvalidContent, content) unless Contexts.valid_content?(content)
25
+ body = { content: content }
26
+ body[:name] = name unless name.nil?
27
+ post(path, body: body)
28
+ end
29
+
30
+ # Uploads a new context. Content must quack like an UploadIO.
31
+ def upload_context_and_match(project_id: @project_id, content:,
32
+ match_params: nil, name: nil)
33
+ path = "/context-api/v2/projects/#{project_id}/contexts"
34
+ path += '/upload-and-match-async'
35
+ raise(InvalidContent, content) unless Contexts.valid_content?(content)
36
+ body = { content: content }
37
+ body[:name] = name unless name.nil?
38
+ body[:matchParams] = match_params unless match_params.nil?
39
+ post(path, body: body)
40
+ end
41
+
42
+ def download_context(project_id: @project_id, context_uid:)
43
+ path = "/context-api/v2/projects/#{project_id}/contexts"
44
+ path += "/#{context_uid}/content"
45
+ get(path)
46
+ end
47
+
48
+ def delete_context(project_id: @project_id, context_uid:)
49
+ path = "/context-api/v2/projects/#{project_id}/contexts/#{context_uid}"
50
+ delete(path)
51
+ end
52
+
53
+ # POSTs to the /match/async endpoint
54
+ def match_context(project_id: @project_id, context_uid:, hashcodes: [])
55
+ path = "/context-api/v2/projects/#{project_id}/contexts/#{context_uid}"
56
+ path += '/match/async'
57
+ query = { stringHashcodes: hashcodes }.to_json
58
+ headers = { 'Content-Type' => 'application/json' }
59
+ post(path, query: query, headers: headers)
60
+ end
61
+
62
+ # GETs the results of an async match
63
+ def context_matches(project_id: @project_id, match_id:)
64
+ path = "/context-api/v2/projects/#{project_id}/match/#{match_id}"
65
+ get(path)
66
+ end
67
+
68
+ InvalidContent = Class.new(ArgumentError)
69
+
70
+ def self.valid_content?(content)
71
+ %i[read content_type original_filename].all? do |required_method|
72
+ content.respond_to?(required_method)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'net/http/post/multipart'
5
+
6
+ module Smartling
7
+ # Methods for using the Smartling files API
8
+ module Files
9
+ def files(project_id: @project_id)
10
+ path = "/files-api/v2/projects/#{project_id}/files/list"
11
+ get(path)
12
+ end
13
+
14
+ def file(project_id: @project_id, file_uri:, locale: nil)
15
+ path = ["/files-api/v2/projects/#{project_id}"]
16
+ path << "locales/#{locale}" unless locale.nil?
17
+ path << 'file/status'
18
+ path = path.join('/')
19
+ get(path, query: { fileUri: file_uri })
20
+ end
21
+
22
+ def delete_file(project_id: @project_id, file_uri:)
23
+ path = "/files-api/v2/projects/#{project_id}/file/delete"
24
+ post(path, body: { fileUri: file_uri })
25
+ end
26
+
27
+ def upload_file(project_id: @project_id, file:, file_uri:, file_type:,
28
+ callback: nil, authorize: nil, locales_to_authorize: nil,
29
+ smartling: {})
30
+ raise(InvalidFile, file) unless Files.valid_file?(file)
31
+ path = "/files-api/v2/projects/#{project_id}/file"
32
+ body = { file: file, fileUri: file_uri, fileType: file_type }
33
+ body[:authorize] = authorize unless authorize.nil?
34
+ body[:callback] = callback unless callback.nil?
35
+ unless locales_to_authorize.nil?
36
+ body[:localeIdsToAuthorize] = locales_to_authorize
37
+ end
38
+ smartling.each { |k, v| body["smartling.#{k}"] = v }
39
+ post(path, body: body)
40
+ end
41
+
42
+ InvalidFile = Class.new(ArgumentError)
43
+
44
+ def self.valid_file?(content)
45
+ %i[read content_type original_filename].all? do |required_method|
46
+ content.respond_to?(required_method)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartling
4
+ # Methods for the Smartling strings API
5
+ module Strings
6
+ def source_strings(project_id: @project_id, file_uri: nil, hashcodes: nil,
7
+ limit: nil, offset: nil)
8
+ path = "/strings-api/v2/projects/#{project_id}/source-strings"
9
+ query = {}
10
+ query[:fileUri] = file_uri unless file_uri.nil?
11
+ query[:stringHashcodes] = hashcodes unless hashcodes.nil?
12
+ query[:limit] = Integer(limit) unless limit.nil?
13
+ query[:offset] = Integer(offset) unless limit.nil?
14
+ get(path, query: query)
15
+ end
16
+
17
+ def translations(project_id: @project_id, file_uri: nil, hashcodes: nil,
18
+ limit: nil, offset: nil, retrieval_type: nil,
19
+ target_locale_id: nil)
20
+ path = "/strings-api/v2/projects/#{project_id}/translations"
21
+ query = {}
22
+ query[:fileUri] = file_uri unless file_uri.nil?
23
+ query[:hashcodes] = hashcodes unless hashcodes.nil?
24
+ query[:retrievalType] = retrieval_type unless retrieval_type.nil?
25
+ query[:limit] = Integer(limit) unless limit.nil?
26
+ query[:offset] = Integer(offset) unless limit.nil?
27
+ query[:targetLocaleId] = target_locale_id unless target_locale_id.nil?
28
+ get(path, query: query)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ module Smartling
5
+ module Verbs
6
+ %i[get put post patch delete].each do |verb|
7
+ define_method(verb) do |path, options = {}|
8
+ authenticate do |token|
9
+ (options[:headers] ||= {})[:Authorization] = "Bearer #{token}"
10
+ resp = self.class.send(verb, path, options).parsed_response
11
+ return resp unless resp.is_a?(Hash)
12
+ HipsterHash[resp].response
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
data/lib/smartling.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'smartling/client'
4
+
5
+ # Smartling contains a client and helpers for working with the Smartling
6
+ # files, contexts, strings and authentication API
7
+ module Smartling
8
+ class << self
9
+ # Gets a new Client
10
+ def new(*args)
11
+ Client.new(*args)
12
+ end
13
+ end
14
+ end
data/lib/smartring.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'smartling'
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'hipsterhash'
5
+ require 'httmultiparty'
6
+ require 'smartling/client'
7
+
8
+ describe Smartling::Auth do
9
+ describe 'authenticate (first time)' do
10
+ it 'POSTs to the authentication endpoint', :vcr do
11
+ client = Smartling::Client.new
12
+ client.authenticate!
13
+ end
14
+ end
15
+
16
+ describe 'authenticate (twice)' do
17
+ it 'POSTs to the authentication endpoint', :vcr do
18
+ client = Smartling::Client.new
19
+ client.authenticate!
20
+ client.refresh_token!
21
+ client.authenticate!
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'smartling/client'
5
+
6
+ describe Smartling::Client, :vcr do
7
+ let(:options) { {} }
8
+ let(:client) { Smartling::Client.new(options) }
9
+
10
+ it('knows context') { assert client.is_a?(Smartling::Contexts) }
11
+ it('knows about files') { assert client.is_a?(Smartling::Files) }
12
+ it('knows about strings') { assert client.is_a?(Smartling::Strings) }
13
+ it('knows how to authenticate') { assert client.is_a?(Smartling::Auth) }
14
+
15
+ it 'can be created with options' do
16
+ c = Smartling::Client.new(project_id: 3)
17
+ refute_nil c.user_id
18
+ refute_nil c.user_secret
19
+ assert_equal 3, c.project_id
20
+ end
21
+
22
+ it 'is cool with bullshit' do
23
+ path = '/no/such/path'
24
+ assert_raises { Smartling::Client.new.get(path) }
25
+ end
26
+
27
+ it 'explains when credentials are borked' do
28
+ path = '/auth-api/v2/authorize'
29
+ assert_raises do
30
+ Smartling::Client.new(user_id: 'no', user_secret: 'way').get(path)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'smartling/contexts'
5
+
6
+ describe Smartling::Contexts do
7
+ let(:smartling) do
8
+ Class.new(Minitest::Mock) do
9
+ include Smartling::Contexts
10
+ end.new
11
+ end
12
+
13
+ after { smartling.verify }
14
+
15
+ describe 'contexts' do
16
+ it 'lists contexts the items of the smartling contexts result' do
17
+ smartling.expect(:get, nil) do |path, query:|
18
+ assert_equal path, '/context-api/v2/projects/1/contexts'
19
+ assert_equal 'n', query.fetch(:nameFilter)
20
+ assert_equal 1, query.fetch(:offset)
21
+ assert_equal 'HTML', query.fetch(:type)
22
+ end
23
+ smartling.contexts(project_id: 1, name_filter: 'n',
24
+ offset: 1, type: 'HTML')
25
+ end
26
+ end
27
+
28
+ describe 'context' do
29
+ it 'gets the /context with the right param' do
30
+ smartling.expect(:get, nil) do |path|
31
+ assert_equal '/context-api/v2/projects/1/contexts/x', path
32
+ end
33
+ smartling.context(project_id: 1, context_uid: 'x')
34
+ end
35
+ end
36
+
37
+ describe 'download_context' do
38
+ it 'gets the /context with the right param' do
39
+ smartling.expect(:get, nil) do |path|
40
+ assert_equal '/context-api/v2/projects/1/contexts/x/content', path
41
+ end
42
+ smartling.download_context(project_id: 1, context_uid: 'x')
43
+ end
44
+ end
45
+
46
+ describe 'delete_context' do
47
+ it 'deletes the context at the right endpoing' do
48
+ smartling.expect(:delete, nil) do |path|
49
+ assert_equal '/context-api/v2/projects/1/contexts/x', path
50
+ end
51
+ smartling.delete_context(project_id: 1, context_uid: 'x')
52
+ end
53
+ end
54
+
55
+ describe 'upload_context' do
56
+ it 'posts the right body to the right endpoint' do
57
+ smartling.expect(:post, nil) do |path, body:|
58
+ assert_equal '/context-api/v2/projects/1/contexts', path
59
+ assert_equal 'n', body.fetch(:name)
60
+ assert_equal '<i>Hi</i>', body.fetch(:content).read
61
+ assert_equal 'text/html', body.fetch(:content).content_type
62
+ assert_equal 'n', body.fetch(:name)
63
+ end
64
+ content = UploadIO.new(StringIO.new('<i>Hi</i>'), 'text/html')
65
+ smartling.upload_context(project_id: 1, content: content, name: 'n')
66
+ end
67
+
68
+ it 'prints a helpful message if the contextType is unsupported' do
69
+ assert_raises Smartling::Contexts::InvalidContent do
70
+ smartling.upload_context(project_id: 1, content: 'not a file')
71
+ end
72
+ end
73
+ end
74
+
75
+ describe 'upload_context_and_match' do
76
+ it 'posts the right body to the right endpoint' do
77
+ smartling.expect(:post, nil) do |path, body:|
78
+ expected = '/context-api/v2/projects/1/contexts/'
79
+ expected += 'upload-and-match-async'
80
+ assert_equal expected, path
81
+ assert_equal 'n', body.fetch(:name)
82
+ assert_equal '<i>Hi</i>', body.fetch(:content).read
83
+ assert_equal 'text/html', body.fetch(:content).content_type
84
+ assert_equal %w[1 2 3], body.fetch(:matchParams)
85
+ assert_equal 'n', body.fetch(:name)
86
+ end
87
+ content = UploadIO.new(StringIO.new('<i>Hi</i>'), 'text/html')
88
+ smartling.upload_context_and_match(project_id: 1, content: content,
89
+ match_params: %w[1 2 3], name: 'n')
90
+ end
91
+
92
+ it 'prints a helpful message if the contextType is unsupported' do
93
+ assert_raises Smartling::Contexts::InvalidContent do
94
+ smartling.upload_context(project_id: 1, content: 'not a file')
95
+ end
96
+ end
97
+ end
98
+
99
+ describe 'async context matching' do
100
+ it 'posts to the right endpoint with the right params' do
101
+ smartling.expect(:post, nil) do |path, query:, headers:|
102
+ assert_equal '/context-api/v2/projects/1/contexts/x/match/async', path
103
+ assert_equal %w[x], JSON.parse(query)['stringHashcodes']
104
+ assert_equal 'application/json', headers['Content-Type']
105
+ end
106
+ smartling.match_context(project_id: 1, context_uid: 'x',
107
+ hashcodes: %w[x])
108
+ end
109
+ end
110
+
111
+ describe 'retrieving match results' do
112
+ it 'get the right endpoint' do
113
+ smartling.expect(:get, nil) do |p|
114
+ assert_equal '/context-api/v2/projects/1/match/8', p
115
+ end
116
+ smartling.context_matches(project_id: 1, match_id: 8)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'smartling/files'
5
+
6
+ describe Smartling::Files do
7
+ let(:smartling) do
8
+ Class.new(Minitest::Mock) do
9
+ include Smartling::Files
10
+ end.new
11
+ end
12
+
13
+ after { smartling.verify }
14
+
15
+ describe 'files' do
16
+ it 'lists files the items of the smartling files result' do
17
+ smartling.expect(:get, nil, ['/files-api/v2/projects/1/files/list'])
18
+ smartling.files(project_id: 1)
19
+ end
20
+ end
21
+
22
+ describe 'file' do
23
+ it 'gets the /file/status endpoint with the right param' do
24
+ smartling.expect(:get, nil) do |path, query:|
25
+ assert_match %r{files-api/v2/projects/1/file/status$}, path
26
+ assert_equal({ fileUri: 'x' }, query)
27
+ end
28
+ smartling.file(project_id: 1, file_uri: 'x')
29
+ end
30
+ end
31
+
32
+ describe 'file with locale' do
33
+ it 'gets the /locale/x/file/status endpoint with the right params' do
34
+ smartling.expect(:get, nil) do |path, query:|
35
+ assert_match %r{files-api/v2/projects/1/locales/es/file/status$}, path
36
+ assert_equal({ fileUri: 'x' }, query)
37
+ end
38
+ smartling.file(project_id: 1, file_uri: 'x', locale: 'es')
39
+ end
40
+ end
41
+
42
+ describe 'delete_file' do
43
+ it 'posts the right body to the right endpoint' do
44
+ smartling.expect(:post, nil) do |path, body:|
45
+ assert_match %r{files-api/v2/projects/1/file/delete$}, path,
46
+ assert_equal({ fileUri: 'x' }, body)
47
+ end
48
+ smartling.delete_file(project_id: 1, file_uri: 'x')
49
+ end
50
+ end
51
+
52
+ describe 'upload_file' do
53
+ it 'posts the right body to the right endpoint' do
54
+ smartling.expect(:post, nil) do |path, body:|
55
+ assert_equal '/files-api/v2/projects/1/file', path
56
+ assert_equal 'x', body.fetch(:fileUri)
57
+ assert_equal 'json', body.fetch(:fileType)
58
+ assert_equal '"hello"', body.fetch(:file).read
59
+ assert_equal 'application/json', body.fetch(:file).content_type
60
+ assert_equal 'cb', body.fetch(:callback)
61
+ assert_equal %w[a b], body.fetch(:localeIdsToAuthorize)
62
+ assert_equal 'bar', body.fetch('smartling.foo')
63
+ assert_equal false, body.fetch(:authorize)
64
+ end
65
+ file = UploadIO.new(StringIO.new('"hello"'), 'application/json')
66
+ smartling.upload_file(project_id: 1, file: file, file_uri: 'x',
67
+ file_type: 'json', callback: 'cb',
68
+ locales_to_authorize: %w[a b],
69
+ smartling: { foo: 'bar' }, authorize: false)
70
+ end
71
+ it 'throws an exception if the file is not a file' do
72
+ assert_raises Smartling::Files::InvalidFile do
73
+ smartling.upload_file(project_id: 1, file: 'hi', file_uri: 'x',
74
+ file_type: 'bullshit')
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'smartling/strings'
5
+
6
+ describe Smartling::Strings do
7
+ let(:smartling) do
8
+ Class.new(Minitest::Mock) do
9
+ include Smartling::Strings
10
+ end.new
11
+ end
12
+
13
+ describe 'source_strings' do
14
+ it 'lists files the items of the smartling files result' do
15
+ smartling.expect(:get, nil) do |path, query:|
16
+ assert_equal path, '/strings-api/v2/projects/1/source-strings'
17
+ assert_equal 'x', query.fetch(:fileUri)
18
+ assert_equal ['a'], query.fetch(:stringHashcodes)
19
+ assert_equal 99, query.fetch(:limit)
20
+ assert_equal 2, query.fetch(:offset)
21
+ end
22
+ smartling.source_strings(project_id: 1, file_uri: 'x', hashcodes: ['a'],
23
+ limit: 99, offset: 2)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'smartling'
5
+
6
+ describe Smartling do
7
+ it 'can be "instantiated"' do
8
+ assert_instance_of Smartling::Client, Smartling.new
9
+ end
10
+
11
+ it 'handles things gracefully', :vcr do
12
+ client = Smartling::Client.new
13
+ assert_instance_of HipsterHash, client.files
14
+ assert_instance_of HipsterHash, client.translations(file_uri: 'boo')
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
4
+
5
+ require 'simplecov'
6
+ SimpleCov.start
7
+
8
+ require 'minitest/autorun'
9
+
10
+ require 'dotenv'
11
+ Dotenv.load '.env.local', '.env.test'
12
+
13
+ require 'vcr'
14
+ require 'cgi'
15
+ VCR.configure do |vcr|
16
+ vcr.hook_into :webmock
17
+ vcr.cassette_library_dir = 'test/cassettes'
18
+ vcr.filter_sensitive_data('SMARTLING_PROJECT_ID') { ENV.fetch('SMARTLING_PROJECT_ID') }
19
+ vcr.filter_sensitive_data('SMARTLING_USER_ID') { ENV.fetch('SMARTLING_USER_ID') }
20
+ vcr.filter_sensitive_data('SMARTLING_USER_SECRET') { ENV.fetch('SMARTLING_USER_SECRET') }
21
+ end
22
+
23
+ require 'minitest-vcr'
24
+ MinitestVcr::Spec.configure!
25
+
26
+ require 'pry'