deepl-rb 3.9.0 → 3.10.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.
Files changed (237) hide show
  1. checksums.yaml +4 -4
  2. data/.gitlab-ci.yml +70 -52
  3. data/.rubocop.yml +26 -0
  4. data/CHANGELOG.md +429 -18
  5. data/Gemfile +4 -0
  6. data/README.md +981 -223
  7. data/Rakefile +18 -5
  8. data/VERSION +1 -1
  9. data/deepl-rb.gemspec +6 -6
  10. data/lib/deepl/admin_client.rb +183 -0
  11. data/lib/deepl/api.rb +30 -6
  12. data/lib/deepl/app_info.rb +20 -0
  13. data/lib/deepl/client.rb +969 -0
  14. data/lib/deepl/configuration.rb +56 -10
  15. data/lib/deepl/constants/admin_usage_group_by.rb +13 -0
  16. data/lib/deepl/constants/custom_tag_usage_aggregate_by.rb +13 -0
  17. data/lib/deepl/constants/document_status_code.rb +17 -0
  18. data/lib/deepl/constants/language_include.rb +17 -0
  19. data/lib/deepl/constants/language_resource_name.rb +21 -0
  20. data/lib/deepl/constants/tag_handling_version.rb +14 -0
  21. data/lib/deepl/deprecation.rb +42 -0
  22. data/lib/deepl/document_api.rb +41 -22
  23. data/lib/deepl/exceptions/bad_request.rb +1 -3
  24. data/lib/deepl/exceptions/connection_error.rb +37 -0
  25. data/lib/deepl/exceptions/document_not_ready_error.rb +17 -0
  26. data/lib/deepl/exceptions/document_translation_error.rb +17 -1
  27. data/lib/deepl/exceptions/error.rb +13 -1
  28. data/lib/deepl/exceptions/not_found.rb +8 -3
  29. data/lib/deepl/exceptions/not_supported.rb +6 -0
  30. data/lib/deepl/exceptions/request_error.rb +19 -2
  31. data/lib/deepl/exceptions/server_error.rb +7 -1
  32. data/lib/deepl/exceptions/validation_error.rb +14 -0
  33. data/lib/deepl/requests/admin/base.rb +94 -0
  34. data/lib/deepl/requests/admin/create_developer_key.rb +38 -0
  35. data/lib/deepl/requests/admin/custom_tag_usage_analytics.rb +38 -0
  36. data/lib/deepl/requests/admin/list_developer_keys.rb +28 -0
  37. data/lib/deepl/requests/admin/update_developer_key.rb +35 -0
  38. data/lib/deepl/requests/admin/usage_analytics.rb +36 -0
  39. data/lib/deepl/requests/base.rb +229 -59
  40. data/lib/deepl/requests/correct.rb +57 -0
  41. data/lib/deepl/requests/document/download.rb +11 -3
  42. data/lib/deepl/requests/document/get_status.rb +2 -2
  43. data/lib/deepl/requests/document/upload.rb +43 -10
  44. data/lib/deepl/requests/glossary/create.rb +6 -2
  45. data/lib/deepl/requests/glossary/language_pairs.rb +20 -4
  46. data/lib/deepl/requests/language_resources.rb +41 -0
  47. data/lib/deepl/requests/languages.rb +28 -4
  48. data/lib/deepl/requests/languages_for_resource.rb +54 -0
  49. data/lib/deepl/requests/multilingual_glossary/create.rb +37 -0
  50. data/lib/deepl/requests/multilingual_glossary/destroy.rb +36 -0
  51. data/lib/deepl/requests/multilingual_glossary/destroy_dictionary.rb +51 -0
  52. data/lib/deepl/requests/multilingual_glossary/entries.rb +66 -0
  53. data/lib/deepl/requests/multilingual_glossary/find.rb +35 -0
  54. data/lib/deepl/requests/multilingual_glossary/helpers.rb +63 -0
  55. data/lib/deepl/requests/multilingual_glossary/list.rb +39 -0
  56. data/lib/deepl/requests/multilingual_glossary/replace_dictionary.rb +41 -0
  57. data/lib/deepl/requests/multilingual_glossary/update.rb +40 -0
  58. data/lib/deepl/requests/rephrase.rb +5 -9
  59. data/lib/deepl/requests/spoken_terms/create.rb +37 -0
  60. data/lib/deepl/requests/spoken_terms/destroy.rb +36 -0
  61. data/lib/deepl/requests/spoken_terms/destroy_term_list.rb +41 -0
  62. data/lib/deepl/requests/spoken_terms/entries.rb +46 -0
  63. data/lib/deepl/requests/spoken_terms/find.rb +35 -0
  64. data/lib/deepl/requests/spoken_terms/helpers.rb +67 -0
  65. data/lib/deepl/requests/spoken_terms/list.rb +35 -0
  66. data/lib/deepl/requests/spoken_terms/replace_term_list.rb +41 -0
  67. data/lib/deepl/requests/spoken_terms/update.rb +40 -0
  68. data/lib/deepl/requests/style_profile/base.rb +83 -0
  69. data/lib/deepl/requests/style_profile/create.rb +35 -0
  70. data/lib/deepl/requests/style_profile/destroy.rb +34 -0
  71. data/lib/deepl/requests/style_profile/find.rb +33 -0
  72. data/lib/deepl/requests/style_profile/list.rb +43 -0
  73. data/lib/deepl/requests/style_profile/update.rb +35 -0
  74. data/lib/deepl/requests/style_rule/create.rb +1 -1
  75. data/lib/deepl/requests/style_rule/create_custom_instruction.rb +4 -2
  76. data/lib/deepl/requests/style_rule/destroy.rb +4 -2
  77. data/lib/deepl/requests/style_rule/destroy_custom_instruction.rb +5 -3
  78. data/lib/deepl/requests/style_rule/find.rb +4 -2
  79. data/lib/deepl/requests/style_rule/find_custom_instruction.rb +5 -3
  80. data/lib/deepl/requests/style_rule/helpers.rb +35 -0
  81. data/lib/deepl/requests/style_rule/list.rb +1 -1
  82. data/lib/deepl/requests/style_rule/update.rb +4 -2
  83. data/lib/deepl/requests/style_rule/update_configured_rules.rb +4 -2
  84. data/lib/deepl/requests/style_rule/update_custom_instruction.rb +5 -3
  85. data/lib/deepl/requests/translate.rb +52 -20
  86. data/lib/deepl/requests/translation_memory/base.rb +1 -4
  87. data/lib/deepl/requests/translation_memory/create_export.rb +1 -1
  88. data/lib/deepl/requests/translation_memory/create_import.rb +1 -1
  89. data/lib/deepl/requests/translation_memory/destroy.rb +1 -1
  90. data/lib/deepl/requests/translation_memory/download_export.rb +4 -0
  91. data/lib/deepl/requests/translation_memory/find.rb +1 -1
  92. data/lib/deepl/requests/translation_memory/find_job.rb +1 -1
  93. data/lib/deepl/requests/translation_memory/list.rb +2 -1
  94. data/lib/deepl/requests/translation_memory/segments.rb +3 -2
  95. data/lib/deepl/requests/translation_memory/storage_base.rb +40 -8
  96. data/lib/deepl/requests/translation_memory/upload_file.rb +1 -1
  97. data/lib/deepl/requests/usage.rb +28 -1
  98. data/lib/deepl/requests/write_improvements.rb +35 -0
  99. data/lib/deepl/resources/admin_usage_report.rb +73 -0
  100. data/lib/deepl/resources/custom_tag_usage_report.rb +59 -0
  101. data/lib/deepl/resources/developer_key.rb +57 -0
  102. data/lib/deepl/resources/document_handle.rb +24 -25
  103. data/lib/deepl/resources/document_translation_status.rb +23 -4
  104. data/lib/deepl/resources/language.rb +10 -2
  105. data/lib/deepl/resources/language_resource.rb +57 -0
  106. data/lib/deepl/resources/language_support.rb +56 -0
  107. data/lib/deepl/resources/multilingual_glossary.rb +112 -0
  108. data/lib/deepl/resources/spoken_terms.rb +61 -0
  109. data/lib/deepl/resources/style_profile.rb +44 -0
  110. data/lib/deepl/resources/style_rule.rb +3 -0
  111. data/lib/deepl/resources/text.rb +18 -2
  112. data/lib/deepl/resources/translation_memory.rb +8 -2
  113. data/lib/deepl/resources/translation_memory_import.rb +1 -1
  114. data/lib/deepl/resources/translation_memory_job.rb +23 -3
  115. data/lib/deepl/resources/translation_memory_segments.rb +8 -6
  116. data/lib/deepl/resources/usage.rb +93 -4
  117. data/lib/deepl/style_rule_api.rb +11 -0
  118. data/lib/deepl/translation_memory_api.rb +104 -24
  119. data/lib/deepl/utils/client_builder.rb +54 -0
  120. data/lib/deepl/utils/document_poller.rb +70 -0
  121. data/lib/deepl/utils/exception_builder.rb +3 -2
  122. data/lib/deepl/utils/http_client_builder.rb +79 -0
  123. data/lib/deepl/utils/number_parser.rb +34 -0
  124. data/lib/deepl/utils/term_validator.rb +33 -0
  125. data/lib/deepl/utils/time_parser.rb +14 -0
  126. data/lib/deepl.rb +166 -41
  127. data/lib/http_client_options.rb +21 -1
  128. data/lib/version.rb +1 -1
  129. data/spec/fixtures.rb +33 -0
  130. data/spec/offline/admin_client_spec.rb +551 -0
  131. data/spec/offline/client_spec.rb +273 -0
  132. data/spec/{api → offline}/configuration_spec.rb +45 -3
  133. data/spec/{constants → offline}/constants_spec.rb +23 -1
  134. data/spec/offline/correct_text_spec.rb +50 -0
  135. data/spec/offline/deprecation_spec.rb +32 -0
  136. data/spec/offline/errors_spec.rb +92 -0
  137. data/spec/offline/glossaries_spec.rb +98 -0
  138. data/spec/offline/http_client_spec.rb +319 -0
  139. data/spec/offline/languages_spec.rb +155 -0
  140. data/spec/offline/module_methods_spec.rb +13 -0
  141. data/spec/offline/multilingual_glossaries_spec.rb +358 -0
  142. data/spec/offline/offline_helper.rb +71 -0
  143. data/spec/offline/rephrase_text_spec.rb +73 -0
  144. data/spec/offline/spoken_terms_spec.rb +215 -0
  145. data/spec/offline/style_profiles_spec.rb +266 -0
  146. data/spec/offline/style_rules_spec.rb +318 -0
  147. data/spec/offline/translate_document_spec.rb +606 -0
  148. data/spec/offline/translate_text_spec.rb +286 -0
  149. data/spec/offline/translation_memories_client_spec.rb +256 -0
  150. data/spec/offline/translation_memories_spec.rb +463 -0
  151. data/spec/offline/usage_spec.rb +151 -0
  152. data/spec/real_api/admin_client_spec.rb +77 -0
  153. data/spec/real_api/correct_text_spec.rb +20 -0
  154. data/spec/real_api/glossaries_spec.rb +28 -0
  155. data/spec/real_api/languages_spec.rb +27 -0
  156. data/spec/real_api/multilingual_glossaries_spec.rb +53 -0
  157. data/spec/real_api/proxy_spec.rb +41 -0
  158. data/spec/real_api/real_api_helper.rb +65 -0
  159. data/spec/real_api/rephrase_text_spec.rb +16 -0
  160. data/spec/real_api/spoken_terms_spec.rb +56 -0
  161. data/spec/real_api/style_profiles_spec.rb +46 -0
  162. data/spec/real_api/style_rules_spec.rb +46 -0
  163. data/spec/real_api/translate_document_spec.rb +18 -0
  164. data/spec/real_api/translate_text_spec.rb +16 -0
  165. data/spec/real_api/translation_memories_spec.rb +56 -0
  166. data/spec/real_api/usage_spec.rb +20 -0
  167. data/spec/spec_helper.rb +1 -50
  168. metadata +116 -76
  169. data/.circleci/config.yml +0 -27
  170. data/.github/workflows/add_issues_to_kanban.yml +0 -16
  171. data/spec/api/api_spec.rb +0 -20
  172. data/spec/api/deepl_spec.rb +0 -262
  173. data/spec/integration_tests/document_api_spec.rb +0 -211
  174. data/spec/integration_tests/document_error_paths_spec.rb +0 -33
  175. data/spec/integration_tests/glossary_api_spec.rb +0 -114
  176. data/spec/integration_tests/glossary_error_paths_spec.rb +0 -107
  177. data/spec/integration_tests/integration_test_utils.rb +0 -178
  178. data/spec/integration_tests/languages_api_spec.rb +0 -54
  179. data/spec/integration_tests/languages_error_paths_spec.rb +0 -25
  180. data/spec/integration_tests/rephrase_api_spec.rb +0 -90
  181. data/spec/integration_tests/rephrase_error_paths_spec.rb +0 -53
  182. data/spec/integration_tests/smoke_test_spec.rb +0 -24
  183. data/spec/integration_tests/style_rule_api_spec.rb +0 -117
  184. data/spec/integration_tests/style_rule_error_paths_spec.rb +0 -45
  185. data/spec/integration_tests/translate_api_spec.rb +0 -117
  186. data/spec/integration_tests/translate_error_paths_spec.rb +0 -48
  187. data/spec/integration_tests/translation_memory_api_spec.rb +0 -244
  188. data/spec/integration_tests/translation_memory_error_paths_spec.rb +0 -87
  189. data/spec/integration_tests/usage_api_spec.rb +0 -29
  190. data/spec/integration_tests/usage_error_paths_spec.rb +0 -18
  191. data/spec/requests/document/upload_spec.rb +0 -130
  192. data/spec/requests/extra_body_parameters_types_spec.rb +0 -82
  193. data/spec/requests/glossary/create_spec.rb +0 -44
  194. data/spec/requests/glossary/destroy_spec.rb +0 -27
  195. data/spec/requests/glossary/entries_spec.rb +0 -27
  196. data/spec/requests/glossary/find_spec.rb +0 -28
  197. data/spec/requests/glossary/language_pairs_spec.rb +0 -27
  198. data/spec/requests/glossary/list_spec.rb +0 -27
  199. data/spec/requests/languages_spec.rb +0 -27
  200. data/spec/requests/rephrase_spec.rb +0 -46
  201. data/spec/requests/style_rule/create_custom_instruction_spec.rb +0 -30
  202. data/spec/requests/style_rule/create_spec.rb +0 -29
  203. data/spec/requests/style_rule/destroy_custom_instruction_spec.rb +0 -28
  204. data/spec/requests/style_rule/destroy_spec.rb +0 -27
  205. data/spec/requests/style_rule/find_custom_instruction_spec.rb +0 -29
  206. data/spec/requests/style_rule/find_spec.rb +0 -28
  207. data/spec/requests/style_rule/list_spec.rb +0 -27
  208. data/spec/requests/style_rule/update_configured_rules_spec.rb +0 -31
  209. data/spec/requests/style_rule/update_custom_instruction_spec.rb +0 -32
  210. data/spec/requests/style_rule/update_spec.rb +0 -29
  211. data/spec/requests/translate_spec.rb +0 -338
  212. data/spec/requests/translation_memory/create_export_spec.rb +0 -37
  213. data/spec/requests/translation_memory/create_import_spec.rb +0 -61
  214. data/spec/requests/translation_memory/destroy_spec.rb +0 -36
  215. data/spec/requests/translation_memory/download_export_spec.rb +0 -54
  216. data/spec/requests/translation_memory/find_job_spec.rb +0 -37
  217. data/spec/requests/translation_memory/find_spec.rb +0 -36
  218. data/spec/requests/translation_memory/list_spec.rb +0 -27
  219. data/spec/requests/translation_memory/segments_spec.rb +0 -58
  220. data/spec/requests/translation_memory/upload_file_spec.rb +0 -55
  221. data/spec/requests/usage_spec.rb +0 -27
  222. data/spec/resources/custom_instruction_spec.rb +0 -32
  223. data/spec/resources/glossary_spec.rb +0 -38
  224. data/spec/resources/language_pair_spec.rb +0 -23
  225. data/spec/resources/language_spec.rb +0 -45
  226. data/spec/resources/style_rule_spec.rb +0 -68
  227. data/spec/resources/text_spec.rb +0 -23
  228. data/spec/resources/translation_memory_export_spec.rb +0 -38
  229. data/spec/resources/translation_memory_import_spec.rb +0 -30
  230. data/spec/resources/translation_memory_job_spec.rb +0 -109
  231. data/spec/resources/translation_memory_segments_spec.rb +0 -78
  232. data/spec/resources/translation_memory_spec.rb +0 -52
  233. data/spec/resources/usage_spec.rb +0 -35
  234. data/spec/support/live_mock_server.rb +0 -12
  235. data/spec/support/managed_glossary.rb +0 -65
  236. data/spec/support/managed_style_rule.rb +0 -17
  237. data/spec/support/managed_translation_memory.rb +0 -55
