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,358 @@
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 'multilingual glossaries' do
9
+ # The client has its own key and server, different from the module configuration, so a request
10
+ # that falls back to module state hits no stub.
11
+ describe 'DeepL::Client' do
12
+ let(:client) { DeepL::Client.new('client-key', server_url: 'https://client.example.test') }
13
+ let(:glossary_json) do
14
+ { glossary_id: 'g-1', name: 'terms', creation_time: '2026-10-23T17:15:53.434769Z',
15
+ dictionaries: [{ source_lang: 'en', target_lang: 'de', entry_count: 2 }] }
16
+ end
17
+ let(:dictionary_json) { { source_lang: 'en', target_lang: 'de', entry_count: 1 } }
18
+ let(:apple) do
19
+ DeepL::Resources::MultilingualGlossaryDictionaryEntries.new('EN', 'DE', [%w[Apple Apfel]])
20
+ end
21
+
22
+ def url(path)
23
+ "https://client.example.test#{path}"
24
+ end
25
+
26
+ def json_headers
27
+ { 'Authorization' => 'DeepL-Auth-Key client-key', 'Content-Type' => 'application/json' }
28
+ end
29
+
30
+ def auth_headers
31
+ { 'Authorization' => 'DeepL-Auth-Key client-key' }
32
+ end
33
+
34
+ def no_body
35
+ ->(body) { body.to_s.empty? }
36
+ end
37
+
38
+ def glossary
39
+ stub_request(:get, url('/v3/glossaries/g-1')).to_return(reply(glossary_json))
40
+ client.get_multilingual_glossary('g-1')
41
+ end
42
+
43
+ describe '#create_multilingual_glossary' do
44
+ it 'takes MultilingualGlossaryDictionaryEntries objects and sends them as TSV' do
45
+ stub_request(:post, url('/v3/glossaries')).with(
46
+ body: { name: 'terms', dictionaries: [{ source_lang: 'EN', target_lang: 'DE',
47
+ entries: "Apple\tApfel", entries_format: 'tsv' }],
48
+ beta: true },
49
+ headers: json_headers
50
+ ).to_return(reply(glossary_json, 201))
51
+
52
+ client.create_multilingual_glossary('terms', [apple], extra_body_parameters: { beta: true })
53
+ end
54
+
55
+ it 'trims the terms of MultilingualGlossaryDictionaryEntries given as a TSV string' do
56
+ dictionary = { source_lang: 'EN', target_lang: 'DE', entries: "Apple\tApfel",
57
+ entries_format: 'tsv' }
58
+ stub_request(:post, url('/v3/glossaries'))
59
+ .with(body: { name: 'terms', dictionaries: [dictionary] })
60
+ .to_return(reply(glossary_json, 201))
61
+ entries_class = DeepL::Resources::MultilingualGlossaryDictionaryEntries
62
+ entries = entries_class.new('EN', 'DE', "Apple \tApfel\n")
63
+
64
+ client.create_multilingual_glossary('terms', [entries])
65
+ end
66
+
67
+ it 'accepts dictionaries with string keys and a string of entries' do
68
+ stub_request(:post, url('/v3/glossaries')).with(
69
+ body: { name: 'terms',
70
+ dictionaries: [{ source_lang: 'EN', target_lang: 'FR', entries: "a\tb",
71
+ entries_format: 'tsv' }] }
72
+ ).to_return(reply(glossary_json, 201))
73
+
74
+ client.create_multilingual_glossary(
75
+ 'terms', [{ 'source_lang' => 'EN', 'target_lang' => 'FR', 'entries' => "a\tb" }]
76
+ )
77
+ end
78
+
79
+ it 'trims the terms of a TSV string of entries and skips blank lines' do
80
+ stub_request(:post, url('/v3/glossaries')).with(
81
+ body: { name: 'terms',
82
+ dictionaries: [{ source_lang: 'EN', target_lang: 'DE',
83
+ entries: "Apple\tApfel\nBanana\tBanane",
84
+ entries_format: 'tsv' }] }
85
+ ).to_return(reply(glossary_json, 201))
86
+
87
+ client.create_multilingual_glossary(
88
+ 'terms', [{ source_lang: 'EN', target_lang: 'DE',
89
+ entries: " Apple \t Apfel\r\n\n Banana\tBanane \n" }]
90
+ )
91
+ end
92
+
93
+ [[%W[Apple\tPie Apfel]], [%W[Apple Apfel\nKuchen]], "Apple\tApfel\tKuchen", "Apple\n",
94
+ " \tApfel", "Apple\tAp\u0001fel", "Apple\tAp\u2028fel"].each do |entries|
95
+ it "raises ValidationError for #{entries.inspect} and sends no request" do
96
+ dictionaries = [{ source_lang: 'EN', target_lang: 'DE', entries: entries }]
97
+
98
+ expect { client.create_multilingual_glossary('terms', dictionaries) }
99
+ .to raise_error(DeepL::Exceptions::ValidationError)
100
+ expect(a_request(:any, /.*/)).not_to have_been_made
101
+ end
102
+ end
103
+
104
+ { 'a duplicate source term' => [%w[Apple Apfel], [' Apple', 'Apfel2']],
105
+ 'a one-element pair' => [['Apple']] }.each do |label, pairs|
106
+ it "raises ValidationError for #{label} and sends no request" do
107
+ entries_class = DeepL::Resources::MultilingualGlossaryDictionaryEntries
108
+
109
+ expect do
110
+ client.create_multilingual_glossary('terms', [entries_class.new('EN', 'DE', pairs)])
111
+ end.to raise_error(DeepL::Exceptions::ValidationError, /Apple/)
112
+ expect(a_request(:any, /.*/)).not_to have_been_made
113
+ end
114
+ end
115
+
116
+ it 'parses the creation time as a UTC Time' do
117
+ stub_request(:post, url('/v3/glossaries')).to_return(reply(glossary_json, 201))
118
+
119
+ created = client.create_multilingual_glossary('terms', [])
120
+
121
+ expect(created.creation_time)
122
+ .to eq(Time.utc(2026, 10, 23, 17, 15, Rational(53_434_769, 1_000_000)))
123
+ end
124
+ end
125
+
126
+ describe '#create_multilingual_glossary_from_csv' do
127
+ it 'sends the CSV content of a string or an IO with the csv entries format' do
128
+ body = { name: 'terms', dictionaries: [{ source_lang: 'EN', target_lang: 'DE',
129
+ entries: "a,b\n", entries_format: 'csv' }] }
130
+ stub_request(:post, url('/v3/glossaries')).with(body: body)
131
+ .to_return(reply(glossary_json, 201))
132
+
133
+ client.create_multilingual_glossary_from_csv('terms', 'EN', 'DE', "a,b\n")
134
+ client.create_multilingual_glossary_from_csv('terms', 'EN', 'DE', StringIO.new("a,b\n"))
135
+ end
136
+ end
137
+
138
+ describe '#list_multilingual_glossaries and #get_multilingual_glossary' do
139
+ it 'send GET requests and take a glossary object' do
140
+ stub_request(:get, url('/v3/glossaries')).with(headers: auth_headers)
141
+ .to_return(reply(glossaries: [glossary_json]))
142
+
143
+ expect(client.list_multilingual_glossaries.map(&:id)).to eq(['g-1'])
144
+ expect(client.get_multilingual_glossary(glossary).name).to eq('terms')
145
+ end
146
+ end
147
+
148
+ describe '#update_multilingual_glossary' do
149
+ it 'sends the name and the dictionaries' do
150
+ stub_request(:patch, url('/v3/glossaries/g-1')).with(
151
+ body: { name: 'new', dictionaries: [{ source_lang: 'EN', target_lang: 'FR',
152
+ entries: "a\tb", entries_format: 'tsv' }] },
153
+ headers: json_headers
154
+ ).to_return(reply(glossary_json))
155
+
156
+ client.update_multilingual_glossary(
157
+ 'g-1', name: 'new', dictionaries: [{ source_lang: 'EN', target_lang: 'FR',
158
+ entries: [%w[a b]] }]
159
+ )
160
+ end
161
+
162
+ it 'sends an empty object when no argument is given' do
163
+ stub_request(:patch, url('/v3/glossaries/g-1')).with(body: '{}')
164
+ .to_return(reply(glossary_json))
165
+
166
+ client.update_multilingual_glossary('g-1')
167
+ end
168
+ end
169
+
170
+ describe '#update_multilingual_glossary_name and #update_multilingual_glossary_dictionary' do
171
+ it 'send a PATCH with only the name or only the dictionary' do
172
+ stub_request(:patch, url('/v3/glossaries/g-1')).with(body: { name: 'new' })
173
+ .to_return(reply(glossary_json))
174
+ stub_request(:patch, url('/v3/glossaries/g-1')).with(
175
+ body: { dictionaries: [{ source_lang: 'EN', target_lang: 'DE', entries: "Apple\tApfel",
176
+ entries_format: 'tsv' }] }
177
+ ).to_return(reply(glossary_json))
178
+
179
+ client.update_multilingual_glossary_name(glossary, 'new')
180
+ client.update_multilingual_glossary_dictionary(glossary, apple)
181
+ end
182
+
183
+ it 'sends CSV content with the csv entries format' do
184
+ stub_request(:patch, url('/v3/glossaries/g-1')).with(
185
+ body: { dictionaries: [{ source_lang: 'EN', target_lang: 'DE', entries: "a,b\n",
186
+ entries_format: 'csv' }] }
187
+ ).to_return(reply(glossary_json))
188
+
189
+ client.update_multilingual_glossary_dictionary_from_csv('g-1', 'EN', 'DE', "a,b\n")
190
+ end
191
+ end
192
+
193
+ describe '#replace_multilingual_glossary_dictionary' do
194
+ it 'sends a PUT with one dictionary given as a hash' do
195
+ stub_request(:put, url('/v3/glossaries/g-1/dictionaries')).with(
196
+ body: { source_lang: 'EN', target_lang: 'DE', entries: "a\tb", entries_format: 'tsv' },
197
+ headers: json_headers
198
+ ).to_return(reply(dictionary_json))
199
+
200
+ replaced = client.replace_multilingual_glossary_dictionary(
201
+ 'g-1', { source_lang: 'EN', target_lang: 'DE', entries: [%w[a b]] }
202
+ )
203
+
204
+ expect([replaced.source_lang, replaced.target_lang, replaced.entry_count])
205
+ .to eq(['en', 'de', 1])
206
+ end
207
+
208
+ it 'sends CSV content with the csv entries format' do
209
+ stub_request(:put, url('/v3/glossaries/g-1/dictionaries')).with(
210
+ body: { source_lang: 'EN', target_lang: 'DE', entries: "a,b\n", entries_format: 'csv' }
211
+ ).to_return(reply(dictionary_json))
212
+
213
+ client.replace_multilingual_glossary_dictionary_from_csv('g-1', 'EN', 'DE', "a,b\n")
214
+ end
215
+ end
216
+
217
+ describe 'entries_format with converted entries' do
218
+ operations = {
219
+ 'create' => [:post, '/v3/glossaries', lambda { |client, dictionary|
220
+ client.create_multilingual_glossary('terms', [dictionary])
221
+ }, ->(sent) { { name: 'terms', dictionaries: [sent] } }],
222
+ 'update' => [:patch, '/v3/glossaries/g-1', lambda { |client, dictionary|
223
+ client.update_multilingual_glossary('g-1', dictionaries: [dictionary])
224
+ }, ->(sent) { { dictionaries: [sent] } }],
225
+ 'replace' => [:put, '/v3/glossaries/g-1/dictionaries', lambda { |client, dictionary|
226
+ client.replace_multilingual_glossary_dictionary('g-1', dictionary)
227
+ }, ->(sent) { sent }]
228
+ }
229
+
230
+ operations.each do |name, (method, path, call, body)|
231
+ { 'a hash' => { 'Hello' => 'Hallo' }, 'pairs' => [%w[Hello Hallo]] }.each do |kind, entries|
232
+ it "#{name} sends #{kind} given with entries_format csv as TSV" do
233
+ sent = { source_lang: 'en', target_lang: 'de', entries: "Hello\tHallo",
234
+ entries_format: 'tsv' }
235
+ stub = stub_request(method, url(path)).with(body: body.call(sent))
236
+ .to_return(reply(glossary_json))
237
+
238
+ call.call(client, { source_lang: 'en', target_lang: 'de', entries: entries,
239
+ entries_format: 'csv' })
240
+
241
+ expect(stub).to have_been_requested
242
+ end
243
+ end
244
+
245
+ it "#{name} sends a CSV string given with entries_format csv unchanged" do
246
+ sent = { source_lang: 'en', target_lang: 'de', entries: "Hello,Hallo\n",
247
+ entries_format: 'csv' }
248
+ stub = stub_request(method, url(path)).with(body: body.call(sent))
249
+ .to_return(reply(glossary_json))
250
+
251
+ call.call(client, sent)
252
+
253
+ expect(stub).to have_been_requested
254
+ end
255
+ end
256
+ end
257
+
258
+ describe '#get_multilingual_glossary_dictionary_entries' do
259
+ it 'encodes a space in a query value as %20' do
260
+ expect(Net::HTTP::Get).to receive(:new)
261
+ .with('/v3/glossaries/g-1/entries?source_lang=E%20N&target_lang=DE', anything)
262
+ .and_call_original
263
+ stub_request(:get, url('/v3/glossaries/g-1/entries?source_lang=E%20N&target_lang=DE'))
264
+ .to_return(reply(dictionaries: [{ source_lang: 'en', target_lang: 'de', entries: '',
265
+ entries_format: 'tsv' }]))
266
+
267
+ client.get_multilingual_glossary_dictionary_entries('g-1', 'E N', 'DE')
268
+ end
269
+
270
+ it 'returns the one dictionary with its entries as a hash' do
271
+ stub_request(:get, url('/v3/glossaries/g-1/entries?source_lang=EN&target_lang=DE'))
272
+ .with(headers: json_headers)
273
+ .to_return(reply(dictionaries: [{ source_lang: 'en', target_lang: 'de',
274
+ entries: "Apple\tApfel\nBanana\tBanane\n",
275
+ entries_format: 'tsv' }]))
276
+
277
+ result = client.get_multilingual_glossary_dictionary_entries('g-1', 'EN', 'DE')
278
+
279
+ expect([result.source_lang, result.target_lang, result.entries])
280
+ .to eq(['en', 'de', { 'Apple' => 'Apfel', 'Banana' => 'Banane' }])
281
+ end
282
+
283
+ it 'returns entries that update and replace send back as TSV' do # rubocop:disable RSpec/ExampleLength
284
+ stub_request(:get, url('/v3/glossaries/g-1/entries?source_lang=EN&target_lang=DE'))
285
+ .to_return(reply(dictionaries: [{ source_lang: 'en', target_lang: 'de',
286
+ entries: "Hello\tHallo", entries_format: 'tsv' }]))
287
+ dictionary = { source_lang: 'en', target_lang: 'de', entries: "Hello\tHallo",
288
+ entries_format: 'tsv' }
289
+ stub_request(:patch, url('/v3/glossaries/g-1'))
290
+ .with(body: { dictionaries: [dictionary] }).to_return(reply(glossary_json))
291
+ stub_request(:put, url('/v3/glossaries/g-1/dictionaries'))
292
+ .with(body: dictionary).to_return(reply(dictionary_json))
293
+
294
+ entries = client.get_multilingual_glossary_dictionary_entries('g-1', 'EN', 'DE')
295
+ client.update_multilingual_glossary_dictionary('g-1', entries)
296
+ client.replace_multilingual_glossary_dictionary('g-1', entries)
297
+ expect(entries.entries).to eq('Hello' => 'Hallo')
298
+ end
299
+
300
+ it 'raises NotFound when the glossary has no dictionary for the language pair' do
301
+ stub_request(:get, url('/v3/glossaries/g-1/entries?source_lang=EN&target_lang=JA'))
302
+ .to_return(reply(dictionaries: []))
303
+
304
+ expect { client.get_multilingual_glossary_dictionary_entries('g-1', 'EN', 'JA') }
305
+ .to raise_error(DeepL::Exceptions::NotFound, /no dictionary for EN -> JA/)
306
+ end
307
+
308
+ it 'returns entries that can be sent to another glossary' do
309
+ stub_request(:get, url('/v3/glossaries/g-1/entries?source_lang=EN&target_lang=DE'))
310
+ .to_return(reply(dictionaries: [{ source_lang: 'en', target_lang: 'de',
311
+ entries: "a\tb", entries_format: 'tsv' }]))
312
+ stub_request(:post, url('/v3/glossaries')).with(
313
+ body: { name: 'copy', dictionaries: [{ source_lang: 'en', target_lang: 'de',
314
+ entries: "a\tb", entries_format: 'tsv' }] }
315
+ ).to_return(reply(glossary_json, 201))
316
+
317
+ entries = client.get_multilingual_glossary_dictionary_entries('g-1', 'EN', 'DE')
318
+ client.create_multilingual_glossary('copy', [entries])
319
+ end
320
+ end
321
+
322
+ describe '#delete_multilingual_glossary_dictionary and #delete_multilingual_glossary' do
323
+ it 'send DELETE requests and return nil' do
324
+ stub_request(:delete, url('/v3/glossaries/g-1/dictionaries?source_lang=EN&target_lang=FR'))
325
+ .with(headers: json_headers, body: no_body).to_return(status: 204)
326
+ stub_request(:delete, url('/v3/glossaries/g-1')).with(headers: auth_headers)
327
+ .to_return(status: 204)
328
+
329
+ expect(client.delete_multilingual_glossary_dictionary(glossary, 'EN', 'FR')).to be_nil
330
+ expect(client.delete_multilingual_glossary(glossary)).to be_nil
331
+ end
332
+ end
333
+
334
+ describe 'glossary IDs' do
335
+ before do
336
+ encoded = url('/v3/glossaries/a%20b%2Fc')
337
+ stub_request(:get, encoded).to_return(reply(glossary_json))
338
+ stub_request(:patch, encoded).to_return(reply(glossary_json))
339
+ stub_request(:delete, encoded).to_return(status: 204)
340
+ stub_request(:get, "#{encoded}/entries?source_lang=EN&target_lang=DE")
341
+ .to_return(reply(dictionaries: [{ source_lang: 'en', target_lang: 'de', entries: '' }]))
342
+ stub_request(:put, "#{encoded}/dictionaries").to_return(reply(dictionary_json))
343
+ stub_request(:delete, "#{encoded}/dictionaries?source_lang=EN&target_lang=DE")
344
+ .to_return(status: 204)
345
+ end
346
+
347
+ it 'are URL-encoded in every path' do
348
+ id = 'a b/c'
349
+ client.get_multilingual_glossary(id)
350
+ client.update_multilingual_glossary_name(id, 'x')
351
+ client.delete_multilingual_glossary(id)
352
+ client.get_multilingual_glossary_dictionary_entries(id, 'EN', 'DE')
353
+ client.replace_multilingual_glossary_dictionary(id, apple)
354
+ client.delete_multilingual_glossary_dictionary(id, 'EN', 'DE')
355
+ end
356
+ end
357
+ end
358
+ end
@@ -0,0 +1,71 @@
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
+ if defined?(RealApiHelper)
7
+ raise 'spec/offline and spec/real_api cannot run in one process: WebMock blocks the network'
8
+ end
9
+
10
+ require_relative '../spec_helper'
11
+ require_relative '../fixtures'
12
+ require 'webmock/rspec'
13
+
14
+ # The offline suite never touches the network: every request is declared with WebMock and
15
+ # asserted exactly.
16
+ WebMock.disable_net_connect!
17
+
18
+ module OfflineHelper
19
+ HOST = 'https://api.deepl.com'
20
+ AUTH_KEY = 'k'
21
+
22
+ def url(path)
23
+ "#{HOST}#{path}"
24
+ end
25
+
26
+ # Headers of a request with a JSON Content-Type.
27
+ def json_headers
28
+ { 'Authorization' => "DeepL-Auth-Key #{AUTH_KEY}", 'Content-Type' => 'application/json' }
29
+ end
30
+
31
+ # Headers of a request without a body.
32
+ def auth_headers
33
+ { 'Authorization' => "DeepL-Auth-Key #{AUTH_KEY}" }
34
+ end
35
+
36
+ # True for a request sent without a body and without a Content-Type, as GET and DELETE are.
37
+ def bodyless?(request)
38
+ request.body.to_s.empty? && !request.headers.key?('Content-Type')
39
+ end
40
+
41
+ def reply(body = {}, status = 200)
42
+ { status: status, body: body.is_a?(String) ? body : body.to_json }
43
+ end
44
+
45
+ def usage_reply
46
+ reply(character_count: 1, character_limit: 10)
47
+ end
48
+ end
49
+
50
+ RSpec.configure do |config|
51
+ config.include OfflineHelper
52
+ config.include Fixtures
53
+
54
+ config.before do
55
+ DeepL.configure do |c|
56
+ c.auth_key = OfflineHelper::AUTH_KEY
57
+ c.host = OfflineHelper::HOST
58
+ c.logger = nil
59
+ c.max_doc_status_queries = nil
60
+ c.headers = {}
61
+ # The default of 5 retries would sleep for about 16 seconds in every retry example.
62
+ c.max_network_retries = 2
63
+ end
64
+ end
65
+
66
+ # A declared request that the SDK never sent is a failure. webmock/rspec resets the stubs
67
+ # in an around hook, which runs after this one.
68
+ config.after do
69
+ expect(WebMock::StubRegistry.instance.request_stubs).to all(have_been_requested.at_least_once)
70
+ end
71
+ end
@@ -0,0 +1,73 @@
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.rephrase' do
9
+ def stub_rephrase(body = nil)
10
+ request = stub_request(:post, url('/v2/write/rephrase'))
11
+ request = request.with(body: body, headers: json_headers) unless body.nil?
12
+ request
13
+ end
14
+
15
+ it 'sends only the text when no other argument is given' do
16
+ stub_rephrase(text: ['Hello']).to_return(reply(improvements: [{ text: 'Hi' }]))
17
+ DeepL.rephrase('Hello')
18
+ end
19
+
20
+ it 'sends the target language, writing style and tone' do
21
+ stub_rephrase(text: %w[a b], target_lang: 'en-GB', writing_style: 'business',
22
+ tone: 'diplomatic')
23
+ .to_return(reply(improvements: [{ text: 'x' }, { text: 'y' }]))
24
+
25
+ DeepL.rephrase(%w[a b], 'en-GB', 'business', 'diplomatic')
26
+ end
27
+
28
+ it 'sends the additional headers' do
29
+ stub_request(:post, url('/v2/write/rephrase'))
30
+ .with(headers: { 'X-DeepL-Reporting-Tag' => 'tag' })
31
+ .to_return(reply(improvements: [{ text: 'Hi' }]))
32
+
33
+ DeepL.rephrase('Hello', nil, nil, nil, {}, { 'X-DeepL-Reporting-Tag' => 'tag' })
34
+ end
35
+
36
+ it 'overrides built-in parameters with extra_body_parameters' do
37
+ stub_rephrase(text: ['Hello'], target_lang: 'de', beta: true)
38
+ .to_return(reply(improvements: [{ text: 'Hallo' }]))
39
+
40
+ DeepL.rephrase('Hello', 'en', nil, nil,
41
+ extra_body_parameters: { target_lang: 'de', beta: true })
42
+ end
43
+
44
+ it 'returns a text for a string' do
45
+ stub_rephrase.to_return(reply(improvements: [{ text: 'Hi' }]))
46
+
47
+ expect(DeepL.rephrase('Hello')).to be_a(DeepL::Resources::Text)
48
+ end
49
+
50
+ it 'parses the target language of each improvement' do
51
+ stub_rephrase.to_return(reply(improvements: [
52
+ { text: 'x', detected_source_language: 'en',
53
+ target_language: 'en-GB' }, { text: 'y', target_language: 1 }
54
+ ]))
55
+
56
+ expect(DeepL.rephrase(%w[a b]).map(&:target_language)).to eq(['en-GB', nil])
57
+ end
58
+
59
+ it 'returns an array of texts for an array' do
60
+ stub_rephrase.to_return(reply(improvements: [{ text: 'x' }, { text: 'y' }]))
61
+
62
+ expect(DeepL.rephrase(%w[a b]).map(&:class)).to eq([DeepL::Resources::Text] * 2)
63
+ end
64
+
65
+ [['Hello', []], [%w[a b], [{ text: 'x' }]]].each do |text, improvements|
66
+ it "raises DeepL::Exceptions::Error for #{improvements.size} improvements of #{text.inspect}" do
67
+ stub_rephrase.to_return(reply(improvements: improvements))
68
+
69
+ expect { DeepL.rephrase(text) }
70
+ .to raise_error(an_instance_of(DeepL::Exceptions::Error), /expected \d improvements/)
71
+ end
72
+ end
73
+ end