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,94 @@
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.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require 'date'
7
+
8
+ module DeepL
9
+ module Requests
10
+ module Admin
11
+ class Base < Requests::Base
12
+ def initialize(api, extra_body_parameters = nil)
13
+ options = {}
14
+ options[:extra_body_parameters] = extra_body_parameters unless extra_body_parameters.nil?
15
+ super(api, options)
16
+ end
17
+
18
+ def to_s
19
+ "#{http_method} #{uri.request_uri}"
20
+ end
21
+
22
+ private
23
+
24
+ def http_method
25
+ 'GET'
26
+ end
27
+
28
+ def get_request # rubocop:disable Naming/AccessorMethodName
29
+ Net::HTTP::Get.new(uri.request_uri, add_json_content_type(headers))
30
+ end
31
+
32
+ def uri
33
+ @uri ||= begin
34
+ base_uri = URI(url)
35
+ query_string = encode_query(query_params)
36
+ base_uri.query = query_string unless query_string.empty?
37
+ base_uri
38
+ end
39
+ end
40
+
41
+ def query_params
42
+ {}
43
+ end
44
+
45
+ def date_range_params(start_date, end_date)
46
+ start_day = parse_date(start_date)
47
+ end_day = parse_date(end_date)
48
+
49
+ { 'start_date' => start_day.iso8601, 'end_date' => end_day.iso8601 }
50
+ end
51
+
52
+ def parse_date(value)
53
+ return value.new_offset(0).to_date if value.is_a?(DateTime)
54
+ return value if value.is_a?(Date)
55
+ return value.getutc.to_date if value.is_a?(Time)
56
+
57
+ date = parse_date_string(value) if value.is_a?(String)
58
+ return date if date
59
+
60
+ raise ArgumentError,
61
+ "Invalid date #{value.inspect}, expected a Date, a Time, a DateTime or " \
62
+ 'a YYYY-MM-DD string'
63
+ end
64
+
65
+ def parse_date_string(value)
66
+ return nil unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
67
+
68
+ year, month, day = value.split('-').map(&:to_i)
69
+ Date.new(year, month, day) if Date.valid_date?(year, month, day)
70
+ end
71
+
72
+ def build_developer_key(request, response)
73
+ data = JSON.parse(response.body)
74
+ raise DeepL::Exceptions::Error, 'Developer key response is missing key_id' unless
75
+ usable_developer_key?(data)
76
+
77
+ Resources::DeveloperKey.new(data, request, response)
78
+ end
79
+
80
+ def usable_developer_key?(data)
81
+ data.is_a?(Hash) && data['key_id'].is_a?(String) && !data['key_id'].empty?
82
+ end
83
+
84
+ def parse_json_object(response)
85
+ data = JSON.parse(response.body)
86
+ raise DeepL::Exceptions::Error, 'Analytics response is not a JSON object' unless
87
+ data.is_a?(Hash)
88
+
89
+ data
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,38 @@
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.md file.
4
+ # frozen_string_literal: true
5
+
6
+ module DeepL
7
+ module Requests
8
+ module Admin
9
+ class CreateDeveloperKey < Base
10
+ def initialize(api, label = nil, extra_body_parameters = nil)
11
+ super(api, extra_body_parameters)
12
+ @label = label
13
+ end
14
+
15
+ def request
16
+ payload = {}
17
+ payload[:label] = @label unless @label.nil?
18
+ build_developer_key(*execute_request_with_retries(post_request(payload)))
19
+ end
20
+
21
+ private
22
+
23
+ def http_method
24
+ 'POST'
25
+ end
26
+
27
+ def path
28
+ 'admin/developer-keys'
29
+ end
30
+
31
+ # A repeated creation request could create a second key.
32
+ def retry_only_unprocessed?
33
+ true
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,38 @@
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.md file.
4
+ # frozen_string_literal: true
5
+
6
+ module DeepL
7
+ module Requests
8
+ module Admin
9
+ class CustomTagUsageAnalytics < Base
10
+ def initialize(api, start_date, end_date, aggregate_by = nil, page = nil)
11
+ super(api)
12
+ @start_date = start_date
13
+ @end_date = end_date
14
+ @aggregate_by = aggregate_by
15
+ @page = page
16
+ end
17
+
18
+ def request
19
+ request, response = execute_request_with_retries(get_request)
20
+ Resources::CustomTagUsageReport.new(parse_json_object(response), request, response)
21
+ end
22
+
23
+ private
24
+
25
+ def query_params
26
+ params = date_range_params(@start_date, @end_date)
27
+ params['aggregate_by'] = @aggregate_by.to_s unless @aggregate_by.nil?
28
+ params['page'] = @page.to_s unless @page.nil?
29
+ params
30
+ end
31
+
32
+ def path
33
+ 'admin/analytics/custom-tags'
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
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.md file.
4
+ # frozen_string_literal: true
5
+
6
+ module DeepL
7
+ module Requests
8
+ module Admin
9
+ class ListDeveloperKeys < Base
10
+ def request
11
+ request, response = execute_request_with_retries(get_request)
12
+ keys = JSON.parse(response.body)
13
+ raise DeepL::Exceptions::Error, 'Developer key list response is not a JSON array' unless
14
+ keys.is_a?(Array)
15
+
16
+ keys.select { |key| usable_developer_key?(key) }
17
+ .map { |key| Resources::DeveloperKey.new(key, request, response) }
18
+ end
19
+
20
+ private
21
+
22
+ def path
23
+ 'admin/developer-keys'
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
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.md file.
4
+ # frozen_string_literal: true
5
+
6
+ module DeepL
7
+ module Requests
8
+ module Admin
9
+ ##
10
+ # Sends a PUT to +admin/developer-keys/<action>+. The payload is sent as is, so +nil+
11
+ # values are serialized as explicit JSON nulls; +extra_body_parameters+ are added last.
12
+ class UpdateDeveloperKey < Base
13
+ def initialize(api, action, payload, extra_body_parameters = nil)
14
+ super(api, extra_body_parameters)
15
+ @action = action
16
+ @payload = payload
17
+ end
18
+
19
+ def request
20
+ build_developer_key(*execute_request_with_retries(put_request(@payload.dup)))
21
+ end
22
+
23
+ private
24
+
25
+ def http_method
26
+ 'PUT'
27
+ end
28
+
29
+ def path
30
+ "admin/developer-keys/#{@action}"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,36 @@
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.md file.
4
+ # frozen_string_literal: true
5
+
6
+ module DeepL
7
+ module Requests
8
+ module Admin
9
+ class UsageAnalytics < Base
10
+ def initialize(api, start_date, end_date, group_by = nil)
11
+ super(api)
12
+ @start_date = start_date
13
+ @end_date = end_date
14
+ @group_by = group_by
15
+ end
16
+
17
+ def request
18
+ request, response = execute_request_with_retries(get_request)
19
+ Resources::AdminUsageReport.new(parse_json_object(response), request, response)
20
+ end
21
+
22
+ private
23
+
24
+ def query_params
25
+ params = date_range_params(@start_date, @end_date)
26
+ params['group_by'] = @group_by.to_s unless @group_by.nil?
27
+ params
28
+ end
29
+
30
+ def path
31
+ 'admin/analytics'
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -9,6 +9,41 @@ module DeepL
9
9
  # Maximum number of glossaries that may be provided via the +glossary_ids+ option.