@@ -0,0 +1,273 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE file.
4
+ # frozen_string_literal: true
5
+
6
+ require_relative 'offline_helper'
7
+
8
+ describe DeepL::Client do
9
+ # The operations are added to DeepL::Client separately; until then the client's connection is
10
+ # exercised with the usage request.
11
+ def usage(client)
12
+ DeepL::Requests::Usage.new(client.send(:api)).request
13
+ end
14
+
15
+ def stub_usage(base = OfflineHelper::HOST, headers: {})
16
+ stub = stub_request(:get, "#{base}/v2/usage")
17
+ stub = stub.with(headers: headers) unless headers.empty?
18
+ stub.to_return(usage_reply)
19
+ end
20
+
21
+ describe '#initialize' do
22
+ it 'sends the trimmed auth key to the Pro server' do
23
+ stub_usage(headers: { 'Authorization' => 'DeepL-Auth-Key abc' })
24
+
25
+ usage(described_class.new(" abc \n"))
26
+ end
27
+
28
+ it 'sends a Free key to the Free server' do
29
+ stub_usage('https://api-free.deepl.com',
30
+ headers: { 'Authorization' => 'DeepL-Auth-Key a:fx' })
31
+
32
+ usage(described_class.new('a:fx'))
33
+ end
34
+
35
+ it 'keeps the path prefix of the server URL' do
36
+ stub_usage('http://localhost:3000/proxy')
37
+
38
+ usage(described_class.new('abc', server_url: 'http://localhost:3000/proxy'))
39
+ end
40
+
41
+ it 'reads no environment variables' do
42
+ env = ENV.to_h.slice('DEEPL_AUTH_KEY', 'DEEPL_SERVER_URL')
43
+ ENV['DEEPL_SERVER_URL'] = 'http://environment.example.test'
44
+ stub_usage(headers: { 'Authorization' => 'DeepL-Auth-Key abc' })
45
+
46
+ usage(described_class.new('abc'))
47
+ ensure
48
+ ENV.delete('DEEPL_SERVER_URL')
49
+ ENV.update(env)
50
+ end
51
+
52
+ it 'rejects an empty or blank auth key' do
53
+ ['', " \t", nil].each do |key|
54
+ expect { described_class.new(key) }.to raise_error(ArgumentError, /auth_key/)
55
+ end
56
+ end
57
+
58
+ it 'does not show the auth key in inspect' do
59
+ expect(described_class.new('secret-key').inspect).not_to include('secret-key')
60
+ end
61
+
62
+ it 'does not expose its connection or configuration' do
63
+ client = described_class.new('abc')
64
+
65
+ expect(client).not_to respond_to(:api)
66
+ expect(client).not_to respond_to(:configuration)
67
+ end
68
+ end
69
+
70
+ describe 'headers' do
71
+ it 'lets the headers replace the Authorization header' do
72
+ stub_usage(headers: { 'Authorization' => 'DeepL-Auth-Key other', 'X-Custom' => 'v' })
73
+
74
+ usage(described_class.new('abc', headers: { 'X-Custom' => 'v',
75
+ 'authorization' => 'DeepL-Auth-Key other' }))
76
+ end
77
+
78
+ it 'replaces the User-Agent with the one in headers' do
79
+ stub_usage(headers: { 'User-Agent' => 'my-agent/1' })
80
+
81
+ usage(described_class.new('abc', headers: { 'user-agent' => 'my-agent/1' }))
82
+ end
83
+
84
+ it 'adds the app info to the User-Agent' do
85
+ stub_usage(headers: { 'User-Agent' => %r{\Adeepl-ruby/[^ ]+ my-app/1\.2\z} })
86
+
87
+ usage(described_class.new('abc', send_platform_info: false,
88
+ app_info: { app_name: 'my-app', app_version: '1.2' }))
89
+ end
90
+
91
+ it 'never mixes the options of two clients' do
92
+ stub_usage(headers: { 'Authorization' => 'DeepL-Auth-Key one', 'X-Client' => '1' })
93
+ stub_usage('http://localhost:3000',
94
+ headers: { 'Authorization' => 'DeepL-Auth-Key two', 'X-Client' => '2' })
95
+
96
+ usage(described_class.new('one', headers: { 'X-Client' => '1' }))
97
+ usage(described_class.new('two', server_url: 'http://localhost:3000',
98
+ headers: { 'X-Client' => '2' }))
99
+ end
100
+ end
101
+
102
+ describe 'max_retries' do
103
+ it 'retries as often as configured' do
104
+ stub = stub_request(:get, url('/v2/usage')).to_return(reply({}, 429), usage_reply)
105
+
106
+ usage(described_class.new('abc', max_retries: 1))
107
+ expect(stub).to have_been_requested.twice
108
+ end
109
+
110
+ it 'does not retry with max_retries 0' do
111
+ stub = stub_request(:get, url('/v2/usage')).to_return(reply({}, 503))
112
+
113
+ client = described_class.new('abc', max_retries: 0)
114
+ expect { usage(client) }.to raise_error(DeepL::Exceptions::ServerError)
115
+ expect(stub).to have_been_requested.once
116
+ end
117
+ end
118
+
119
+ describe 'timeout' do
120
+ it 'applies the timeout to each attempt' do
121
+ stub_usage
122
+ client = described_class.new('abc', timeout: 3)
123
+
124
+ usage(client)
125
+
126
+ http = client.send(:api).http_client
127
+ expect([http.open_timeout, http.read_timeout, http.write_timeout]).to eq([3.0, 3.0, 3.0])
128
+ end
129
+
130
+ it 'defaults to 10 seconds and keeps the timeouts of the HTTP client options' do
131
+ stub_usage
132
+ options = DeepL::HTTPClientOptions.new({}, nil, read_timeout: 7)
133
+ client = described_class.new('abc', http_client_options: options)
134
+
135
+ usage(client)
136
+
137
+ http = client.send(:api).http_client
138
+ expect([http.open_timeout, http.read_timeout, http.write_timeout]).to eq([10.0, 7, 10.0])
139
+ end
140
+ end
141
+
142
+ describe 'proxy' do
143
+ def http_client(**options)
144
+ described_class.new('abc', **options).send(:api).http_client
145
+ end
146
+
147
+ it 'accepts a proxy URL with credentials' do
148
+ expect(http_client(proxy: 'http://us%40r:p%3Ass@proxy.example.test:3128'))
149
+ .to have_attributes(proxy_address: 'proxy.example.test', proxy_port: 3128,
150
+ proxy_user: 'us@r', proxy_pass: 'p:ss')
151
+ end
152
+
153
+ it 'combines the proxy URL with the other HTTP client options' do
154
+ options = DeepL::HTTPClientOptions.new({}, nil, enable_ssl_verification: false)
155
+
156
+ expect(http_client(proxy: 'http://proxy.example.test:3128', http_client_options: options))
157
+ .to have_attributes(proxy_address: 'proxy.example.test',
158
+ verify_mode: OpenSSL::SSL::VERIFY_NONE)
159
+ end
160
+
161
+ it 'rejects a proxy in both proxy: and http_client_options:' do
162
+ options = DeepL::HTTPClientOptions.new('http://other.example.test:1')
163
+
164
+ expect { http_client(proxy: 'http://proxy.example.test:3128', http_client_options: options) }
165
+ .to raise_error(ArgumentError, /proxy/)
166
+ end
167
+
168
+ it 'does not show the proxy credentials in inspect' do
169
+ client = described_class.new('abc', proxy: 'http://u:secret@proxy.test:3128')
170
+
171
+ expect(client.inspect).not_to include('secret')
172
+ expect(client.send(:api).inspect).not_to include('secret')
173
+ expect(client.send(:api).http_client_options.inspect).to include('proxy.test:3128')
174
+ end
175
+
176
+ it 'does not show the proxy credentials in the error for an invalid proxy URL' do
177
+ ['u:secret@proxy.test:3128', 'http://u:secret@proxy test:3128'].each do |proxy|
178
+ expect { described_class.new('abc', proxy: proxy) }
179
+ .to raise_error(ArgumentError) { |e| expect(e.full_message).not_to include('secret') }
180
+ end
181
+ end
182
+ end
183
+
184
+ describe '.free_account_auth_key?' do
185
+ it 'detects Free keys' do
186
+ expect(%w[a:fx a].map { |key| described_class.free_account_auth_key?(key) })
187
+ .to eq([true, false])
188
+ expect(described_class.free_account_auth_key?(nil)).to be(false)
189
+ end
190
+ end
191
+
192
+ describe '#translate_text' do
193
+ let(:client) { described_class.new(OfflineHelper::AUTH_KEY) }
194
+
195
+ def stub_translate(body, translations)
196
+ stub_request(:post, url('/v2/translate'))
197
+ .with(body: body, headers: json_headers)
198
+ .to_return(reply(translations: translations))
199
+ end
200
+
201
+ it 'sends the text, the options and show_billed_characters and returns one result' do
202
+ stub_translate({ text: ['Hello'], target_lang: 'DE', show_billed_characters: true,
203
+ formality: 'less', glossary_id: 'g-1', split_sentences: '0' },
204
+ [{ text: 'Hallo', billed_characters: 5 }])
205
+
206
+ result = client.translate_text('Hello', nil, 'DE', formality: 'less', glossary: 'g-1',
207
+ split_sentences: false, context: nil)
208
+ expect([result.class, result.text, result.billed_characters])
209
+ .to eq([DeepL::Resources::Text, 'Hallo', 5])
210
+ end
211
+
212
+ it 'returns an array for a one-element array' do
213
+ stub_translate({ text: ['Hello'], source_lang: 'EN', target_lang: 'DE',
214
+ show_billed_characters: true }, [{ text: 'Hallo' }])
215
+
216
+ expect(client.translate_text(['Hello'], 'EN', 'DE').map(&:text)).to eq(['Hallo'])
217
+ end
218
+ end
219
+
220
+ describe '#rephrase_text' do
221
+ let(:client) { described_class.new(OfflineHelper::AUTH_KEY) }
222
+
223
+ def stub_rephrase(body, improvements)
224
+ stub_request(:post, url('/v2/write/rephrase'))
225
+ .with(body: body, headers: json_headers)
226
+ .to_return(reply(improvements: improvements))
227
+ end
228
+
229
+ it 'sends the writing style and tone keywords, no nil options, and returns one result' do
230
+ stub_rephrase({ text: ['Hello'], target_lang: 'en-GB', writing_style: 'business',
231
+ tone: 'friendly' },
232
+ [{ text: 'Hi', detected_source_language: 'en', target_language: 'en-GB' }])
233
+
234
+ result = client.rephrase_text('Hello', 'en-GB', writing_style: 'business',
235
+ tone: 'friendly', context: nil)
236
+ expect([result.class, result.text, result.target_language])
237
+ .to eq([DeepL::Resources::Text, 'Hi', 'en-GB'])
238
+ end
239
+
240
+ it 'applies extra_body_parameters last, replacing string or symbol keyed fields' do
241
+ stub_rephrase({ text: ['Hello'], tone: 'confident', beta: true }, [{ text: 'Hi' }])
242
+
243
+ client.rephrase_text('Hello', tone: 'friendly',
244
+ extra_body_parameters: { 'tone' => 'confident', beta: true })
245
+ end
246
+
247
+ it 'returns an array for a one-element array' do
248
+ stub_rephrase({ text: ['Hello'] }, [{ text: 'Hi' }])
249
+
250
+ expect(client.rephrase_text(['Hello']).map(&:text)).to eq(['Hi'])
251
+ end
252
+ end
253
+
254
+ describe '#correct_text' do
255
+ let(:client) { described_class.new(OfflineHelper::AUTH_KEY) }
256
+
257
+ it 'sends the text, the target language and the extra body parameters' do
258
+ stub_request(:post, url('/v2/write/correct'))
259
+ .with(body: { text: ['Helo'], target_lang: 'en-US', beta: 1 }, headers: json_headers)
260
+ .to_return(reply(improvements: [{ text: 'Hello', target_language: 'en-US' }]))
261
+
262
+ result = client.correct_text('Helo', 'en-US', extra_body_parameters: { beta: 1 })
263
+ expect([result.text, result.target_language]).to eq(%w[Hello en-US])
264
+ end
265
+
266
+ it 'returns an array for a one-element array' do
267
+ stub_request(:post, url('/v2/write/correct'))
268
+ .to_return(reply(improvements: [{ text: 'Hello' }]))
269
+
270
+ expect(client.correct_text(['Helo']).map(&:text)).to eq(['Hello'])
271
+ end
272
+ end
273
+ end
@@ -4,7 +4,7 @@
4
4
  # frozen_string_literal: true
