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,319 @@
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 'logger'
7
+ require 'stringio'
8
+ require_relative 'offline_helper'
9
+
10
+ describe 'DeepL HTTP client' do
11
+ def stub_usage(*replies)
12
+ stub_request(:get, url('/v2/usage')).to_return(*replies)
13
+ end
14
+
15
+ describe 'retries' do
16
+ it 'retries a 429 reply' do
17
+ stub = stub_usage(reply({}, 429), usage_reply)
18
+
19
+ DeepL.usage
20
+ expect(stub).to have_been_requested.twice
21
+ end
22
+
23
+ it 'retries a 500 reply' do
24
+ stub = stub_usage(reply({}, 500), usage_reply)
25
+
26
+ DeepL.usage
27
+
28
+ expect(stub).to have_been_requested.twice
29
+ end
30
+
31
+ it 'retries a 503 reply' do
32
+ stub = stub_usage(reply({}, 503), usage_reply)
33
+
34
+ DeepL.usage
35
+
36
+ expect(stub).to have_been_requested.twice
37
+ end
38
+
39
+ it 'retries refused and reset connections, broken pipes and EOF' do
40
+ [Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE, EOFError].each do |error|
41
+ WebMock.reset!
42
+ stub = stub_request(:get, url('/v2/usage')).to_raise(error).then.to_return(usage_reply)
43
+
44
+ DeepL.usage
45
+
46
+ expect(stub).to have_been_requested.twice
47
+ end
48
+ end
49
+
50
+ it 'does not retry a TLS failure or a malformed response' do
51
+ [OpenSSL::SSL::SSLError, Net::HTTPBadResponse].each do |error|
52
+ WebMock.reset!
53
+ stub = stub_request(:get, url('/v2/usage')).to_raise(error).then.to_return(usage_reply)
54
+
55
+ expect { DeepL.usage }.to raise_error(error)
56
+ expect(stub).to have_been_requested.once
57
+ end
58
+ end
59
+
60
+ it 'raises a failed proxy CONNECT without retrying' do
61
+ error = Net::HTTPClientException.new('407 "Proxy Authentication Required"', nil)
62
+ stub = stub_request(:get, url('/v2/usage')).to_raise(error).then.to_return(usage_reply)
63
+
64
+ expect { DeepL.usage }.to raise_error(Net::HTTPClientException)
65
+ expect(stub).to have_been_requested.once
66
+ end
67
+
68
+ it 'retries a body shorter than its Content-Length and raises EOFError' do
69
+ short = { status: 200, body: '{"char', headers: { 'Content-Length' => '46' } }
70
+ stub = stub_usage(short)
71
+
72
+ expect { DeepL.usage }.to raise_error(EOFError)
73
+ expect(stub).to have_been_requested.times(3)
74
+ end
75
+
76
+ it 'does not let Net::HTTP re-send a request or accept a short body' do # rubocop:disable RSpec/ExampleLength
77
+ clients = []
78
+ allow(Net::HTTP).to receive(:new).and_wrap_original do |new, *args|
79
+ new.call(*args).tap { clients << _1 }
80
+ end
81
+ upload_url = 'https://storage.example.test/upload?sig=abc'
82
+ stub_request(:put, upload_url).to_return(status: 200)
83
+
84
+ [nil, DeepL::HTTPClientOptions.new({})].each do |options|
85
+ client = DeepL::Client.new('abc', http_client_options: options)
86
+ client.upload_translation_memory_file(upload_url, 'tmx')
87
+ end
88
+
89
+ expect(clients.map(&:max_retries)).to eq([0, 0, 0, 0])
90
+ # Net::HTTP#ignore_eof= exists from net-http 0.3 (Ruby 3.2).
91
+ next unless Net::HTTP.method_defined?(:ignore_eof=)
92
+
93
+ expect(clients.map(&:ignore_eof)).to eq([false, false, false, false])
94
+ end
95
+
96
+ it 'trims the auth key' do
97
+ DeepL.configuration.auth_key = " k \n"
98
+ stub_request(:get, url('/v2/usage')).with(headers: auth_headers).to_return(usage_reply)
99
+
100
+ DeepL.usage
101
+ end
102
+
103
+ it 'retries a network error' do
104
+ stub = stub_request(:get, url('/v2/usage')).to_raise(SocketError).then.to_return(usage_reply)
105
+
106
+ DeepL.usage
107
+
108
+ expect(stub).to have_been_requested.twice
109
+ end
110
+
111
+ it 'retries a timeout' do
112
+ stub = stub_request(:get, url('/v2/usage')).to_timeout.then.to_return(usage_reply)
113
+
114
+ DeepL.usage
115
+
116
+ expect(stub).to have_been_requested.twice
117
+ end
118
+
119
+ it 'raises the network error after max_network_retries retries' do
120
+ stub = stub_request(:get, url('/v2/usage')).to_timeout
121
+
122
+ expect { DeepL.usage }.to raise_error(Net::OpenTimeout)
123
+ expect(stub).to have_been_requested.times(3)
124
+ end
125
+
126
+ it 'raises LimitExceeded after max_network_retries retries' do
127
+ stub = stub_usage(reply({}, 429))
128
+
129
+ expect { DeepL.usage }.to raise_error(DeepL::Exceptions::LimitExceeded)
130
+ expect(stub).to have_been_requested.times(3)
131
+ end
132
+
133
+ it 'does not retry when max_network_retries is 0' do
134
+ DeepL.configuration.max_network_retries = 0
135
+ stub = stub_usage(reply({}, 429))
136
+
137
+ expect { DeepL.usage }.to raise_error(DeepL::Exceptions::LimitExceeded)
138
+ expect(stub).to have_been_requested.once
139
+ end
140
+ end
141
+
142
+ describe '.with_session' do
143
+ it 'runs the block without client options' do
144
+ stub = stub_usage(usage_reply)
145
+
146
+ DeepL.with_session { DeepL.usage }
147
+
148
+ expect(stub).to have_been_requested.once
149
+ end
150
+
151
+ it 'raises ArgumentError without a block' do
152
+ expect { DeepL.with_session }.to raise_error(ArgumentError, /requires a block/)
153
+ end
154
+
155
+ it 'applies the proxy and the timeouts to the client inside the block' do
156
+ options = DeepL::HTTPClientOptions.new({ 'proxy_addr' => 'proxy.example.test',
157
+ 'proxy_port' => 8080 },
158
+ nil, open_timeout: 3, read_timeout: 4)
159
+ stub_usage(usage_reply)
160
+
161
+ client = DeepL.with_session(options) do
162
+ DeepL.usage
163
+ DeepL.api.http_client
164
+ end
165
+
166
+ expect([client.proxy_address, client.proxy_port, client.open_timeout, client.read_timeout])
167
+ .to eq(['proxy.example.test', 8080, 3, 4])
168
+ end
169
+
170
+ it 'enables SSL from the URL scheme and verifies certificates unless disabled' do
171
+ session_client = lambda do |host, verify|
172
+ DeepL.configuration.host = host
173
+ options = DeepL::HTTPClientOptions.new({}, nil, enable_ssl_verification: verify)
174
+ DeepL.with_session(options) { DeepL.api.http_client }
175
+ end
176
+
177
+ expect(session_client.call('http://localhost:3000', true)).to have_attributes(use_ssl?: false)
178
+ expect(session_client.call('https://api.deepl.com', false))
179
+ .to have_attributes(use_ssl?: true, verify_mode: OpenSSL::SSL::VERIFY_NONE)
180
+ end
181
+
182
+ it 'uses no proxy when only the proxy address is given' do
183
+ options = DeepL::HTTPClientOptions.new({ 'proxy_addr' => 'proxy.example.test' })
184
+
185
+ expect(DeepL.with_session(options) { DeepL.api.http_client }.proxy_address).to be_nil
186
+ end
187
+
188
+ it 'raises SocketError when the session cannot connect' do
189
+ http = Net::HTTP.new('unresolvable.invalid', 443)
190
+ allow(http).to receive(:start).and_raise(SocketError, 'getaddrinfo: Name not known')
191
+ allow(DeepL::Utils::HTTPClientBuilder).to receive(:build).and_return(http)
192
+
193
+ expect { DeepL.with_session { nil } }.to raise_error(SocketError)
194
+ end
195
+
196
+ it 'opens the session connection with the configured timeout' do
197
+ DeepL.configuration.timeout = 4
198
+ options = DeepL::HTTPClientOptions.new({}, nil, read_timeout: 7)
199
+
200
+ client = DeepL.with_session(options) { DeepL.api.http_client }
201
+
202
+ expect([client.open_timeout, client.read_timeout]).to eq([4, 7])
203
+ end
204
+
205
+ it 'restores the previous client when the block ends' do
206
+ previous = DeepL.api.http_client
207
+ options = DeepL::HTTPClientOptions.new({ 'proxy_addr' => 'proxy.example.test',
208
+ 'proxy_port' => 8080 })
209
+ stub_usage(usage_reply)
210
+
211
+ DeepL.with_session(options) { DeepL.usage }
212
+
213
+ expect(DeepL.api.http_client).to equal(previous)
214
+ expect(DeepL.api.http_client.proxy_address).to be_nil
215
+ expect(DeepL.http_client).to be_nil
216
+ end
217
+
218
+ it 'restores the previous client when the block raises' do
219
+ previous = DeepL.api.http_client
220
+
221
+ expect { DeepL.with_session { raise 'boom' } }.to raise_error('boom')
222
+ expect(DeepL.api.http_client).to equal(previous)
223
+ end
224
+ end
225
+
226
+ describe 'logging' do
227
+ let(:log) { StringIO.new }
228
+
229
+ it 'logs the request and the retry' do
230
+ DeepL.configuration.logger = Logger.new(log)
231
+ stub_usage(reply({}, 429), usage_reply)
232
+
233
+ DeepL.usage
234
+
235
+ expect(log.string).to include('Request to the DeepL API: GET /v2/usage')
236
+ .and include('Starting retry 1 for request GET /v2/usage')
237
+ end
238
+
239
+ context 'with a debug logger' do
240
+ before do
241
+ DeepL.configuration.auth_key = 'sdktest-secret-key'
242
+ DeepL.configuration.logger = Logger.new(log, level: Logger::DEBUG)
243
+ end
244
+
245
+ def expect_key_redacted
246
+ expect(log.string).not_to include('sdktest-secret-key')
247
+ expect(log.string).to include('DeepL-Auth-Key [REDACTED]')
248
+ end
249
+
250
+ it 'redacts the auth key in the request details' do
251
+ stub_usage(usage_reply)
252
+
253
+ DeepL.usage
254
+
255
+ expect_key_redacted
256
+ end
257
+
258
+ it 'redacts the auth key in the details of a request type that overrides them' do
259
+ stub_request(:post, url('/v2/translate'))
260
+ .to_return(reply(translations: [{ text: 'Hallo' }]))
261
+
262
+ DeepL.translate('Hello', nil, 'DE')
263
+
264
+ expect_key_redacted
265
+ end
266
+
267
+ it 'redacts an Authorization header given with another casing' do
268
+ stub_request(:post, url('/v2/translate'))
269
+ .to_return(reply(translations: [{ text: 'Hallo' }]))
270
+
271
+ other = { 'authorization' => 'DeepL-Auth-Key sdktest-other-key' }
272
+ DeepL.translate('Hello', nil, 'DE', {}, other)
273
+
274
+ expect(log.string).not_to include('sdktest-other-key')
275
+ end
276
+ end
277
+ end
278
+
279
+ describe 'headers' do
280
+ it 'lets per-call headers replace the Authorization header in any casing' do
281
+ stub_request(:post, url('/v2/translate'))
282
+ .with(headers: { 'Authorization' => 'DeepL-Auth-Key other', 'X-Custom' => 'v' })
283
+ .to_return(reply(translations: [{ text: 'Hallo' }]))
284
+
285
+ DeepL.translate('Hello', nil, 'DE', {}, { 'authorization' => 'DeepL-Auth-Key other',
286
+ 'X-Custom' => 'v' })
287
+ end
288
+
289
+ it "keeps the SDK's Host and Content-Type over per-call headers" do
290
+ sdk_host = ->(request) { [nil, 'api.deepl.com'].include?(request.headers['Host']) }
291
+ stub_request(:post, 'https://api.deepl.com/v2/translate')
292
+ .with(headers: { 'Content-Type' => 'application/json' }, &sdk_host)
293
+ .to_return(reply(translations: [{ text: 'Hallo' }]))
294
+
295
+ DeepL.translate('Hello', nil, 'DE', {}, { 'host' => 'other.example.test',
296
+ 'content-type' => 'text/plain' })
297
+ end
298
+
299
+ it 'sends no caller Content-Type with a request without a body' do
300
+ DeepL.configuration.headers = { 'content-type' => 'text/plain' }
301
+ stub_request(:get, url('/v2/usage'))
302
+ .with { |request| !request.headers.key?('Content-Type') }.to_return(usage_reply)
303
+
304
+ DeepL.usage
305
+ end
306
+ end
307
+
308
+ describe 'HTTPClientBuilder' do
309
+ it 'uses the proxy only when both address and port are given' do
310
+ partial = DeepL::HTTPClientOptions.new({ 'proxy_addr' => 'proxy.example.test' })
311
+ full = DeepL::HTTPClientOptions.new({ 'proxy_addr' => 'proxy.example.test',
312
+ 'proxy_port' => 8080 })
313
+ build = ->(options) { DeepL::Utils::HTTPClientBuilder.build('https://api.deepl.com', options) }
314
+
315
+ expect(build.call(partial).proxy_address).to be_nil
316
+ expect(build.call(full).proxy_address).to eq('proxy.example.test')
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,155 @@
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 'languages' do
9
+ describe 'DeepL.languages' do
10
+ let(:languages_reply) do
11
+ reply([
12
+ { lang: 'en', name: 'English', usable_as_source: true, usable_as_target: false },
13
+ { lang: 'en-gb', name: 'English (British)', usable_as_source: false,
14
+ usable_as_target: true, features: { formality: { status: 'stable' } } },
15
+ { lang: 'de', name: 'German', usable_as_source: true, usable_as_target: true,
16
+ features: { formality: { status: 'stable' } } },
17
+ { lang: 'ja', name: 'Japanese', usable_as_source: true, usable_as_target: true }
18
+ ])
19
+ end
20
+
21
+ def stub_languages
22
+ stub_request(:get, url('/v3/languages?resource=translate_text'))
23
+ .with(headers: json_headers).to_return(languages_reply)
24
+ end
25
+
26
+ it 'lists the source languages with upper-case codes by default' do
27
+ stub_languages
28
+ languages = DeepL.languages
29
+
30
+ expect(languages.map(&:code)).to eq(%w[EN DE JA])
31
+ end
32
+
33
+ it 'lists the target languages with the formality support of each' do
34
+ stub_languages
35
+ languages = DeepL.languages(type: :target)
36
+
37
+ expect(languages.map { |l| [l.code, l.supports_formality?] })
38
+ .to eq([['EN-GB', true], ['DE', true], ['JA', false]])
39
+ end
40
+
41
+ it 'accepts the type as a string' do
42
+ stub_languages
43
+ expect(DeepL.languages('type' => 'target').map(&:code)).to eq(%w[EN-GB DE JA])
44
+ end
45
+
46
+ it 'raises NotSupported for the formality support of a source language' do
47
+ stub_languages
48
+ expect { DeepL.languages(type: :source).first.supports_formality? }
49
+ .to raise_error(DeepL::Exceptions::NotSupported, /only available on target languages/)
50
+ end
51
+
52
+ it 'raises ValidationError for an unknown type without sending a request' do
53
+ expect { DeepL.languages(type: :both) }
54
+ .to raise_error(DeepL::Exceptions::ValidationError, /Unsupported `type`/)
55
+ end
56
+ end
57
+
58
+ describe DeepL::Client do
59
+ let(:client) { described_class.new('client-key', server_url: 'https://client.example.test') }
60
+ let(:client_headers) do
61
+ { 'Authorization' => 'DeepL-Auth-Key client-key', 'Content-Type' => 'application/json' }
62
+ end
63
+
64
+ def stub_client_get(path, body)
65
+ stub_request(:get, "https://client.example.test#{path}")
66
+ .with(headers: client_headers).to_return(reply(body))
67
+ end
68
+
69
+ def stub_client_languages
70
+ stub_client_get('/v3/languages?resource=translate_text',
71
+ [{ lang: 'en', name: 'English', usable_as_source: true,
72
+ usable_as_target: false },
73
+ { lang: 'en-gb', name: 'English (British)', usable_as_source: false,
74
+ usable_as_target: true, features: { formality: { status: 'stable' } } },
75
+ { lang: 'ja', name: 'Japanese', usable_as_source: true,
76
+ usable_as_target: true }])
77
+ end
78
+
79
+ it '#get_source_languages returns the source languages without formality support' do
80
+ stub_client_languages
81
+
82
+ languages = client.get_source_languages
83
+
84
+ expect(languages.map { |l| [l.code, l.name, l.supports_formality?] })
85
+ .to eq([['EN', 'English', nil], ['JA', 'Japanese', nil]])
86
+ end
87
+
88
+ it '#get_target_languages returns the target languages with their formality support' do
89
+ stub_client_languages
90
+
91
+ languages = client.get_target_languages
92
+
93
+ expect(languages.map { |l| [l.code, l.supports_formality?] })
94
+ .to eq([['EN-GB', true], ['JA', false]])
95
+ end
96
+
97
+ it '#get_language_resources parses the resource name and its features' do
98
+ stub_client_get('/v3/languages/resources',
99
+ [{ name: 'glossary', features: [{ name: 'formality' }] }])
100
+
101
+ resource = client.get_language_resources.first
102
+
103
+ expect([resource.name, resource.to_s, resource.supports?(:formality)])
104
+ .to eq(['glossary', 'glossary', true])
105
+ end
106
+
107
+ it '#get_languages_for_resource sends the resource and returns the codes unchanged' do
108
+ stub_client_get('/v3/languages?resource=translate_document',
109
+ [{ lang: 'en-GB', name: 'English (British)', status: 'stable',
110
+ usable_as_source: false, usable_as_target: true }])
111
+
112
+ languages = client.get_languages_for_resource(
113
+ DeepL::Constants::LanguageResourceName::TRANSLATE_DOCUMENT
114
+ )
115
+
116
+ expect(languages.map(&:code)).to eq(['en-GB'])
117
+ end
118
+
119
+ it '#get_languages_for_resource sends one include parameter per value' do
120
+ # WebMock matches repeated query keys by their last value only, so the path of the sent
121
+ # request is asserted.
122
+ stub_client_get('/v3/languages?resource=voice&include=beta&include=external',
123
+ [{ lang: 'de', name: 'German' }])
124
+
125
+ languages = client.get_languages_for_resource(
126
+ :voice, include: [DeepL::Constants::LanguageInclude::BETA,
127
+ DeepL::Constants::LanguageInclude::EXTERNAL]
128
+ )
129
+
130
+ expect(languages.first.request.path)
131
+ .to eq('/v3/languages?resource=voice&include=beta&include=external')
132
+ end
133
+
134
+ it '#get_languages_for_resource sends a resource and include value without a constant' do
135
+ stub_client_get('/v3/languages?resource=new_resource&include=preview', [])
136
+
137
+ client.get_languages_for_resource('new_resource', include: 'preview')
138
+ end
139
+ end
140
+
141
+ describe 'DeepL.glossaries.language_pairs' do
142
+ it 'requests the glossary languages and pairs every source with every other target' do
143
+ stub_request(:get,
144
+ url('/v3/languages?resource=glossary')).with(headers: json_headers).to_return(
145
+ reply([{ lang: 'en', usable_as_source: true, usable_as_target: true },
146
+ { lang: 'de', usable_as_source: true, usable_as_target: true },
147
+ { lang: 'fr', usable_as_source: false, usable_as_target: true }])
148
+ )
149
+
150
+ pairs = DeepL.glossaries.language_pairs
151
+
152
+ expect(pairs.map(&:to_s)).to eq(['en - de', 'en - fr', 'de - en', 'de - fr'])
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,13 @@
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 do
9
+ it 'has no module-level methods for the operations added after 3.9.0' do
10
+ expect(%i[correct language_resources languages_for_resource multilingual_glossaries]
11
+ .map { |name| described_class.respond_to?(name) }).to eq([false] * 4)
12
+ end
13
+ end