10
10
  MAX_GLOSSARY_IDS = 5
11
11
 
12
+ # Transient network failures, which are retried: DNS (SocketError), timeouts
13
+ # (Net::OpenTimeout and Net::ReadTimeout are Timeout::Errors), refused or reset connections,
14
+ # broken pipes and unreachable hosts (SystemCallError), and connections closed early.
15
+ RETRYABLE_NETWORK_ERRORS = [Timeout::Error, SocketError, SystemCallError, EOFError].freeze
16
+
17
+ # Network failures without an HTTP response from the API. TLS failures, malformed responses
18
+ # and proxy failures (Net::ProtocolError, raised when a proxy refuses CONNECT, e.g. with 407)
19
+ # are not transient and are raised without retrying.
20
+ NETWORK_ERRORS = [*RETRYABLE_NETWORK_ERRORS, Net::HTTPBadResponse,
21
+ OpenSSL::SSL::SSLError, Net::ProtocolError].freeze
22
+
23
+ # Errors raised while building the result from a received response: the response has an
24
+ # unexpected format.
25
+ RESPONSE_FORMAT_ERRORS = [JSON::ParserError, NoMethodError, TypeError, KeyError].freeze
26
+
27
+ # Headers that describe the request the SDK builds; a caller's value is dropped.
28
+ TECHNICAL_HEADERS = %w[content-type content-length transfer-encoding host].freeze
29
+
30
+ # Wraps +request+ of every request class, so that a malformed response raises a
31
+ # DeepL::Exceptions::Error instead of a raw JSON or type error.
32
+ module ResponseFormatGuard
33
+ def request
34
+ super
35
+ rescue *RESPONSE_FORMAT_ERRORS => e
36
+ raise e unless @response_received
37
+
38
+ raise Exceptions::Error, "Unexpected response from the DeepL API: #{e.message}"
39
+ end
40
+ end
41
+
42
+ def self.inherited(subclass)
43
+ super
44
+ subclass.prepend(ResponseFormatGuard)
45
+ end
46
+
12
47
  attr_reader :api, :response, :options
