translation_diff 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +84 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +11 -0
  5. data/.rubocop.yml +39 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +723 -0
  8. data/Gemfile +61 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +158 -0
  11. data/Rakefile +41 -0
  12. data/data/languages/azure.json +285 -0
  13. data/data/languages/deepl.json +220 -0
  14. data/data/languages/google.json +399 -0
  15. data/data/languages/modernmt.json +413 -0
  16. data/docs/caching.md +185 -0
  17. data/docs/configuration.md +205 -0
  18. data/docs/contracts.md +187 -0
  19. data/docs/development.md +34 -0
  20. data/docs/errors.md +92 -0
  21. data/docs/how-it-works.md +145 -0
  22. data/docs/instrumentation.md +96 -0
  23. data/docs/languages.md +94 -0
  24. data/docs/providers.md +379 -0
  25. data/docs/sql-cache.md +366 -0
  26. data/lib/generators/translation_diff/install_generator.rb +15 -0
  27. data/lib/generators/translation_diff/templates/create_translation_diff_tables.rb.erb +24 -0
  28. data/lib/translation_diff/active_record/support.rb +34 -0
  29. data/lib/translation_diff/active_record.rb +3 -0
  30. data/lib/translation_diff/batch.rb +98 -0
  31. data/lib/translation_diff/call_preparation.rb +47 -0
  32. data/lib/translation_diff/capabilities.rb +9 -0
  33. data/lib/translation_diff/configuration/cache_guard_options.rb +39 -0
  34. data/lib/translation_diff/configuration/cache_ttl_option.rb +30 -0
  35. data/lib/translation_diff/configuration/option_table.rb +38 -0
  36. data/lib/translation_diff/configuration/provider_option_owners.rb +40 -0
  37. data/lib/translation_diff/configuration.rb +135 -0
  38. data/lib/translation_diff/context.rb +23 -0
  39. data/lib/translation_diff/dispatcher.rb +57 -0
  40. data/lib/translation_diff/document.rb +23 -0
  41. data/lib/translation_diff/errors.rb +44 -0
  42. data/lib/translation_diff/fragment.rb +33 -0
  43. data/lib/translation_diff/http_provider.rb +128 -0
  44. data/lib/translation_diff/instrumentation.rb +25 -0
  45. data/lib/translation_diff/languages/refresh.rb +70 -0
  46. data/lib/translation_diff/languages/set.rb +53 -0
  47. data/lib/translation_diff/languages.rb +30 -0
  48. data/lib/translation_diff/leaves.rb +22 -0
  49. data/lib/translation_diff/markup.rb +85 -0
  50. data/lib/translation_diff/passage.rb +149 -0
  51. data/lib/translation_diff/preview.rb +3 -0
  52. data/lib/translation_diff/previewer.rb +78 -0
  53. data/lib/translation_diff/provider.rb +91 -0
  54. data/lib/translation_diff/providers/amazon.rb +126 -0
  55. data/lib/translation_diff/providers/azure.rb +78 -0
  56. data/lib/translation_diff/providers/deepl.rb +88 -0
  57. data/lib/translation_diff/providers/google.rb +64 -0
  58. data/lib/translation_diff/providers/libretranslate.rb +65 -0
  59. data/lib/translation_diff/providers/modernmt.rb +74 -0
  60. data/lib/translation_diff/providers/null.rb +20 -0
  61. data/lib/translation_diff/providers.rb +69 -0
  62. data/lib/translation_diff/railtie.rb +12 -0
  63. data/lib/translation_diff/rate_limiters/active_record.rb +92 -0
  64. data/lib/translation_diff/rate_limiters/redis.rb +59 -0
  65. data/lib/translation_diff/rate_limiters.rb +9 -0
  66. data/lib/translation_diff/redaction.rb +45 -0
  67. data/lib/translation_diff/registry.rb +31 -0
  68. data/lib/translation_diff/segment.rb +32 -0
  69. data/lib/translation_diff/segmenters/pragmatic.rb +102 -0
  70. data/lib/translation_diff/segmenters/simple.rb +122 -0
  71. data/lib/translation_diff/segmenters.rb +4 -0
  72. data/lib/translation_diff/sentence_cache.rb +76 -0
  73. data/lib/translation_diff/stores/active_record.rb +106 -0
  74. data/lib/translation_diff/stores/memory.rb +34 -0
  75. data/lib/translation_diff/stores/redis.rb +49 -0
  76. data/lib/translation_diff/stores.rb +9 -0
  77. data/lib/translation_diff/tasks/translation_diff.rake +21 -0
  78. data/lib/translation_diff/translation/request.rb +8 -0
  79. data/lib/translation_diff/translation/response.rb +35 -0
  80. data/lib/translation_diff/translation/usage.rb +8 -0
  81. data/lib/translation_diff/translator.rb +103 -0
  82. data/lib/translation_diff/version.rb +3 -0
  83. data/lib/translation_diff.rb +93 -0
  84. data/translation_diff.gemspec +56 -0
  85. metadata +243 -0
