lara-sdk 1.0.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f327f84f1775f706e868dcb2a843ce2074778a988e35a5b56949301df4b9fb9b
4
+ data.tar.gz: 8cc17e63bf6f79b0b85e273f2559873985339a1eab1948529a195fd59b164b47
5
+ SHA512:
6
+ metadata.gz: 2d962b4da02a66c126dfcd0740a5fd7894e125d9a97d0f016ac6dab3d41136b1025db02549b95c19a86a3a9253d0a154ab44b6171b8dce06a7a4a392f258b41b
7
+ data.tar.gz: 86f152fc3c6891561a9cc4d0f18696d86451cd130023bd1d186ac1cb261fed3b04837ff1c59096075af66f753800c1fc27c0ec7b878e03192c45549b85ad4ca7
data/lib/lara/audio.rb ADDED
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lara
4
+ class AudioTranslator
5
+ ALLOWED_AUDIO_PARAMS = %i[
6
+ id
7
+ status
8
+ target
9
+ filename
10
+ source
11
+ created_at
12
+ updated_at
13
+ options
14
+ translated_seconds
15
+ total_seconds
16
+ error_reason
17
+ ].freeze
18
+
19
+ def initialize(client, s3_client = S3Client.new)
20
+ @client = client
21
+ @s3 = s3_client
22
+ @polling_interval = 2
23
+ end
24
+
25
+ # Uploads an audio file to S3 and creates a translation job.
26
+ # @return [Lara::Models::Audio]
27
+ def upload(file_path:, filename:, target:, source: nil, adapt_to: nil, glossaries: nil,
28
+ no_trace: false, style: nil)
29
+ response_data = @client.get("/v2/audio/upload-url", params: { filename: filename })
30
+ url = response_data["url"]
31
+ fields = response_data["fields"]
32
+
33
+ @s3.upload(url: url, fields: fields, io: file_path)
34
+
35
+ body = {
36
+ s3key: fields["key"],
37
+ target: target,
38
+ source: source,
39
+ adapt_to: adapt_to,
40
+ glossaries: glossaries,
41
+ style: style
42
+ }.compact
43
+
44
+ headers = {}
45
+ headers["X-No-Trace"] = "true" if no_trace
46
+
47
+ response = @client.post("/v2/audio/translate", body: body, headers: headers)
48
+ response_params = response.transform_keys(&:to_sym)
49
+ Lara::Models::Audio.new(**filter_audio_params(response_params))
50
+ end
51
+
52
+ # Fetch audio translation status.
53
+ # @return [Lara::Models::Audio]
54
+ def status(id)
55
+ response = @client.get("/v2/audio/#{id}")
56
+ response_params = response.transform_keys(&:to_sym)
57
+ Lara::Models::Audio.new(**filter_audio_params(response_params))
58
+ end
59
+
60
+ # Download translated audio bytes.
61
+ # @return [String] bytes
62
+ def download(id)
63
+ url = @client.get("/v2/audio/#{id}/download-url")["url"]
64
+ @s3.download(url: url)
65
+ end
66
+
67
+ # Translates an audio file end-to-end
68
+ # @return [String] translated audio bytes
69
+ def translate(file_path:, filename:, target:, source: nil, adapt_to: nil, glossaries: nil,
70
+ no_trace: false, style: nil)
71
+ audio = upload(file_path: file_path, filename: filename, target: target, source: source,
72
+ adapt_to: adapt_to, glossaries: glossaries, no_trace: no_trace, style: style)
73
+
74
+ max_wait_time = 60 * 15 # 15 minutes
75
+ start = Time.now
76
+
77
+ loop do |_|
78
+ current = status(audio.id)
79
+
80
+ case current.status
81
+ when Lara::Models::AudioStatus::TRANSLATED
82
+ return download(current.id)
83
+ when Lara::Models::AudioStatus::ERROR
84
+ raise Lara::LaraApiError.new(500, "AudioError",
85
+ current.error_reason || "Unknown error")
86
+ end
87
+
88
+ raise Timeout::Error if Time.now - start > max_wait_time
89
+
90
+ sleep @polling_interval
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def filter_audio_params(params)
97
+ params.slice(*ALLOWED_AUDIO_PARAMS)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lara
4
+ # JWT authentication token for API access
5
+ class AuthToken
6
+ attr_reader :token, :refresh_token
7
+
8
+ def initialize(token, refresh_token)
9
+ @token = token
10
+ @refresh_token = refresh_token
11
+ end
12
+
13
+ def to_s
14
+ token
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+ require "json"
6
+ require "openssl"
7
+ require "base64"
8
+ require "digest"
9
+ require "uri"
10
+
11
+ module Lara
12
+ # This class is used to interact with Lara via the REST API.
13
+
14
+ class Client
15
+ DEFAULT_BASE_URL = "https://api.laratranslate.com"
16
+
17
+ def initialize(auth_method, base_url: DEFAULT_BASE_URL, connection_timeout: nil,
18
+ read_timeout: nil)
19
+ case auth_method
20
+ when Credentials
21
+ @credentials = auth_method
22
+ @auth_token = nil
23
+ when AuthToken
24
+ @credentials = nil
25
+ @auth_token = auth_method
26
+ else
27
+ raise ArgumentError, "auth_method must be Credentials or AuthToken"
28
+ end
29
+
30
+ @base_url = base_url.to_s.sub(%r{/+$}, "")
31
+ @connection_timeout = connection_timeout
32
+ @read_timeout = read_timeout
33
+ @extra_headers = {}
34
+ @auth_mutex = Mutex.new
35
+
36
+ @connection = build_connection
37
+ end
38
+
39
+ attr_reader :base_url
40
+
41
+ # Sets an extra header
42
+ # @param name [String] Header name
43
+ # @param value [String] Header value
44
+ def set_extra_header(name, value)
45
+ @extra_headers[name] = value
46
+ end
47
+
48
+ # Sends a GET request to the Lara API.
49
+ # @param path [String] The path to send the request to.
50
+ # @param params [Hash,nil] The parameters to send with the request.
51
+ # @param headers [Hash,nil] Additional headers to include in the request.
52
+ # @return [Hash, Array, String, nil] The JSON 'content' from the API or CSV body for csv responses.
53
+ def get(path, params: nil, headers: nil)
54
+ request(:get, path, body: nil, headers: headers, params: params)
55
+ end
56
+
57
+ # Sends a POST request to the Lara API.
58
+ # @param path [String] The path to send the request to.
59
+ # @param body [Hash,nil] The parameters to send with the request.
60
+ # @param files [Hash,nil] The files to send with the request. If present, request will be sent as multipart/form-data.
61
+ # @param headers [Hash,nil] Additional headers to include in the request.
62
+ # @param raw_response [Boolean] If true, returns the raw response body (useful for binary data like images).
63
+ # @yield [Hash] Each partial JSON result from the stream (if streaming)
64
+ # @return [Hash, Array, String, nil] The JSON 'content' from the API, CSV body, or raw bytes.
65
+ def post(path, body: nil, files: nil, headers: nil, raw_response: false, &callback)
66
+ request(:post, path, body: body, files: files, headers: headers, raw_response: raw_response, &callback)
67
+ end
68
+
69
+ # Sends a PUT request to the Lara API.
70
+ # @param path [String] The path to send the request to.
71
+ # @param body [Hash,nil] The parameters to send with the request.
72
+ # @param files [Hash,nil] The files to send with the request. If present, request will be sent as multipart/form-data.
73
+ # @param headers [Hash,nil] Additional headers to include in the request.
74
+ # @return [Hash, Array, String, nil] The JSON 'content' from the API or CSV body for csv responses.
75
+ def put(path, body: nil, files: nil, headers: nil)
76
+ request(:put, path, body: body, files: files, headers: headers)
77
+ end
78
+
79
+ # Sends a DELETE request to the Lara API.
80
+ # @param path [String] The path to send the request to.
81
+ # @param params [Hash,nil] The parameters to send with the request.
82
+ # @param headers [Hash,nil] Additional headers to include in the request.
83
+ # @return [Hash, Array, String, nil] The JSON 'content' from the API or CSV body for csv responses.
84
+ def delete(path, params: nil, body: nil, headers: nil)
85
+ request(:delete, path, body: body, headers: headers, params: params)
86
+ end
87
+
88
+ private
89
+
90
+ def request(method, path, body: nil, files: nil, headers: nil, params: nil, raw_response: false, &callback)
91
+ ensure_valid_token
92
+
93
+ make_request(method, path, body: body, files: files, headers: headers, params: params,
94
+ raw_response: raw_response, &callback)
95
+ rescue LaraApiError => e
96
+ # Auto-refresh token on 401 with jwt expired
97
+ raise unless e.status_code == 401 && e.message.include?("jwt expired")
98
+
99
+ refresh_token
100
+ # Retry once with new token
101
+ make_request(method, path, body: body, files: files, headers: headers, params: params,
102
+ raw_response: raw_response, &callback)
103
+ end
104
+
105
+ def ensure_valid_token
106
+ @auth_mutex.synchronize do
107
+ return if @auth_token
108
+
109
+ raise LaraError, "No authentication method available" unless @credentials
110
+
111
+ @auth_token = authenticate
112
+ end
113
+ end
114
+
115
+ def authenticate
116
+ path = "/v2/auth"
117
+ method = "POST"
118
+ timestamp = Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT")
119
+
120
+ body = { id: @credentials&.access_key_id }
121
+ body_json = body.to_json
122
+ content_md5 = Digest::MD5.hexdigest(body_json)
123
+
124
+ headers = {
125
+ "Date" => timestamp,
126
+ "X-Lara-SDK-Name" => "lara-ruby",
127
+ "X-Lara-SDK-Version" => Lara::VERSION,
128
+ "Content-Type" => "application/json",
129
+ "Content-MD5" => content_md5,
130
+ "Authorization" => "Lara:#{generate_hmac_signature(method, path, content_md5,
131
+ 'application/json', timestamp)}"
132
+ }
133
+
134
+ conn = Faraday.new(url: @base_url) do |c|
135
+ c.adapter Faraday.default_adapter
136
+ end
137
+
138
+ response = conn.post(path) do |req|
139
+ req.headers = headers
140
+ req.body = body_json
141
+ end
142
+
143
+ raise LaraApiError.from_response(response) unless response.success?
144
+
145
+ data = JSON.parse(response.body)
146
+ refresh_token = response.headers["x-lara-refresh-token"]
147
+
148
+ raise LaraError, "Missing refresh token in authentication response" unless refresh_token
149
+
150
+ AuthToken.new(data["token"], refresh_token)
151
+ end
152
+
153
+ def refresh_token
154
+ @auth_mutex.synchronize do
155
+ return unless @auth_token
156
+
157
+ conn = Faraday.new(url: @base_url) do |c|
158
+ c.adapter Faraday.default_adapter
159
+ end
160
+
161
+ response = conn.post("/v2/auth/refresh") do |req|
162
+ req.headers = {
163
+ "Date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
164
+ "X-Lara-SDK-Name" => "lara-ruby",
165
+ "X-Lara-SDK-Version" => Lara::VERSION,
166
+ "Authorization" => "Bearer #{@auth_token&.refresh_token}"
167
+ }
168
+ end
169
+
170
+ if response.success?
171
+ data = JSON.parse(response.body)
172
+ refresh_token = response.headers["x-lara-refresh-token"]
173
+
174
+ if refresh_token
175
+ @auth_token = AuthToken.new(data["token"], refresh_token)
176
+ else
177
+ # Refresh failed, force re-authentication
178
+ @auth_token = nil
179
+ ensure_valid_token
180
+ end
181
+ else
182
+ # Refresh failed, force re-authentication
183
+ @auth_token = nil
184
+ ensure_valid_token
185
+ end
186
+ end
187
+ end
188
+
189
+ def make_request(method, path, body: nil, files: nil, headers: nil, params: nil, raw_response: false, &callback)
190
+ path = "/#{path}" unless path.start_with?("/")
191
+ request_headers = build_request_headers(body, files, headers)
192
+
193
+ # Make the API call
194
+ response = if files&.any?
195
+ @connection.post(path) do |req|
196
+ req.headers.merge!(request_headers)
197
+ req.params = params if params
198
+ req.body = body.is_a?(Hash) ? body.dup : {}
199
+ files.each { |key, value| req.body[key] = value }
200
+ end
201
+ else
202
+ @connection.send(method, path) do |req|
203
+ req.headers.merge!(request_headers)
204
+ req.params = params if params
205
+ req.body = body.to_json if body.is_a?(Hash) && !body.empty?
206
+ end
207
+ end
208
+
209
+ raise LaraApiError.from_response(response) unless response.success?
210
+
211
+ return response.body if raw_response
212
+
213
+ content_type = response.headers["content-type"] || response.headers["Content-Type"]
214
+ return response.body if content_type&.include?("text/csv")
215
+
216
+ if callback || (body && body[:reasoning])
217
+ parse_stream_response(response.body, &callback)
218
+ else
219
+ parse_json(response.body)
220
+ end
221
+ end
222
+
223
+ def parse_json(body)
224
+ parsed = body.nil? || body.empty? ? {} : JSON.parse(body)
225
+ parsed.is_a?(Hash) && parsed.key?("content") ? parsed["content"] : parsed
226
+ end
227
+
228
+ def parse_stream_response(body, &block)
229
+ return {} if body.nil? || body.empty?
230
+
231
+ buffer = ""
232
+ last_result = nil
233
+
234
+ body.each_line do |line|
235
+ buffer += line
236
+ next unless line.end_with?("\n")
237
+
238
+ trimmed_line = buffer.strip
239
+ buffer = ""
240
+
241
+ next if trimmed_line.empty?
242
+
243
+ begin
244
+ parsed = JSON.parse(trimmed_line)
245
+ result = parsed["content"] || parsed
246
+ block.call(result) if block
247
+ last_result = result
248
+ rescue JSON::ParserError
249
+ next
250
+ end
251
+ end
252
+
253
+ if !buffer.empty? && buffer.strip != ""
254
+ begin
255
+ parsed = JSON.parse(buffer.strip)
256
+ result = parsed["content"] || parsed
257
+ block.call(result) if block
258
+ last_result = result
259
+ rescue JSON::ParserError
260
+ end
261
+ end
262
+
263
+ last_result || {}
264
+ end
265
+
266
+ def build_request_headers(body, files, extra_headers)
267
+ timestamp = Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT")
268
+
269
+ headers = {
270
+ "Date" => timestamp,
271
+ "X-Lara-SDK-Name" => "lara-ruby",
272
+ "X-Lara-SDK-Version" => Lara::VERSION,
273
+ "Authorization" => "Bearer #{@auth_token&.token}",
274
+ **@extra_headers
275
+ }
276
+
277
+ headers.merge!(extra_headers) if extra_headers
278
+
279
+ if !files&.any? && body.is_a?(Hash) && !body.empty?
280
+ headers["Content-Type"] = "application/json"
281
+ end
282
+
283
+ headers
284
+ end
285
+
286
+ def build_connection
287
+ Faraday.new(url: @base_url) do |conn|
288
+ conn.request :multipart
289
+ conn.request :url_encoded
290
+ conn.adapter Faraday.default_adapter
291
+ conn.options.timeout = @connection_timeout if @connection_timeout
292
+ conn.options.open_timeout = @read_timeout if @read_timeout
293
+ end
294
+ end
295
+
296
+ def generate_hmac_signature(method, path, content_md5, content_type, timestamp)
297
+ string_to_sign = [
298
+ method.to_s.upcase,
299
+ path,
300
+ content_md5,
301
+ content_type,
302
+ timestamp
303
+ ].join("\n")
304
+
305
+ digest = OpenSSL::HMAC.digest("sha256", @credentials&.access_key_secret || "", string_to_sign)
306
+ Base64.strict_encode64(digest)
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lara
4
+ # Credentials for accessing the Lara API. A credentials object has two properties:
5
+ # - access_key_id: The access key ID.
6
+ # - access_key_secret: The access key secret.
7
+
8
+ # IMPORTANT: Do not hard-code your access key ID and secret in your code. Always use environment variables or
9
+ # a credentials file. Please note also that the access key secret is never sent directly via HTTP, but it is used to
10
+ # sign the request. If you suspect that your access key secret has been compromised, you can revoke it in the Lara
11
+ # dashboard.
12
+ class Credentials
13
+ # @!attribute [r] access_key_id
14
+ # @return [String] The access key ID.
15
+ # @!attribute [r] access_key_secret
16
+ # @return [String] The access key secret.
17
+ attr_reader :access_key_id, :access_key_secret
18
+
19
+ # @param access_key_id [String] The access key ID.
20
+ # @param access_key_secret [String] The access key secret.
21
+ def initialize(access_key_id, access_key_secret)
22
+ @access_key_id = access_key_id
23
+ @access_key_secret = access_key_secret
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lara
4
+ class Documents
5
+ ALLOWED_DOCUMENT_PARAMS = %i[
6
+ id
7
+ status
8
+ target
9
+ filename
10
+ source
11
+ created_at
12
+ updated_at
13
+ options
14
+ translated_chars
15
+ total_chars
16
+ error_reason
17
+ ].freeze
18
+
19
+ def initialize(client, s3_client = S3Client.new)
20
+ @client = client
21
+ @s3 = s3_client
22
+ @polling_interval = 2
23
+ end
24
+
25
+ # Uploads a file to S3
26
+ # @return [Lara::Models::Document]
27
+ def upload(file_path:, filename:, target:, source: nil, adapt_to: nil, glossaries: nil,
28
+ no_trace: false, style: nil, password: nil, extraction_params: nil)
29
+ response_data = @client.get("/v2/documents/upload-url", params: { filename: filename })
30
+ url = response_data["url"]
31
+ fields = response_data["fields"]
32
+
33
+ @s3.upload(url: url, fields: fields, io: file_path)
34
+
35
+ body = {
36
+ s3key: fields["key"],
37
+ target: target,
38
+ source: source,
39
+ adapt_to: adapt_to,
40
+ glossaries: glossaries,
41
+ style: style,
42
+ password: password,
43
+ extraction_params: extraction_params&.to_h
44
+ }.compact
45
+
46
+ headers = {}
47
+ headers["X-No-Trace"] = "true" if no_trace
48
+
49
+ response = @client.post("/v2/documents", body: body, headers: headers)
50
+ response_params = response.transform_keys(&:to_sym)
51
+ Lara::Models::Document.new(**filter_document_params(response_params))
52
+ end
53
+
54
+ # Fetch document status
55
+ # @return [Lara::Models::Document]
56
+ def status(id)
57
+ response = @client.get("/v2/documents/#{id}")
58
+ response_params = response.transform_keys(&:to_sym)
59
+ Lara::Models::Document.new(**filter_document_params(response_params))
60
+ end
61
+
62
+ # Download translated document bytes
63
+ # @return [String] bytes
64
+ def download(id, output_format: nil)
65
+ params = {}
66
+ params[:output_format] = output_format if output_format
67
+ url = @client.get("/v2/documents/#{id}/download-url", params: params)["url"]
68
+ @s3.download(url: url)
69
+ end
70
+
71
+ # Translates a document end-to-end
72
+ # @return [String] translated file bytes
73
+ def translate(file_path:, filename:, target:, source: nil, adapt_to: nil, glossaries: nil, output_format: nil,
74
+ no_trace: false, style: nil, password: nil, extraction_params: nil)
75
+ document = upload(file_path: file_path, filename: filename, target: target, source: source,
76
+ adapt_to: adapt_to, glossaries: glossaries, no_trace: no_trace, style: style, password: password,
77
+ extraction_params: extraction_params)
78
+
79
+ max_wait_time = 60 * 15 # 15 minutes
80
+ start = Time.now
81
+
82
+ loop do |_|
83
+ current = status(document.id)
84
+
85
+ case current.status
86
+ when Lara::Models::DocumentStatus::TRANSLATED
87
+ return download(current.id, output_format: output_format)
88
+ when Lara::Models::DocumentStatus::ERROR
89
+ raise Lara::LaraApiError.new(500, "DocumentError",
90
+ current.error_reason || "Unknown error")
91
+ end
92
+
93
+ raise Timeout::Error if Time.now - start > max_wait_time
94
+
95
+ sleep @polling_interval
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def filter_document_params(params)
102
+ params.slice(*ALLOWED_DOCUMENT_PARAMS)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lara
6
+ # Base SDK error.
7
+ class LaraError < StandardError
8
+ attr_reader :status_code
9
+
10
+ def initialize(message, status_code = nil)
11
+ super(message)
12
+ @status_code = status_code
13
+ end
14
+ end
15
+
16
+ # API error with HTTP status, type and message.
17
+ class LaraApiError < LaraError
18
+ attr_reader :type
19
+
20
+ # Builds an error from an HTTP response with JSON body:
21
+ # { "error": { "type": "...", "message": "..." } }
22
+ def self.from_response(response)
23
+ data = begin
24
+ JSON.parse(response.body)
25
+ rescue StandardError
26
+ {}
27
+ end
28
+ error = data["error"] || {}
29
+ error_type = error["type"] || "UnknownError"
30
+ error_message = error["message"] || "An unknown error occurred"
31
+ new(response.status, error_type, error_message)
32
+ end
33
+
34
+ # @param status_code [Integer]
35
+ # @param type [String]
36
+ # @param message [String]
37
+ def initialize(status_code, type, message)
38
+ super("(HTTP #{status_code}) #{type}: #{message}", status_code)
39
+ @type = type
40
+ @message = message
41
+ end
42
+ end
43
+ end