13
48
 
14
49
  def initialize(api, options = {}, additional_headers = {})
@@ -24,7 +59,7 @@ module DeepL
24
59
  end
25
60
 
26
61
  def details
27
- "HTTP Headers #{headers}"
62
+ "HTTP Headers #{loggable_headers}"
28
63
  end
29
64
 
30
65
  private
@@ -34,11 +69,15 @@ module DeepL
34
69
  end
35
70
 
36
71
  def option(name)
37
- options[name.to_s] || options[name.to_sym]
72
+ return options[name.to_s] if options.key?(name.to_s)
73
+
74
+ options[name.to_sym]
38
75
  end
39
76
 
40
77
  def delete_option(name)
41
- options.delete(name.to_s) || options.delete(name.to_sym)
78
+ return options.delete(name.to_s) if options.key?(name.to_s)
79
+
80
+ options.delete(name.to_sym)
42
81
  end
43
82
 
44
83
  def set_option(name, value)
@@ -64,7 +103,7 @@ module DeepL
64
103
 
65
104
  extra_params = option(:extra_body_parameters)
66
105
  extra_params.each do |key, value|
67
- payload[key] = value
106
+ payload[key.to_s] = value
68
107
  end
69
108
  end
70
109
 
@@ -88,47 +127,116 @@ module DeepL
88
127
  api.configuration.logger&.info("Request to the DeepL API: #{self}")
89
128
  api.configuration.logger&.debug("Request details: #{details}")
90
129
  loop do
91
- resp = http_client.request(req)
92
- validate_response!(resp)
130
+ resp = nil
131
+ client = http_client
132
+ apply_request_timeout(client)
133
+ resp = client.request(req) { |response| read_complete_body(response) }
134
+ validate_response!(resp, req)
135
+ @response_received = true
93
136
  return [req, resp]
94
137
  rescue DeepL::Exceptions::Error => e
95
138
  raise e unless should_retry?(resp, e, @backoff_timer.num_retries)
96
139
 
97
- unless e.nil?
98
- api.configuration.logger&.info("Encountered a retryable exception: #{e.message}")
99
- end
100
- api.configuration.logger&.info("Starting retry #{@backoff_timer.num_retries + 1} for " \
101
- "request #{self} after sleeping for " \
102
- "#{format('%.2f', @backoff_timer.time_until_deadline)}")
103
- files_to_reset.each(&:rewind)
104
- @backoff_timer.sleep_until_deadline
140
+ wait_before_retry(e, files_to_reset)
105
141
  next
106
- rescue Net::HTTPBadResponse, Net::HTTPServerError, Net::HTTPFatalError, Timeout::Error,
107
- SocketError => e
108
- unless e.nil?
109
- api.configuration.logger&.info("Encountered a retryable exception: #{e.message}")
110
- end
111
- api.configuration.logger&.info("Starting retry #{@backoff_timer.num_retries + 1} for " \
112
- "request #{self} after sleeping for " \
113
- "#{format('%.2f', @backoff_timer.time_until_deadline)}")
114
- files_to_reset.each(&:rewind)
115
- @backoff_timer.sleep_until_deadline
142
+ rescue *NETWORK_ERRORS => e
143
+ raise_network_error(e) unless retry_network_error?(e)
144
+
145
+ wait_before_retry(e, files_to_reset)
116
146
  next
117
147
  end
118
148
  end
119
149
 