5
5
 
6
6
  require 'logger'
7
- require 'spec_helper'
7
+ require_relative 'offline_helper'
8
8
 
9
9
  describe DeepL::Configuration do
10
10
  subject(:config) { described_class.new(attributes) }
@@ -25,6 +25,7 @@ describe DeepL::Configuration do
25
25
  expect(config.host).to eq('https://api.deepl.com')
26
26
  expect(config.max_doc_status_queries).to be_nil
27
27
  expect(config.version).to eq('v2')
28
+ expect(config.timeout).to eq(60.0)
28
29
  end
29
30
 
30
31
  it 'sends platform information' do
@@ -48,6 +49,23 @@ describe DeepL::Configuration do
48
49
  end
49
50
  end
50
51
 
52
+ context 'when the environment provides the key and the server URL' do
53
+ before do
54
+ ENV['DEEPL_AUTH_KEY'] = 'env-key'
55
+ ENV['DEEPL_SERVER_URL'] = 'https://api.example.test'
56
+ end
57
+
58
+ it 'uses the environment values' do
59
+ expect(config.auth_key).to eq('env-key')
60
+ expect(config.host).to eq('https://api.example.test')
61
+ end
62
+
63
+ it 'prefers the given attributes' do
64
+ config = described_class.new(auth_key: 'given', host: 'https://given.example.test')
65
+ expect([config.auth_key, config.host]).to eq(%w[given https://given.example.test])
66
+ end
67
+ end
68
+
51
69
  context 'when using a free key' do
52
70
  let(:attributes) { { auth_key: '123e4567-e89b-12d3-a456-426614174000:fx' } }
53
71
 
@@ -56,6 +74,22 @@ describe DeepL::Configuration do
56
74
  end
57
75
  end
58
76
 
77
+ context 'when the free key is set after the configuration was created' do
78
+ it 'uses the free API URL' do
79
+ config.auth_key = 'abc:fx'
80
+
81
+ expect(config.host).to eq('https://api-free.deepl.com')
82
+ end
83
+ end
84
+
85
+ context 'when the free key has surrounding whitespace' do
86
+ let(:attributes) { { auth_key: 'abc:fx ' } }
87
+
88
+ it 'uses the free API URL' do
89
+ expect(config.host).to eq('https://api-free.deepl.com')
90
+ end
91
+ end
92
+
59
93
  context 'when using a pro key' do
60
94
  let(:attributes) { { auth_key: '123e4567-e89b-12d3-a456-426614174000' } }
61
95
 
@@ -103,7 +137,7 @@ describe DeepL::Configuration do
103
137
  describe '#validate!' do
104
138
  let(:auth_message) { 'auth_key not provided' }
105
139
 
106
- context 'when providing a valid auth key' do
140
+ context 'when the auth key is empty' do
107
141
  let(:attributes) { { auth_key: '' } }
108
142
 
109
143
  it 'raises an error' do
@@ -111,7 +145,15 @@ describe DeepL::Configuration do
111
145
  end
112
146
  end
113
147
 
114
- context 'when providing an invalid auth key' do
148
+ context 'when the auth key is blank' do
149
+ let(:attributes) { { auth_key: " \n" } }
150
+
151
+ it 'raises an error' do
152
+ expect { config.validate! }.to raise_error(DeepL::Exceptions::Error, auth_message)
153
+ end
154
+ end
155
+
156
+ context 'when the auth key is set' do
115
157
  let(:attributes) { { auth_key: 'not-empty' } }
116
158
 
117
159
  it 'does not raise an error' do
@@ -3,7 +3,7 @@
3
3
  # license that can be found in the LICENSE.md file.
4
4
  # frozen_string_literal: true
5
5
 
6
- require 'spec_helper'
6
+ require_relative 'offline_helper'
7
7
 
8
8
  describe 'DeepL::Constants' do
9
9
  describe DeepL::Constants::Tone do
@@ -89,6 +89,28 @@ describe 'DeepL::Constants' do
89
89
  end
90
90
  end
91
91
 
92
+ describe DeepL::Constants::TagHandlingVersion do
93
+ it 'includes v1 and v2' do
94
+ expect([described_class::V1, described_class::V2]).to eq(%w[v1 v2])
95
+ expect(described_class.options).to contain_exactly('v1', 'v2')
96
+ end
97
+ end
98
+
99
+ describe DeepL::Constants::LanguageResourceName do
100
+ it 'includes the resources of the languages endpoint' do
101
+ expect(described_class.options).to contain_exactly(
102
+ 'translate_text', 'translate_document', 'glossary', 'voice', 'write', 'style_rules',
103
+ 'translation_memory'
104
+ )
105
+ end
106
+ end
107
+
108
+ describe DeepL::Constants::LanguageInclude do
109
+ it 'includes beta and external' do
110
+ expect([described_class::BETA, described_class::EXTERNAL]).to eq(%w[beta external])
111
+ end
112
+ end
113
+
92
114
  describe DeepL::Constants::SplitSentences do
93
115
  subject(:split_sentences) { described_class }
94
116
 
@@ -0,0 +1,50 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE file.
4
+ # frozen_string_literal: true
5
+
6
+ require_relative 'offline_helper'
7
+
8
+ describe 'DeepL::Client#correct_text' do
9
+ let(:client) { DeepL::Client.new(OfflineHelper::AUTH_KEY) }
10
+
11
+ def stub_correct(body = nil)
12
+ request = stub_request(:post, url('/v2/write/correct'))
13
+ request = request.with(body: body, headers: json_headers) unless body.nil?
14
+ request
15
+ end
16
+
17
+ it 'sends only the text when no target language is given' do
18
+ stub_correct(text: ['Helo']).to_return(reply(improvements: [{ text: 'Hello' }]))
19
+ client.correct_text('Helo')
20
+ end
21
+
22
+ it 'sends several texts' do
23
+ stub_correct(text: %w[a b], target_lang: 'en-US')
24
+ .to_return(reply(improvements: [{ text: 'a' }, { text: 'b' }]))
25
+
26
+ client.correct_text(%w[a b], 'en-US')
27
+ end
28
+
29
+ it 'returns a text for a string' do
30
+ stub_correct.to_return(reply(improvements: [{ text: 'Hello' }]))
31
+
32
+ expect(client.correct_text('Helo')).to be_a(DeepL::Resources::Text)
33
+ end
34
+
35
+ [['Helo', []], [%w[a b], [{ text: 'x' }]]].each do |text, improvements|
36
+ it "raises DeepL::Exceptions::Error for #{improvements.size} improvements of #{text.inspect}" do
37
+ stub_correct.to_return(reply(improvements: improvements))
38
+
39
+ expect { client.correct_text(text) }
40
+ .to raise_error(an_instance_of(DeepL::Exceptions::Error), /expected \d improvements/)
41
+ end
42
+ end
43
+
44
+ [[''], [[]], [['a', '']]].each do |args|
45
+ it "raises ArgumentError for #{args.first.inspect} without sending a request" do
46
+ expect { client.correct_text(*args) }.to raise_error(ArgumentError, /non-empty/)
47
+ expect(a_request(:any, /.*/)).not_to have_been_made
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,32 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE file.
4
+ # frozen_string_literal: true
5
+
6
+ require_relative 'offline_helper'
7
+
8
+ describe DeepL::Deprecation do
9
+ around do |example|
10
+ enabled = Warning[:deprecated]
11
+ example.run
12
+ ensure
13
+ Warning[:deprecated] = enabled
14
+ end
15
+
16
+ it "prints nothing while Ruby's deprecation warnings are off" do
17
+ Warning[:deprecated] = false
18
+
19
+ expect { described_class.warn_once('spec-off', 'm') }.not_to output.to_stderr
20
+ end
21
+
22
+ it 'warns in the deprecated category when they are on' do
23
+ Warning[:deprecated] = true
24
+
25
+ if RUBY_VERSION >= '3.0'
26
+ expect(Warning).to receive(:warn).with(/m/, category: :deprecated)
27
+ described_class.warn_once('spec-on', 'm')
28
+ else
29
+ expect { described_class.warn_once('spec-on', 'm') }.to output(/m/).to_stderr
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,92 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE file.
4
+ # frozen_string_literal: true
5
+
6
+ require_relative 'offline_helper'
7
+
8
+ describe 'DeepL error mapping' do
9
+ {
10
+ 401 => [DeepL::Exceptions::AuthorizationFailed, /Authorization failed/],
11
+ 403 => [DeepL::Exceptions::AuthorizationFailed, /Authorization failed/],
12
+ 456 => [DeepL::Exceptions::QuotaExceeded, /Quota exceeded/],
13
+ 413 => [DeepL::Exceptions::RequestEntityTooLarge,
14
+ /request size has reached the supported limit/],
15
+ 418 => [DeepL::Exceptions::RequestError, 'Request failed with HTTP status 418: m']
16
+ }.each do |status, (error_class, message)|
17
+ it "raises #{error_class.name.split('::').last} for status #{status} without retrying" do
18
+ stub = stub_request(:get, url('/v2/usage')).to_return(status: status, body: '{"message":"m"}')
19
+
20
+ expect { DeepL.usage }.to raise_error(error_class, message)
21
+ expect(stub).to have_been_requested.once
22
+ end
23
+ end
24
+
25
+ {
26
+ 400 => DeepL::Exceptions::BadRequest,
27
+ 404 => DeepL::Exceptions::NotFound
28
+ }.each do |status, error_class|
29
+ it "raises #{error_class.name.split('::').last} with the API message for status #{status}" do
30
+ stub = stub_request(:get,
31
+ url('/v2/usage')).to_return(reply({ message: 'Value for x invalid' },
32
+ status))
33
+
34
+ expect { DeepL.usage }.to raise_error(error_class, 'Value for x invalid')
35
+ expect(stub).to have_been_requested.once
36
+ end
37
+
38
+ it "raises #{error_class.name.split('::').last} with a plain-text body as the message" do
39
+ stub_request(:get, url('/v2/usage')).to_return(status: status, body: 'plain text')
40
+
41
+ expect { DeepL.usage }.to raise_error(error_class, 'plain text')
42
+ end
43
+ end
44
+
45
+ it 'retries a 503 reply and raises ServerError with its status' do
46
+ stub = stub_request(:get, url('/v2/usage')).to_return(status: 503, body: '')
47
+
48
+ expect { DeepL.usage }
49
+ .to raise_error(DeepL::Exceptions::ServerError) { |e| expect(e.http_status_code).to eq(503) }
50
+ expect(stub).to have_been_requested.times(3)
51
+ end
52
+
53
+ it 'keeps the status code and the failed request on the raised error' do
54
+ stub_request(:get, url('/v2/usage')).to_return(status: 418, body: '')
55
+
56
+ expect { DeepL.usage }.to(raise_error(DeepL::Exceptions::RequestError) do |error|
57
+ expect([error.http_status_code, error.request.path, error.message])
58
+ .to eq([418, '/v2/usage', 'Request failed with HTTP status 418.'])
59
+ end)
60
+ end
61
+
62
+ it 'uses the raw body as the message of a 400 reply with a JSON array' do
63
+ stub_request(:get, url('/v2/usage')).to_return(status: 400, body: '["x"]')
64
+
65
+ expect { DeepL.usage }.to raise_error(DeepL::Exceptions::BadRequest, '["x"]')
66
+ end
67
+
68
+ ['not json', '[]', '{"translations":[1]}'].each do |body|
69
+ it "raises DeepL::Exceptions::Error for the malformed response #{body}" do
70
+ stub_request(:post, url('/v2/translate')).to_return(status: 200, body: body)
71
+
72
+ expect { DeepL.translate('a', nil, 'DE') }
73
+ .to raise_error(an_instance_of(DeepL::Exceptions::Error), /Unexpected response/)
74
+ end
75
+ end
76
+
77
+ it 'keeps the response on the raised error' do
78
+ stub_request(:get, url('/v2/usage')).to_return(status: 456, body: 'quota')
79
+
80
+ expect { DeepL.usage }.to(raise_error { |error| expect(error.response.body).to eq('quota') })
81
+ end
82
+
83
+ it 'raises the errors of every endpoint family the same way' do
84
+ stub_request(:post, %r{/v2/(translate|write/rephrase)}).to_return(status: 403)
85
+ stub_request(:delete, url('/v2/glossaries/g-1')).to_return(status: 404,
86
+ body: '{"message":"gone"}')
87
+
88
+ expect { DeepL.translate('a', nil, 'DE') }.to raise_error(DeepL::Exceptions::AuthorizationFailed)
89
+ expect { DeepL.rephrase('a') }.to raise_error(DeepL::Exceptions::AuthorizationFailed)
90
+ expect { DeepL.glossaries.destroy('g-1') }.to raise_error(DeepL::Exceptions::NotFound, 'gone')
91
+ end
92
+ end
@@ -0,0 +1,98 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE file.
4
+ # frozen_string_literal: true
5
+
6
+ require_relative 'offline_helper'
7
+
8
+ describe DeepL::GlossaryApi do
9
+ let(:glossaries) { DeepL.glossaries }
10
+ let(:glossary_json) do
11
+ { glossary_id: 'g-1', name: 'terms', ready: true, source_lang: 'en', target_lang: 'de',
12
+ creation_time: '2026-01-02T03:04:05.678Z', entry_count: 2 }
13
+ end
14
+
15
+ describe '#create' do
16
+ it 'sends the entries of an array as TSV' do
17
+ stub_request(:post, url('/v2/glossaries')).with(
18
+ body: { name: 'terms', source_lang: 'EN', target_lang: 'DE',
19
+ entries: "Apple\tApfel\nBanana\tBanane\n", entries_format: 'tsv' },
20
+ headers: json_headers
21
+ ).to_return(reply(glossary_json, 201))
22
+
23
+ glossaries.create('terms', 'EN', 'DE', [%w[Apple Apfel], %w[Banana Banane]])
24
+ end
25
+
26
+ it 'sends a string of entries and the entries format as given' do
27
+ stub_request(:post, url('/v2/glossaries')).with(
28
+ body: { name: 'terms', source_lang: 'EN', target_lang: 'DE', entries: "Apple,Apfel\n",
29
+ entries_format: 'csv' }
30
+ ).to_return(reply(glossary_json, 201))
31
+
32
+ glossaries.create('terms', 'EN', 'DE', "Apple,Apfel\n", entries_format: 'csv')
33
+ end
34
+
35
+ it 'strips surrounding whitespace from the terms of an array' do
36
+ stub_request(:post, url('/v2/glossaries'))
37
+ .with(body: hash_including(entries: "Apple\tApfel\n"))
38
+ .to_return(reply(glossary_json, 201))
39
+
40
+ glossaries.create('terms', 'EN', 'DE', [[' Apple ', "Apfel\n"]])
41
+ end
42
+
43
+ it 'returns a glossary' do
44
+ stub_request(:post, url('/v2/glossaries')).to_return(reply(glossary_json, 201))
45
+
46
+ expect(glossaries.create('terms', 'EN', 'DE', [%w[a b]])).to be_a(DeepL::Resources::Glossary)
47
+ end
48
+ end
49
+
50
+ describe '#create with invalid terms' do
51
+ [[%W[Apple\tPie Apfel]], [%W[Apple Apfel\nKuchen]], [['Apple', ' ']]].each do |entries|
52
+ it "raises ValidationError for #{entries.inspect} and sends no request" do
53
+ expect { glossaries.create('terms', 'EN', 'DE', entries) }
54
+ .to raise_error(DeepL::Exceptions::ValidationError)
55
+ expect(a_request(:any, /.*/)).not_to have_been_made
56
+ end
57
+ end
58
+ end
59
+
60
+ describe '#find' do
61
+ it 'sends a GET without a body' do
62
+ stub_request(:get, url('/v2/glossaries/g-1')).with(headers: auth_headers) { bodyless?(_1) }
63
+ .to_return(reply(glossary_json))
64
+
65
+ glossaries.find('g-1')
66
+ end
67
+ end
68
+
69
+ describe '#list' do
70
+ it 'returns one glossary per entry of the reply' do
71
+ second = glossary_json.merge(glossary_id: 'g-2')
72
+ stub_request(:get, url('/v2/glossaries'))
73
+ .with(headers: auth_headers) { bodyless?(_1) }
74
+ .to_return(reply(glossaries: [glossary_json, second]))
75
+
76
+ expect(glossaries.list.size).to eq(2)
77
+ end
78
+ end
79
+
80
+ describe '#entries' do
81
+ it 'parses the TSV reply into pairs' do
82
+ stub_request(:get, url('/v2/glossaries/g-1/entries'))
83
+ .with(headers: auth_headers) { bodyless?(_1) }
84
+ .to_return(status: 200, body: "Apple\tApfel\nBanana\tBanane")
85
+
86
+ expect(glossaries.entries('g-1')).to eq([%w[Apple Apfel], %w[Banana Banane]])
87
+ end
88
+ end
89
+
90
+ describe '#destroy' do
91
+ it 'sends a DELETE without a body and returns the ID' do
92
+ stub_request(:delete, url('/v2/glossaries/g-1')).with(headers: auth_headers) { bodyless?(_1) }
93
+ .to_return(status: 204)
94
+
95
+ expect(glossaries.destroy('g-1')).to eq('g-1')
96
+ end
97
+ end
98
+ end