@@ -0,0 +1,88 @@
1
+ # Talks to DeepL's REST API directly, not deepl-rb: it logged the auth key at DEBUG and defaulted notranslate off.
2
+ class TranslationDiff::Providers::DeepL < TranslationDiff::HTTPProvider
3
+ PAID_HOST = "https://api.deepl.com".freeze
4
+ FREE_HOST = "https://api-free.deepl.com".freeze
5
+
6
+ # A key ending in :fx is a free-plan key, and the free plan lives on its own host.
7
+ FREE_KEY_SUFFIX = ":fx".freeze
8
+
9
+ # DeepL honours class="notranslate" only under HTML tag handling -- otherwise content translates, tags survive.
10
+ DEFAULT_OPTIONS = { tag_handling: :html, tag_handling_version: "v2" }.freeze
11
+
12
+ # 50 texts / 128 KiB are DeepL's documented per-request limits; max_batch_size was wrong before (it said 300).
13
+ def self.capabilities
14
+ TranslationDiff::Capabilities.new(
15
+ max_request_size: 1_700, max_batch_size: 50, max_text_size: nil,
16
+ html: :tag_handling, notranslate: true, detects_language: true, reports_billing: true
17
+ )
18
+ end
19
+
20
+ # DEEPL_AUTH_KEY is what deepl-rb read on our behalf; the callable keeps it read on use, not at load.
21
+ def self.configuration_options
22
+ [:deepl_api_base, { deepl_api_key: -> { ENV.fetch("DEEPL_AUTH_KEY", nil) } }]
23
+ end
24
+
25
+ def self.configuration_requirements = %i[deepl_api_key]
26
+
27
+ # DeepL is the one vendor documenting upper-case codes.
28
+ def self.language_case = :upcase
29
+
30
+ # DeepL requires a target language even when only detection is wanted, so the provider picks one.
31
+ DETECTION_TARGET = "EN".freeze
32
+
33
+ def api_base
34
+ config.deepl_api_base || (free_key? ? FREE_HOST : PAID_HOST)
35
+ end
36
+
37
+ def headers = { "Authorization" => "DeepL-Auth-Key #{config.deepl_api_key}" }
38
+
39
+ def translate_url = "v2/translate"
40
+
41
+ def render_translate_payload(request)
42
+ DEFAULT_OPTIONS
43
+ .merge(request.options)
44
+ .merge(text: request.texts, target_lang: language(request.to))
45
+ .tap { |payload| payload[:source_lang] = language(request.from) unless request.from.nil? }
46
+ end
47
+
48
+ def parse_translate_response(body, _headers, request)
49
+ translations = Array(body["translations"])
50
+
51
+ TranslationDiff::Translation::Response.build(
52
+ request: request,
53
+ texts: translations.map { |t| t["text"] },
54
+ detected_source: translations.first&.dig("detected_source_language")&.downcase,
55
+ usage: usage_for(request, translations)
56
+ )
57
+ end
58
+
59
+ # DeepL has no detection endpoint; translating a sample and reading the source it reports is the only way.
60
+ def detect(text)
61
+ request = TranslationDiff::Translation::Request.new(texts: [text], from: nil,
62
+ to: DETECTION_TARGET)
63
+ translate(request).detected_source
64
+ end
65
+
66
+ def languages
67
+ { source: codes(get("v2/languages?type=source").body),
68
+ target: codes(get("v2/languages?type=target").body) }
69
+ end
70
+
71
+ # The source-list URL is enough to document what #languages fetches; the target one differs only by query.
72
+ def languages_endpoint = "#{api_base}/v2/languages"
73
+
74
+ private
75
+
76
+ def codes(body) = Array(body).map { |entry| entry["language"] }
77
+
78
+ def free_key? = config.deepl_api_key.to_s.end_with?(FREE_KEY_SUFFIX)
79
+
80
+ def usage_for(request, translations)
81
+ TranslationDiff::Translation::Usage.new(
82
+ characters: request.texts.sum(&:size),
83
+ billed_characters: billed_characters(translations.map { |t| t["billed_characters"] })
84
+ )
85
+ end
86
+ end
87
+
88
+ TranslationDiff::Providers.register(:deepl, TranslationDiff::Providers::DeepL)
@@ -0,0 +1,64 @@
1
+ # Talks to Cloud Translation v2 directly, not google-cloud-translate-v2, which pulled in grpc for one POST.
2
+ class TranslationDiff::Providers::Google < TranslationDiff::HTTPProvider
3
+ HOST = "https://translation.googleapis.com".freeze
4
+
5
+ # Verified against the live API: `text` format translates the protected span and drops its markup.
6
+ DEFAULT_FORMAT = :html
7
+
8
+ # Google's documented limits: 128 strings/request, 5,000 chars recommended (hard ceiling 100 KB).
9
+ def self.capabilities
10
+ TranslationDiff::Capabilities.new(
11
+ max_request_size: 5_000, max_batch_size: 128, max_text_size: nil,
12
+ html: :format, notranslate: true, detects_language: true, reports_billing: false
13
+ )
14
+ end
15
+
16
+ # TRANSLATE_KEY then GOOGLE_CLOUD_KEY, the order google-cloud-translate-v2 read them in.
17
+ def self.configuration_options
18
+ [:google_api_base,
19
+ { google_api_key: -> { ENV.fetch("TRANSLATE_KEY", nil) || ENV.fetch("GOOGLE_CLOUD_KEY", nil) },
20
+ google_project_id: -> { ENV.fetch("TRANSLATE_PROJECT", nil) } }]
21
+ end
22
+
23
+ def self.configuration_requirements = %i[google_api_key]
24
+
25
+ def api_base = config.google_api_base || HOST
26
+ def translate_url = "language/translate/v2?key=#{CGI.escape(config.google_api_key.to_s)}"
27
+ def detect_url = "language/translate/v2/detect?key=#{CGI.escape(config.google_api_key.to_s)}"
28
+
29
+ def render_translate_payload(request)
30
+ { format: DEFAULT_FORMAT }
31
+ .merge(request.options)
32
+ .merge(q: request.texts, target: language(request.to))
33
+ .tap { |payload| payload[:source] = language(request.from) unless request.from.nil? }
34
+ end
35
+
36
+ def parse_translate_response(body, _headers, request)
37
+ translations = Array(body.dig("data", "translations"))
38
+
39
+ TranslationDiff::Translation::Response.build(
40
+ request: request,
41
+ texts: translations.map { |t| t["translatedText"] },
42
+ detected_source: translations.first&.dig("detectedSourceLanguage")&.downcase,
43
+ usage: TranslationDiff::Translation::Usage.new(characters: request.texts.sum(&:size))
44
+ )
45
+ end
46
+
47
+ def detect(text)
48
+ response = post(detect_url, { q: [text] })
49
+ response.body.dig("data", "detections", 0, 0, "language")&.downcase
50
+ end
51
+
52
+ # One list, used in both directions.
53
+ def languages
54
+ codes = Array(get("language/translate/v2/languages?key=#{CGI.escape(config.google_api_key.to_s)}")
55
+ .body.dig("data", "languages")).map { |entry| entry["language"] }
56
+
57
+ { source: codes, target: codes }
58
+ end
59
+
60
+ # The key is left out: it is a credential, not part of what documents where this list comes from.
61
+ def languages_endpoint = "#{api_base}/language/translate/v2/languages"
62
+ end
63
+
64
+ TranslationDiff::Providers.register(:google, TranslationDiff::Providers::Google)
@@ -0,0 +1,65 @@
1
+ # The only free, self-hosted provider here; base URL is required (everyone runs their own), API key is optional.
2
+ class TranslationDiff::Providers::LibreTranslate < TranslationDiff::HTTPProvider
3
+ DEFAULT_FORMAT = "html".freeze
4
+
5
+ # The API's own way of asking for detection: `source` is required, and "auto" means "work it out".
6
+ AUTO = "auto".freeze
7
+
8
+ # Observed 2026-09-09 via Docker: LibreTranslate's HTML format preserves markup but translates content anyway.
9
+ LIBRETRANSLATE_HONOURS_NOTRANSLATE = false
10
+
11
+ # LibreTranslate publishes no per-request limits; these are this library's own conservative numbers.
12
+ def self.capabilities
13
+ TranslationDiff::Capabilities.new(
14
+ max_request_size: 5_000, max_batch_size: 50, max_text_size: nil,
15
+ html: :format, notranslate: LIBRETRANSLATE_HONOURS_NOTRANSLATE,
16
+ detects_language: true, reports_billing: false
17
+ )
18
+ end
19
+
20
+ def self.configuration_options = %i[libretranslate_api_key libretranslate_api_base]
21
+ def self.configuration_requirements = %i[libretranslate_api_base]
22
+
23
+ def api_base = config.libretranslate_api_base
24
+ def translate_url = "translate"
25
+
26
+ def render_translate_payload(request)
27
+ { format: DEFAULT_FORMAT }
28
+ .merge(request.options)
29
+ .merge(q: request.texts, target: language(request.to),
30
+ source: request.from.nil? ? AUTO : language(request.from))
31
+ .tap { |payload| payload[:api_key] = config.libretranslate_api_key if config.libretranslate_api_key }
32
+ end
33
+
34
+ def parse_translate_response(body, _headers, request)
35
+ translated = body["translatedText"]
36
+ detected = body["detectedLanguage"]
37
+ detected = detected.first if detected.is_a?(Array)
38
+
39
+ TranslationDiff::Translation::Response.build(
40
+ request: request,
41
+ texts: translated.is_a?(Array) ? translated : [translated].compact,
42
+ detected_source: detected.is_a?(Hash) ? detected["language"]&.downcase : nil,
43
+ usage: TranslationDiff::Translation::Usage.new(characters: request.texts.sum(&:size))
44
+ )
45
+ end
46
+
47
+ def detect(text)
48
+ payload = { q: text }
49
+ payload[:api_key] = config.libretranslate_api_key if config.libretranslate_api_key
50
+
51
+ post("detect", payload).body.dig(0, "language")&.downcase
52
+ end
53
+
54
+ # Each entry lists its own targets; a self-hosted instance answers for itself, which is the point.
55
+ def languages
56
+ entries = Array(get("languages").body)
57
+
58
+ { source: entries.map { |entry| entry["code"] },
59
+ target: entries.flat_map { |entry| Array(entry["targets"]) }.uniq }
60
+ end
61
+
62
+ def languages_endpoint = "#{api_base}/languages"
63
+ end
64
+
65
+ TranslationDiff::Providers.register(:libretranslate, TranslationDiff::Providers::LibreTranslate)
@@ -0,0 +1,74 @@
1
+ # ModernMT: adaptive translation with translation memories.
2
+ class TranslationDiff::Providers::ModernMT < TranslationDiff::HTTPProvider
3
+ HOST = "https://api.modernmt.com".freeze
4
+
5
+ # ModernMT spells its formats as MIME types.
6
+ DEFAULT_FORMAT = "text/html".freeze
7
+
8
+ # Unverified, not observed: no key was available to probe it; false is the safe assumption either way.
9
+ MODERNMT_HONOURS_NOTRANSLATE = false
10
+
11
+ # 128 texts is documented; the character limit is not, so Google's 5,000 recommendation is borrowed.
12
+ def self.capabilities
13
+ TranslationDiff::Capabilities.new(
14
+ max_request_size: 5_000, max_batch_size: 128, max_text_size: nil,
15
+ html: :format, notranslate: MODERNMT_HONOURS_NOTRANSLATE,
16
+ detects_language: true, reports_billing: true
17
+ )
18
+ end
19
+
20
+ def self.configuration_options = %i[modernmt_api_key modernmt_api_base]
21
+ def self.configuration_requirements = %i[modernmt_api_key]
22
+
23
+ def api_base = config.modernmt_api_base || HOST
24
+ def headers = { "MMT-ApiKey" => config.modernmt_api_key.to_s }
25
+ def translate_url = "translate"
26
+
27
+ def render_translate_payload(request)
28
+ { format: DEFAULT_FORMAT }
29
+ .merge(request.options)
30
+ .merge(q: request.texts, target: language(request.to))
31
+ .tap { |payload| payload[:source] = language(request.from) unless request.from.nil? }
32
+ end
33
+
34
+ # One text comes back as an object rather than a one-element array, so the envelope is always coerced.
35
+ def parse_translate_response(body, _headers, request)
36
+ results = results_from(body)
37
+
38
+ TranslationDiff::Translation::Response.build(
39
+ request: request,
40
+ texts: results.map { |r| r["translation"] },
41
+ detected_source: results.first&.dig("detectedLanguage")&.downcase,
42
+ usage: usage_for(request, results)
43
+ )
44
+ end
45
+
46
+ def detect(text)
47
+ request = TranslationDiff::Translation::Request.new(texts: [text], from: nil, to: "en")
48
+ translate(request).detected_source
49
+ end
50
+
51
+ def languages
52
+ codes = Array(get("translate/languages").body["data"])
53
+
54
+ { source: codes, target: codes }
55
+ end
56
+
57
+ def languages_endpoint = "#{api_base}/translate/languages"
58
+
59
+ private
60
+
61
+ def results_from(body)
62
+ data = body["data"]
63
+ data.is_a?(Array) ? data : [data].compact
64
+ end
65
+
66
+ def usage_for(request, results)
67
+ TranslationDiff::Translation::Usage.new(
68
+ characters: request.texts.sum(&:size),
69
+ billed_characters: billed_characters(results.map { |r| r["billedCharacters"] })
70
+ )
71
+ end
72
+ end
73
+
74
+ TranslationDiff::Providers.register(:modernmt, TranslationDiff::Providers::ModernMT)
@@ -0,0 +1,20 @@
1
+ # Hands back what it was given -- for tests, and for wiring a pipeline up before a real provider is available.
2
+ class TranslationDiff::Providers::Null < TranslationDiff::Provider
3
+ # Deliberately not detecting: this is the provider that proves the optional branch works.
4
+ def self.capabilities
5
+ TranslationDiff::Capabilities.new(
6
+ max_request_size: 1_000_000, max_batch_size: 1_000_000, max_text_size: nil,
7
+ html: :none, notranslate: false, detects_language: false, reports_billing: false
8
+ )
9
+ end
10
+
11
+ def translate(request)
12
+ TranslationDiff::Translation::Response.build(
13
+ request: request, texts: request.texts.map(&:to_s)
14
+ )
15
+ end
16
+
17
+ def cache_key = "null"
18
+ end
19
+
20
+ TranslationDiff::Providers.register(:null, TranslationDiff::Providers::Null)
@@ -0,0 +1,69 @@
1
+ # Translation providers, by name; registering one also declares its configuration options.
2
+ module TranslationDiff::Providers
3
+ # Said once, so a provider refused by name, by class or as an object is refused in the same words.
4
+ CONTRACT = "The base class supplies the transport, the configuration check and the " \
5
+ "capability defaults, so a provider that skips it has none of them.".freeze
6
+
7
+ class << self
8
+ # Options are declared before the registry entry is written, so a name collision raises without replacing.
9
+ def register(name, klass)
10
+ ensure_provider_class!(klass)
11
+ TranslationDiff::Configuration.register_provider_options(klass.configuration_options, klass)
12
+ registry.register(name, klass)
13
+ end
14
+
15
+ # Guards registration. `klass < Provider` alone raises NoMethodError for an instance or a non-Module.
16
+ def ensure_provider_class!(klass)
17
+ return klass if klass.is_a?(Class) && klass < TranslationDiff::Provider
18
+
19
+ raise TranslationDiff::InvalidProviderError,
20
+ "#{describe(klass)} cannot be registered as a provider: the registry takes a " \
21
+ "class inheriting TranslationDiff::Provider. #{CONTRACT}"
22
+ end
23
+
24
+ # Guards the other two ways a provider arrives: assigned to config.provider, or passed as `provider:`.
25
+ def ensure_provider!(instance)
26
+ return instance if instance.is_a?(TranslationDiff::Provider)
27
+
28
+ raise TranslationDiff::InvalidProviderError,
29
+ "#{describe(instance)} cannot be used as a provider: it does not inherit " \
30
+ "TranslationDiff::Provider. #{CONTRACT}"
31
+ end
32
+
33
+ def build(name, config)
34
+ registry.build(name, config).tap { |provider| provider.name = name.to_sym }
35
+ end
36
+
37
+ # The one seam Translator and Previewer both resolve a provider through, cache_key guard included.
38
+ def resolve(requested, config)
39
+ provider = requested.nil? ? config.provider_instance : resolve_requested(requested, config)
40
+ ensure_cache_key!(provider)
41
+ end
42
+
43
+ def registered?(name) = registry.registered?(name)
44
+ def names = registry.names
45
+ def classes = registry.classes
46
+ def registry = @registry ||= TranslationDiff::Registry.new("provider")
47
+
48
+ private
49
+
50
+ # A provider arrives as a name to build, or as an object to use as it is.
51
+ def resolve_requested(requested, config)
52
+ return build(requested, config) if requested.is_a?(Symbol) || requested.is_a?(String)
53
+
54
+ ensure_provider!(requested)
55
+ end
56
+
57
+ # The cache key names the provider in every payload too: it is the one identifier every provider must have.
58
+ def ensure_cache_key!(provider)
59
+ return provider unless provider.cache_key.to_s.strip.empty?
60
+
61
+ raise TranslationDiff::InvalidProviderError,
62
+ "#{provider.class} must define #cache_key: a blank one would file its " \
63
+ "translations in every other provider's cache namespace."
64
+ end
65
+
66
+ # Never #to_s on a non-Module: an arbitrary object renders its own content, or an address.
67
+ def describe(value) = value.is_a?(Module) ? value.to_s : "an instance of #{value.class}"
68
+ end
69
+ end
@@ -0,0 +1,12 @@
1
+ # Loaded only when Rails already is (see the guarded require in translation_diff.rb), never on its own.
2
+ require "active_support/core_ext/module/delegation"
3
+ require "rails/railtie"
4
+
5
+ # Adds translation_diff:prune to a host Rails application's own rake tasks; the gem's dev Rakefile loads the
6
+ # same task file directly, so a host application's `rake -T` and this gem's own suite see one definition.
7
+ class TranslationDiff::Railtie < Rails::Railtie
8
+ rake_tasks do
9
+ load File.expand_path("tasks/translation_diff.rake", __dir__)
10
+ Rake::Task["translation_diff:prune"].enhance(["environment"])
11
+ end
12
+ end
@@ -0,0 +1,92 @@
1
+ # Throttles by counting characters into namespaced, time-bucketed rows in the application's own database.
2
+ class TranslationDiff::RateLimiters::ActiveRecord
3
+ include TranslationDiff::ActiveRecord::Support
4
+
5
+ DEFAULT_THRESHOLD = 8000
6
+ DEFAULT_INTERVAL = 60
7
+
8
+ # A bucket a twelfth of the interval wide caps a window's slop at under 10%, the same shape the `ratelimit` gem
9
+ # gets from its own fixed five-second buckets at the default 60-second interval.
10
+ BUCKET_FRACTION = 12
11
+
12
+ # An unset rate_limit must mean DEFAULT_THRESHOLD, not the nil that would override that keyword default.
13
+ def self.build(config)
14
+ options = { namespace: config.cache_namespace, table_name: config.rate_limit_table_name,
15
+ interval: config.rate_interval, base: config.active_record_base }
16
+ options[:threshold] = config.rate_limit unless config.rate_limit.nil?
17
+ new(**options)
18
+ end
19
+
20
+ def initialize(namespace:, table_name:, threshold: DEFAULT_THRESHOLD, interval: DEFAULT_INTERVAL, base: nil,
21
+ clock: -> { Time.now })
22
+ @namespace = namespace
23
+ @table_name = table_name
24
+ @threshold = threshold
25
+ @interval = interval
26
+ @base = base
27
+ @clock = clock
28
+ @bucket_width = [@interval / BUCKET_FRACTION, 1].max
29
+ end
30
+
31
+ # A sliding window: every bucket covering the last `interval` seconds is summed, not just the current one.
32
+ def check(size)
33
+ raise TranslationDiff::RateLimitExceeded, exceeded_message if current_total >= @threshold
34
+
35
+ add(size)
36
+ rescue StandardError => e
37
+ raise unless ar_error?(e)
38
+
39
+ raise redacted_error(e), cause: nil
40
+ end
41
+
42
+ # The limiter's own statements carry counts, not content -- but a ReadOnlyError quotes the statement, and
43
+ # a raw ActiveRecord error from inside a translate call tells a caller nothing about which gem it came from.
44
+ def redacted_error(error)
45
+ adapter_error = error.cause&.class || error.class
46
+ TranslationDiff::Error.new("the rate limit check failed (#{adapter_error}): a read or upsert on " \
47
+ "#{@table_name}(namespace, bucket, characters)")
48
+ end
49
+
50
+ # Counts and settings, never a character of what was being translated.
51
+ def exceeded_message
52
+ "rate limit reached for #{@namespace}: #{@threshold} characters per #{@interval} seconds"
53
+ end
54
+
55
+ # Buckets that have fully aged out of the window as of now; the oldest bucket itself is still counted by it.
56
+ def prune = model.where(namespace: @namespace).where(bucket: ...oldest_bucket).delete_all
57
+
58
+ private
59
+
60
+ # The oldest bucket is only ever partially inside the window, so summing from it, not past it, errs strict.
61
+ def current_total
62
+ model.where(namespace: @namespace, bucket: oldest_bucket..current_bucket).sum(:characters)
63
+ end
64
+
65
+ def current_bucket = now / @bucket_width
66
+
67
+ def oldest_bucket = (now - @interval) / @bucket_width
68
+
69
+ def now = @clock.call.to_i
70
+
71
+ # One statement, so two processes incrementing the same bucket cannot lose an increment between them.
72
+ # A negative size would otherwise hand back headroom it never used, so it is clamped before it reaches SQL.
73
+ def add(size)
74
+ size = size.to_i.clamp(0..)
75
+ model.upsert_all([{ namespace: @namespace, bucket: current_bucket, characters: size }],
76
+ **upsert_options(model.connection, size))
77
+ end
78
+
79
+ # MySQL's adapter never answers true here and its ON DUPLICATE KEY UPDATE already targets every unique key.
80
+ def upsert_options(connection, size)
81
+ table = connection.quote_table_name(model.table_name)
82
+ options = { on_duplicate: Arel.sql("characters = #{table}.characters + #{size}") }
83
+ options[:unique_by] = %i[namespace bucket] if connection.supports_insert_conflict_target?
84
+ options
85
+ end
86
+
87
+ def active_record_feature = "the rate limiter"
88
+ def active_record_component = "ActiveRecord rate limiter"
89
+ def active_record_upsert_detail = "upsert_all takes unique_by there."
90
+ end
91
+
92
+ TranslationDiff::RateLimiters.register(:active_record, TranslationDiff::RateLimiters::ActiveRecord)
@@ -0,0 +1,59 @@
1
+ class TranslationDiff::RateLimiters::Redis
2
+ DEFAULT_THRESHOLD = 8000
3
+ DEFAULT_INTERVAL = 60
4
+ DEFAULT_NAMESPACE = "translation-diff".freeze
5
+
6
+ # This library limits the provider as a whole rather than per caller, so there is exactly one subject.
7
+ SUBJECT = "call".freeze
8
+
9
+ # An unset rate_limit must mean DEFAULT_THRESHOLD, not the nil that would override that keyword default.
10
+ def self.build(config)
11
+ options = { interval: config.rate_interval, namespace: config.cache_namespace }
12
+ options[:threshold] = config.rate_limit unless config.rate_limit.nil?
13
+ new(config.redis_pool, **options)
14
+ end
15
+
16
+ # `connection_pool` is duck-typed to #with; neither connection_pool nor ratelimit is a hard dependency.
17
+ def initialize(connection_pool,
18
+ threshold: DEFAULT_THRESHOLD,
19
+ interval: DEFAULT_INTERVAL,
20
+ namespace: DEFAULT_NAMESPACE)
21
+ @connection_pool = connection_pool
22
+ @threshold = threshold
23
+ @interval = interval
24
+ @namespace = namespace
25
+ end
26
+
27
+ def check(size)
28
+ limiter_class = ratelimit_class
29
+
30
+ connection_pool.with do |redis|
31
+ rate_limit = limiter_class.new(namespace, redis: redis)
32
+ exceeded = rate_limit.exceeded?(SUBJECT, threshold: threshold, interval: interval)
33
+ raise TranslationDiff::RateLimitExceeded, exceeded_message if exceeded
34
+
35
+ rate_limit.add(SUBJECT, size)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # Counts and settings, never a character of what was being translated.
42
+ def exceeded_message
43
+ "rate limit reached for #{namespace}: #{threshold} characters per #{interval} seconds"
44
+ end
45
+
46
+ attr_reader :connection_pool, :threshold, :interval, :namespace
47
+
48
+ # Required at first check, not load time; naming the bare constant instead would raise a raw NameError.
49
+ def ratelimit_class
50
+ require "ratelimit"
51
+ ::Ratelimit
52
+ rescue LoadError
53
+ raise TranslationDiff::Error,
54
+ "a rate limit was configured but the `ratelimit` gem is not available. " \
55
+ 'Add `gem "ratelimit"` to your Gemfile.'
56
+ end
57
+ end
58
+
59
+ TranslationDiff::RateLimiters.register(:redis, TranslationDiff::RateLimiters::Redis)
@@ -0,0 +1,9 @@
1
+ # Rate limiters, by name; assigning an object to `config.rate_limiter` bypasses this entirely.
2
+ module TranslationDiff::RateLimiters
3
+ def self.register(name, klass) = registry.register(name, klass)
4
+ def self.build(name, config) = registry.build(name, config)
5
+ def self.registered?(name) = registry.registered?(name)
6
+ def self.names = registry.names
7
+ def self.classes = registry.classes
8
+ def self.registry = @registry ||= TranslationDiff::Registry.new("rate limiter")
9
+ end
@@ -0,0 +1,45 @@
1
+ require "uri"
2
+
3
+ # Which configuration options must never be printed, decided by name rather than by a list someone maintains.
4
+ module TranslationDiff::Redaction
5
+ SENSITIVE = /key|secret|token|password|auth|credential/
6
+ FILTERED = "[FILTERED]".freeze
7
+
8
+ def self.sensitive?(name) = name.to_s.match?(SENSITIVE)
9
+
10
+ # Unioned fresh on every call, never memoised -- a provider can register after the first inspect.
11
+ def self.declared_sensitive
12
+ TranslationDiff::Providers.classes.flat_map(&:sensitive_options).map(&:to_sym)
13
+ end
14
+
15
+ # Reads through the public accessor, or an option set only through its ENV-backed default goes unnoticed.
16
+ def self.render(config)
17
+ declared = declared_sensitive
18
+
19
+ TranslationDiff::Configuration.options.filter_map do |key|
20
+ value = config.public_send(key)
21
+ next if value.nil?
22
+
23
+ "#{key}=#{rendered_value(key, value, declared)}"
24
+ end
25
+ end
26
+
27
+ def self.rendered_value(key, value, declared)
28
+ return FILTERED if sensitive?(key) || declared.include?(key)
29
+
30
+ (redact_userinfo(value) || value).inspect
31
+ end
32
+
33
+ # redis_url and a provider's *_api_base can carry a credential inline; the host stays, only the userinfo hides.
34
+ def self.redact_userinfo(value)
35
+ return nil unless value.is_a?(String)
36
+
37
+ userinfo = URI.parse(value).userinfo
38
+ return nil unless userinfo
39
+
40
+ user, separator, = userinfo.partition(":")
41
+ value.sub(userinfo, separator.empty? ? FILTERED : "#{user}:#{FILTERED}")
42
+ rescue URI::Error
43
+ nil
44
+ end
45
+ end
@@ -0,0 +1,31 @@
1
+ # Maps a symbol to a class that builds itself from a Configuration; the whole contract is answering `build(config)`.
2
+ class TranslationDiff::Registry
3
+ # `kind` appears in the unknown-name error message, so it should be a singular noun: "provider".
4
+ def initialize(kind)
5
+ @kind = kind
6
+ @entries = {}
7
+ end
8
+
9
+ def register(name, klass)
10
+ @entries[name.to_sym] = klass
11
+ end
12
+
13
+ def build(name, config)
14
+ fetch(name).build(config)
15
+ end
16
+
17
+ def registered?(name) = @entries.key?(name.to_sym)
18
+
19
+ def names = @entries.keys
20
+
21
+ def classes = @entries.values
22
+
23
+ private
24
+
25
+ def fetch(name)
26
+ @entries.fetch(name.to_sym) do
27
+ raise TranslationDiff::Error,
28
+ "Unknown #{@kind} #{name.to_sym.inspect}. Registered: #{names.join(', ')}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ # A sentence with the whitespace it was found in: its core is the text a provider sees, its render is markup again.
2
+ class TranslationDiff::Segment
3
+ attr_reader :source, :body, :core
4
+ attr_accessor :translation
5
+
6
+ # A core is compared decoded, so a sentence that was only `&nbsp;` counts as the padding it is and is never sent.
7
+ BLANK = /\A[[:space:]]*\z/
8
+
9
+ def initialize(source)
10
+ @source = source.dup
11
+ @leading, @body, @trailing = @source.partition(/[^[:space:]].*[^[:space:]]|[^[:space:]]/m)
12
+ @core = TranslationDiff::Markup.decode_entities(@body)
13
+ end
14
+
15
+ # Reflects whether a translation is set right now, not history -- clearing it to nil flips this back to false.
16
+ def translated? = !translation.nil?
17
+
18
+ def empty? = core.match?(BLANK)
19
+
20
+ # Untranslated hands back the bytes it was cut from; a translation is text, so it is encoded as markup on the way out.
21
+ def render
22
+ "#{@leading}#{translated? ? escaped_translation : @body}#{@trailing}"
23
+ end
24
+
25
+ private
26
+
27
+ # @body already carries this same escape from Passage; without it, Passage's one shared restore pass would
28
+ # read a translated `&lt;` as a source document's own bare `<` and hand back markup nobody asked for.
29
+ def escaped_translation
30
+ TranslationDiff::Markup.escape_bare_angles(TranslationDiff::Markup.encode_translation(translation))
31
+ end
32
+ end