150
+ # Until the next major version, the module-level API raises the original network error.
151
+ def raise_network_error(error)
152
+ if api.client?
153
+ raise Exceptions::ConnectionError.from_network_error(
154
+ error, should_retry: !retry_only_unprocessed? || unprocessed_error?(error)
155
+ )
156
+ end
157
+
158
+ raise error
159
+ end
160
+
161
+ # Before net-http 0.3, Net::HTTP cannot raise at EOF of a short body (+ignore_eof+), so the
162
+ # body is compared with Content-Length. An inflated body has no comparable length.
163
+ def read_complete_body(response)
164
+ encoding = response['content-encoding']
165
+ expected = response.content_length unless response.chunked?
166
+ body = response.read_body
167
+ return if body.nil? || expected.nil? || !(encoding.nil? || encoding == 'identity')
168
+ return if body.bytesize >= expected
169
+
170
+ raise EOFError, "Response body ended after #{body.bytesize} of #{expected} bytes"
171
+ end
172
+
173
+ # Per attempt, the timeout is the configured timeout or, if longer, the time until the
174
+ # backoff deadline. Timeouts given in HTTPClientOptions take precedence.
175
+ def apply_request_timeout(client) # rubocop:disable Metrics/AbcSize
176
+ timeout = api.configuration.timeout
177
+ return if timeout.nil? || !client.respond_to?(:read_timeout=)
178
+
179
+ timeout = [timeout.to_f, @backoff_timer.time_until_deadline].max
180
+ options = http_client_options_of(client)
181
+ client.open_timeout = timeout if options&.open_timeout.nil?
182
+ client.read_timeout = timeout if options&.read_timeout.nil?
183
+ client.write_timeout = timeout if options&.write_timeout.nil?
184
+ end
185
+
186
+ # The HTTPClientOptions +client+ was built with, if any.
187
+ def http_client_options_of(client)
188
+ client.equal?(api.http_client) ? api.http_client_options : nil
189
+ end
190
+
191
+ def wait_before_retry(error, files_to_reset)
192
+ logger = api.configuration.logger
193
+ logger&.info("Encountered a retryable exception: #{error.message}")
194
+ logger&.info("Starting retry #{@backoff_timer.num_retries + 1} for " \
195
+ "request #{self} after sleeping for " \
196
+ "#{format('%.2f', @backoff_timer.time_until_deadline)}")
197
+ files_to_reset.each(&:rewind)
198
+ @backoff_timer.sleep_until_deadline
199
+ end
200
+
201
+ def retry_network_error?(error)
202
+ Exceptions::ConnectionError.transient?(error) &&
203
+ @backoff_timer.num_retries < api.configuration.max_network_retries &&
204
+ (!retry_only_unprocessed? || unprocessed_error?(error))
205
+ end
206
+
120
207
  def should_retry?(response, exception, num_retries)
121
208
  return false if num_retries >= api.configuration.max_network_retries
122
209
  return exception.should_retry? if response.nil?
123
210
 
124
- response.is_a?(Net::HTTPTooManyRequests) || response.is_a?(Net::HTTPInternalServerError)
211
+ return response.is_a?(Net::HTTPTooManyRequests) if retry_only_unprocessed?
212
+
213
+ response.is_a?(Net::HTTPTooManyRequests) || (500..599).cover?(response.code.to_i)
214
+ end
215
+
216
+ # Requests that are unsafe to repeat return true to retry only failures that show the
217
+ # request was not processed: HTTP 429, DNS resolution errors (SocketError), refused
218
+ # connections and connection timeouts (Net::OpenTimeout).
219
+ def retry_only_unprocessed?
220
+ false
221
+ end
222
+
223
+ def unprocessed_error?(error)
224
+ error.is_a?(SocketError) || error.is_a?(Net::OpenTimeout) ||
225
+ error.is_a?(Errno::ECONNREFUSED)
226
+ end
227
+
228
+ # The request body: the SDK's payload, then the options, then +extra_body_parameters+,
229
+ # which replace an SDK field of the same name.
230
+ def json_body(payload)
231
+ body = payload.merge(options_without_extra_params).transform_keys(&:to_s)
232
+ apply_extra_body_parameters_to_json(body)
233
+ body.to_json
125
234
  end
126
235
 
127
236
  def post_request(payload)
128
- apply_extra_body_parameters_to_json(payload)
129
237
  http_headers = add_json_content_type(headers)
130
238
  post_req = Net::HTTP::Post.new(uri.path, http_headers)
131
- post_req.body = payload.merge(options_without_extra_params).to_json
239
+ post_req.body = json_body(payload)
132
240
  post_req
133
241
  end
134
242
 
@@ -139,89 +247,131 @@ module DeepL
139
247
  post_req
140
248
  end
141
249
 
250
+ # GET and DELETE send no body, so no Content-Type either.
142
251
  def get_request # rubocop:disable Naming/AccessorMethodName
143
- http_headers = add_json_content_type(headers)
144
- get_req = Net::HTTP::Get.new(uri.path, http_headers)
145
- get_req.body = options_without_extra_params.to_json
146
- get_req
252
+ Net::HTTP::Get.new(uri.path, headers)
147
253
  end
148
254
 
149
255
  def patch_request(payload)
150
- apply_extra_body_parameters_to_json(payload)
151
256
  http_headers = add_json_content_type(headers)
152
257
  req = Net::HTTP::Patch.new(uri.path, http_headers)
153
- req.body = payload.merge(options_without_extra_params).to_json
258
+ req.body = json_body(payload)
154
259
  req
155
260
  end
156
261
 
157
262
  def put_request(payload)
158
- apply_extra_body_parameters_to_json(payload)
159
263
  http_headers = add_json_content_type(headers)
160
264
  req = Net::HTTP::Put.new(uri.path, http_headers)
161
- req.body = payload.merge(options_without_extra_params).to_json
265
+ req.body = json_body(payload)
162
266
  req
163
267
  end
164
268
 
165
269
  def delete_request
166
- http_headers = add_json_content_type(headers)
167
- del_req = Net::HTTP::Delete.new(uri.path, http_headers)
168
- del_req.body = options_without_extra_params.to_json
169
- del_req
270
+ Net::HTTP::Delete.new(uri.path, headers)
170
271
  end
171
272
 
172
- def validate_response!(response)
273
+ def validate_response!(response, request = nil)
173
274
  return if response.is_a?(Net::HTTPSuccess)
275
+ # A request that is unsafe to repeat may have been processed before the server failed.
276
+ if retry_only_unprocessed? && (500..599).cover?(response.code.to_i)
277
+ raise Exceptions::ServerError.new(response, request, should_retry: false)
278
+ end
174
279
 
175
- raise Utils::ExceptionBuilder.new(response).build
280
+ raise Utils::ExceptionBuilder.new(response, request).build
176
281
  end
177
282
 
178
283
  # Validates the +glossary_ids+ option and resolves it to an array of glossary ID strings.
179
284
  #
180
285
  # The +glossary_ids+ option accepts an array of up to `MAX_GLOSSARY_IDS` glossary
181
- # identifiers. Each entry may be a glossary ID string or a
182
- # `DeepL::Resources::Glossary` object. It:
183
- # * requires +source_lang+ to be set,
184
- # * cannot be combined with the singular +glossary_id+ option,
286
+ # identifiers. Each entry may be a glossary ID string, a
287
+ # `DeepL::Resources::Glossary` or a `DeepL::Resources::MultilingualGlossary` object. It:
288
+ # * cannot be combined with the singular +glossary+ or +glossary_id+ option,
185
289
  # * allows at most `MAX_GLOSSARY_IDS` identifiers.
186
290
  #
187
291
  # The API expects +glossary_ids+ as an array (a JSON array in the JSON translate body).
188
292
  # Callers sending multipart/form-data (e.g. document upload) should join the returned
189
293
  # array into a comma-separated string.
190
294
  #
191
- # @param [String, nil] source_lang The source language for the request.
192
295
  # @return [Array<String>, nil] The list of glossary IDs, or nil if the +glossary_ids+
193
296
  # option was not provided.
194
297
  # @raise [ArgumentError] If any of the validation rules above are violated.
195
- def build_glossary_ids_param(source_lang)
298
+ def build_glossary_ids_param
196
299
  return nil unless option?(:glossary_ids)
197
300
 
198
301
  ids = Array(delete_option(:glossary_ids))
199
302
  return nil if ids.empty?
200
303
 
201
- validate_glossary_ids!(ids, source_lang)
202
- ids.map do |glossary|
203
- glossary.is_a?(DeepL::Resources::Glossary) ? glossary.id : glossary.to_s
204
- end
304
+ validate_glossary_ids!(ids)
305
+ ids.map { |glossary| glossary_id_string(glossary) }
205
306
  end
206
307
 
207
- def validate_glossary_ids!(ids, source_lang)
208
- if option?(:glossary_id)
308
+ # Resolves the +glossary+ option (a glossary ID string, a `DeepL::Resources::Glossary` or
309
+ # a `DeepL::Resources::MultilingualGlossary`) to the ID sent as +glossary_id+, and warns
310
+ # about the deprecated +glossary_id+ option.
311
+ #
312
+ # @return [String, nil] The glossary ID, or nil if the +glossary+ option is unset.
313
+ # @raise [ArgumentError] If +glossary+ is combined with +glossary_id+.
314
+ def build_glossary_param
315
+ warn_glossary_id_deprecated unless option(:glossary_id).nil?
316
+ return nil unless option?(:glossary)
317
+
318
+ glossary = delete_option(:glossary)
319
+ return nil if glossary.nil?
320
+
321
+ unless option(:glossary_id).nil?
209
322
  raise ArgumentError,
210
- 'The `glossary_ids` option cannot be used together with the `glossary_id` option'
211
- end
212
- if source_lang.nil?
213
- raise ArgumentError, 'The `glossary_ids` option requires `source_lang` to be set'
323
+ 'The `glossary` option cannot be used together with the `glossary_id` option'
214
324
  end
325
+ glossary_id_string(glossary)
326
+ end
327
+
328
+ def warn_glossary_id_deprecated
329
+ Deprecation.warn_once_at_caller(
330
+ 'option glossary_id',
331
+ '[DEPRECATION] The glossary_id option is deprecated, use the glossary option instead.'
332
+ )
333
+ end
334
+
335
+ def glossary_id_string(glossary)
336
+ return glossary.id if glossary.is_a?(DeepL::Resources::Glossary)
337
+ return glossary.id if glossary.is_a?(DeepL::Resources::MultilingualGlossary)
338
+
339
+ glossary.to_s
340
+ end
341
+
342
+ def validate_glossary_ids!(ids)
343
+ validate_no_single_glossary!
215
344
  return unless ids.size > MAX_GLOSSARY_IDS
216
345
 
217
346
  raise ArgumentError,
218
347
  "A maximum of #{MAX_GLOSSARY_IDS} glossary IDs may be provided via `glossary_ids`"
219
348
  end
220
349
 
350
+ def validate_no_single_glossary!
351
+ single = if !option(:glossary).nil? then 'glossary'
352
+ elsif !option(:glossary_id).nil? then 'glossary_id'
353
+ end
354
+ return unless single
355
+
356
+ raise ArgumentError,
357
+ "The `glossary_ids` option cannot be used together with the `#{single}` option"
358
+ end
359
+
221
360
  def path
222
361
  raise NotImplementedError
223
362
  end
224
363
 
364
+ # URL-encodes a value for use as one path segment (a space as %20, not +).
365
+ def escape_path_segment(value)
366
+ URI.encode_www_form_component(value.to_s).gsub('+', '%20')
367
+ end
368
+
369
+ # URL-encodes +params+ as a query string per RFC 3986. encode_www_form writes a space as +,
370
+ # which is right for a form body only; a literal + is already %2B, so every + is a space.
371
+ def encode_query(params)
372
+ URI.encode_www_form(params).gsub('+', '%20')
373
+ end
374
+
225
375
  def url
226
376
  "#{host}/#{api.configuration.version}/#{path}"
227
377
  end
@@ -245,9 +395,29 @@ module DeepL
245
395
  options
246
396
  end
247
397
 
398
+ # The configured +headers+, then the per-call additional headers, each replace a header of
399
+ # the same name in any casing, Authorization and User-Agent included.
248
400
  def headers
249
- { 'Authorization' => "DeepL-Auth-Key #{api.configuration.auth_key}",
250
- 'User-Agent' => api.configuration.user_agent }.merge(@additional_headers)
401
+ library = { 'Authorization' => "DeepL-Auth-Key #{api.configuration.auth_key.to_s.strip}",
402
+ 'User-Agent' => api.configuration.user_agent }
403
+ merge_headers(library, api.configuration.headers, @additional_headers)
404
+ end
405
+
406
+ def merge_headers(base, *given_headers)
407
+ given_headers.each_with_object(base) do |given, merged|
408
+ (given || {}).each do |name, value|
409
+ next if TECHNICAL_HEADERS.include?(name.to_s.downcase)
410
+
411
+ merged.delete_if { |existing, _| existing.to_s.casecmp?(name.to_s) }
412
+ merged[name] = value
413
+ end
414
+ end
415
+ end
416
+
417
+ def loggable_headers
418
+ headers.to_h do |name, value|
419
+ name.to_s.casecmp?('authorization') ? [name, 'DeepL-Auth-Key [REDACTED]'] : [name, value]
420
+ end
251
421
  end
252
422
 
253
423
  def add_json_content_type(headers_to_add_to)