multilocale 0.1.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.
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Multilocale
8
+ # HTTP client for the Multilocale REST API (https://api.multilocale.com).
9
+ #
10
+ # client = Multilocale::Client.new(api_key: ENV["MULTILOCALE_API_KEY"])
11
+ # client.projects.list
12
+ # client.dictionary(project: "website", language: "es")
13
+ #
14
+ # Authentication is `Authorization: Basic base64(secret)` — the API key
15
+ # secret on its own, base64'd, with no key/secret pair and no colon. The
16
+ # secret selects both the organization and the project, so there is no
17
+ # tenant parameter to pass and no way for a key to reach another project.
18
+ class Client
19
+ DEFAULT_API_URL = "https://api.multilocale.com"
20
+ DEFAULT_OPEN_TIMEOUT = 5
21
+ DEFAULT_READ_TIMEOUT = 30
22
+ DEFAULT_MAX_RETRIES = 2
23
+ DEFAULT_RETRY_BACKOFF = 0.5
24
+
25
+ # 429 and 5xx are the transient ones. 4xx is the caller's problem and
26
+ # retrying it only burns rate limit.
27
+ RETRIABLE_STATUSES = [429, 500, 502, 503, 504].freeze
28
+ RETRIABLE_EXCEPTIONS = [
29
+ Errno::ECONNREFUSED,
30
+ Errno::ECONNRESET,
31
+ Errno::EHOSTUNREACH,
32
+ EOFError,
33
+ IOError,
34
+ Net::OpenTimeout,
35
+ Net::ReadTimeout,
36
+ SocketError
37
+ ].freeze
38
+
39
+ attr_reader :api_url, :project, :open_timeout, :read_timeout, :max_retries, :retry_backoff, :user_agent
40
+
41
+ # @param api_key [String] REST API key secret, from app.multilocale.com → API keys
42
+ # @param access_token [String] operator session token, the alternative the
43
+ # dashboard and the npm CLI use (`Authorization: Token base64(token)`).
44
+ # Prefer an API key for anything automated: it is scoped and revocable.
45
+ # @param project [String] default project id or name for calls that take one
46
+ def initialize(
47
+ api_key: ENV.fetch("MULTILOCALE_API_KEY", nil),
48
+ access_token: ENV.fetch("MULTILOCALE_ACCESS_TOKEN", nil),
49
+ api_url: ENV.fetch("MULTILOCALE_API_URL", DEFAULT_API_URL),
50
+ project: ENV.fetch("MULTILOCALE_PROJECT", nil),
51
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
52
+ read_timeout: DEFAULT_READ_TIMEOUT,
53
+ max_retries: DEFAULT_MAX_RETRIES,
54
+ retry_backoff: DEFAULT_RETRY_BACKOFF,
55
+ user_agent: nil,
56
+ logger: nil
57
+ )
58
+ api_key = nil if api_key.nil? || api_key.to_s.strip.empty?
59
+ access_token = nil if access_token.nil? || access_token.to_s.strip.empty?
60
+
61
+ if api_key.nil? && access_token.nil?
62
+ raise ConfigurationError, <<~MESSAGE
63
+ No Multilocale credential.
64
+
65
+ Create an API key at https://app.multilocale.com/keys and export it:
66
+
67
+ export MULTILOCALE_API_KEY=<the key secret>
68
+
69
+ or pass one explicitly: Multilocale::Client.new(api_key: "…").
70
+ MESSAGE
71
+ end
72
+
73
+ @api_key = api_key
74
+ @access_token = access_token
75
+ @api_url = api_url.to_s.sub(%r{/+\z}, "")
76
+ @project = project
77
+ @open_timeout = open_timeout
78
+ @read_timeout = read_timeout
79
+ @max_retries = max_retries
80
+ @retry_backoff = retry_backoff
81
+ @user_agent = user_agent || "multilocale-ruby/#{VERSION} (ruby #{RUBY_VERSION})"
82
+ @logger = logger
83
+ end
84
+
85
+ def projects
86
+ @projects ||= Projects.new(self)
87
+ end
88
+
89
+ def phrases
90
+ @phrases ||= Phrases.new(self)
91
+ end
92
+
93
+ # One language of one project as a flat key => value dictionary.
94
+ #
95
+ # `project:` falls back to the client's own when it is nil, rather than
96
+ # defaulting in the signature: callers forward an optional value here, and
97
+ # `project: nil` would otherwise silently defeat the default.
98
+ def dictionary(language:, project: nil)
99
+ Dictionary.new(
100
+ language,
101
+ phrases.list(project: project_name(project || @project), language: language)
102
+ .each_with_object({}) { |phrase, entries| entries[phrase.key] = phrase.value }
103
+ )
104
+ end
105
+
106
+ # Every language of one project, as { "en" => Dictionary, … }.
107
+ def dictionaries(project: nil, languages: nil)
108
+ rows = phrases.list(project: project_name(project || @project))
109
+ dictionaries = Dictionary.from_phrases(rows)
110
+ return dictionaries if languages.nil?
111
+
112
+ languages.each_with_object({}) do |language, selected|
113
+ selected[language] = dictionaries[language] || Dictionary.new(language, {})
114
+ end
115
+ end
116
+
117
+ # Accepts an id, a name or a Project and returns the name the phrases
118
+ # endpoints filter on — they match `projects` (names), never ids.
119
+ def project_name(project_or_id)
120
+ raise ConfigurationError, "No project given, and no default project on the client." if project_or_id.nil?
121
+ return project_or_id.name if project_or_id.is_a?(Project)
122
+ return project_or_id unless project_or_id.to_s.match?(/\A[0-9a-f]{24}\z/)
123
+
124
+ projects.find(project_or_id).name
125
+ end
126
+
127
+ def get(path, params: nil)
128
+ request(:get, path, params: params)
129
+ end
130
+
131
+ def post(path, body:)
132
+ request(:post, path, body: body)
133
+ end
134
+
135
+ def put(path, body:)
136
+ request(:put, path, body: body)
137
+ end
138
+
139
+ def delete(path, params: nil)
140
+ request(:delete, path, params: params)
141
+ end
142
+
143
+ def request(method, path, params: nil, body: nil)
144
+ url = build_url(path, params)
145
+ uri = URI.parse(url)
146
+ attempt = 0
147
+
148
+ loop do
149
+ attempt += 1
150
+
151
+ begin
152
+ response = execute(method, uri, body)
153
+ rescue *RETRIABLE_EXCEPTIONS => error
154
+ raise ConnectionError, "#{method.to_s.upcase} #{url} failed: #{error.class}: #{error.message}" if attempt > max_retries
155
+
156
+ sleep(backoff_for(attempt))
157
+ next
158
+ end
159
+
160
+ status = response.code.to_i
161
+
162
+ if RETRIABLE_STATUSES.include?(status) && attempt <= max_retries
163
+ sleep(retry_after(response) || backoff_for(attempt))
164
+ next
165
+ end
166
+
167
+ return parse(response, method: method, url: url)
168
+ end
169
+ end
170
+
171
+ # Never let a credential reach a log line, an exception report or `p client`.
172
+ def inspect
173
+ "#<Multilocale::Client api_url=#{@api_url.inspect} auth=#{@api_key ? 'api_key' : 'access_token'} [redacted]>"
174
+ end
175
+ alias to_s inspect
176
+
177
+ private
178
+
179
+ def authorization
180
+ return "Basic #{Encoding.base64(@api_key)}" if @api_key
181
+
182
+ "Token #{Encoding.base64(@access_token)}"
183
+ end
184
+
185
+ def build_url(path, params)
186
+ url = "#{api_url}/api/#{path.to_s.sub(%r{\A/+}, '')}"
187
+ query = params && Encoding.query(params)
188
+ query && !query.empty? ? "#{url}?#{query}" : url
189
+ end
190
+
191
+ def execute(method, uri, body)
192
+ request = request_class(method).new(uri)
193
+ request["Accept"] = "application/json"
194
+ request["Authorization"] = authorization
195
+ request["User-Agent"] = user_agent
196
+
197
+ unless body.nil?
198
+ request["Content-Type"] = "application/json"
199
+ request.body = JSON.generate(body)
200
+ end
201
+
202
+ @logger&.debug("multilocale #{method.to_s.upcase} #{uri}")
203
+
204
+ Net::HTTP.start(
205
+ uri.hostname,
206
+ uri.port,
207
+ use_ssl: uri.scheme == "https",
208
+ open_timeout: open_timeout,
209
+ read_timeout: read_timeout
210
+ ) { |http| http.request(request) }
211
+ end
212
+
213
+ def request_class(method)
214
+ case method
215
+ when :get then Net::HTTP::Get
216
+ when :post then Net::HTTP::Post
217
+ when :put then Net::HTTP::Put
218
+ when :delete then Net::HTTP::Delete
219
+ else raise ArgumentError, "Unsupported HTTP method #{method.inspect}"
220
+ end
221
+ end
222
+
223
+ def parse(response, method:, url:)
224
+ status = response.code.to_i
225
+ raw = response.body.to_s
226
+ payload =
227
+ begin
228
+ raw.empty? ? nil : JSON.parse(raw)
229
+ rescue JSON::ParserError
230
+ nil
231
+ end
232
+
233
+ return payload if status < 400
234
+
235
+ raise ApiError.build(
236
+ status: status,
237
+ payload: payload,
238
+ raw_body: raw,
239
+ request_method: method,
240
+ url: url
241
+ )
242
+ end
243
+
244
+ def retry_after(response)
245
+ seconds = response["Retry-After"]
246
+ return nil if seconds.nil?
247
+
248
+ value = seconds.to_f
249
+ value.positive? ? value : nil
250
+ end
251
+
252
+ def backoff_for(attempt)
253
+ retry_backoff * (2**(attempt - 1))
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Multilocale
6
+ # multilocale.json — the same file the npm CLI reads, so a repository that
7
+ # uses both tools describes its project once.
8
+ #
9
+ # {
10
+ # "projectId": "website",
11
+ # "defaultLocale": "en",
12
+ # "locales": ["en", "es", "fr"],
13
+ # "paths": ["config/locales/%lang%.yml"]
14
+ # }
15
+ #
16
+ # `projectId` accepts an id or a name, exactly like the CLI's `--project`.
17
+ #
18
+ # Paths are resolved relative to the config file, not to the working
19
+ # directory: `rake` from a subdirectory writes the same files as `rake` from
20
+ # the root. (The npm CLI resolves them against the process cwd instead.)
21
+ class ConfigFile
22
+ FILENAME = "multilocale.json"
23
+
24
+ attr_reader :path, :data
25
+
26
+ def initialize(path, data)
27
+ @path = path
28
+ @data = data
29
+ end
30
+
31
+ # Walks up from `directory` looking for multilocale.json, the way git finds
32
+ # .git. Returns nil rather than raising: callers decide whether a missing
33
+ # config is fatal.
34
+ def self.discover(directory = Dir.pwd)
35
+ current = File.expand_path(directory)
36
+
37
+ loop do
38
+ candidate = File.join(current, FILENAME)
39
+ return load(candidate) if File.exist?(candidate)
40
+
41
+ parent = File.dirname(current)
42
+ return nil if parent == current
43
+
44
+ current = parent
45
+ end
46
+ end
47
+
48
+ def self.load(path)
49
+ raise ConfigurationError, "No such config file: #{path}" unless File.exist?(path)
50
+
51
+ data =
52
+ begin
53
+ JSON.parse(File.read(path))
54
+ rescue JSON::ParserError => error
55
+ raise ConfigurationError, "#{path} is not valid JSON: #{error.message}"
56
+ end
57
+
58
+ new(File.expand_path(path), data)
59
+ end
60
+
61
+ def directory
62
+ File.dirname(path)
63
+ end
64
+
65
+ # Id or name. Named `projectId` in the file for CLI compatibility even
66
+ # though a name is equally valid there.
67
+ def project
68
+ data["projectId"] || data["project"]
69
+ end
70
+
71
+ def default_locale
72
+ data["defaultLocale"]
73
+ end
74
+
75
+ def locales
76
+ data["locales"]
77
+ end
78
+
79
+ # The npm CLI's `import` and `unused` commands destructure this without a
80
+ # default and crash with "Cannot read properties of undefined" when it is
81
+ # missing; this gem answers with an empty list and lets the caller decide.
82
+ def paths
83
+ Array(data["paths"])
84
+ end
85
+
86
+ def format
87
+ data["format"]
88
+ end
89
+
90
+ def header
91
+ data["header"]
92
+ end
93
+
94
+ # Gem-only key. Absent from the CLI's vocabulary, which ignores unknown
95
+ # keys, so adding it here does not break `npx multilocale`.
96
+ def nested?
97
+ data.fetch("nested", true)
98
+ end
99
+
100
+ def project!
101
+ value = project
102
+ if value.nil? || value.to_s.empty?
103
+ raise ConfigurationError, <<~MESSAGE
104
+ #{path} does not say which project it describes.
105
+
106
+ Add the project id or name:
107
+
108
+ { "projectId": "website", "locales": ["en", "es"], "paths": ["config/locales/%lang%.yml"] }
109
+ MESSAGE
110
+ end
111
+
112
+ value
113
+ end
114
+
115
+ def paths!
116
+ values = paths
117
+ if values.empty?
118
+ raise ConfigurationError, <<~MESSAGE
119
+ #{path} has no "paths".
120
+
121
+ Add at least one path template containing #{LocaleFile::PLACEHOLDER}:
122
+
123
+ { "paths": ["config/locales/#{LocaleFile::PLACEHOLDER}.yml"] }
124
+ MESSAGE
125
+ end
126
+
127
+ values
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ # One language's phrases as a flat `key => value` map, plus the conversion
5
+ # the Ruby i18n gem needs.
6
+ #
7
+ # Multilocale stores keys flat. The i18n gem resolves `t("cart.title")` by
8
+ # splitting on dots and walking nested hashes, so a flat YAML key
9
+ # `"cart.title": Cart` is NOT found at runtime — it has to be nested on the
10
+ # way out and flattened again on the way in. That translation is this class.
11
+ class Dictionary
12
+ include Enumerable
13
+
14
+ attr_reader :language, :entries
15
+
16
+ def initialize(language, entries = {})
17
+ @language = language
18
+ @entries = entries.sort_by { |key, _| key.to_s }.to_h
19
+ end
20
+
21
+ # { "en" => Dictionary, "es" => Dictionary, … } from a flat list of rows.
22
+ def self.from_phrases(phrases)
23
+ phrases.each_with_object({}) { |phrase, grouped|
24
+ (grouped[phrase.language] ||= {})[phrase.key] = phrase.value
25
+ }.map { |language, entries| [language, new(language, entries)] }.to_h
26
+ end
27
+
28
+ def [](key)
29
+ entries[key.to_s]
30
+ end
31
+
32
+ def each(&block)
33
+ entries.each(&block)
34
+ end
35
+
36
+ def size
37
+ entries.size
38
+ end
39
+
40
+ def empty?
41
+ entries.empty?
42
+ end
43
+
44
+ def keys
45
+ entries.keys
46
+ end
47
+
48
+ def to_h
49
+ entries
50
+ end
51
+
52
+ # Dotted keys become nested hashes, which is what i18n (and Rails) read.
53
+ # `cart.title` and `cart` cannot both be keys — one would have to be both a
54
+ # string and a hash — so that collision is reported rather than silently
55
+ # dropping one of them.
56
+ def nested
57
+ entries.each_with_object({}) do |(key, value), tree|
58
+ path = key.to_s.split(".")
59
+ leaf = path.pop
60
+ node = tree
61
+
62
+ path.each_with_index do |segment, index|
63
+ node[segment] ||= {}
64
+ unless node[segment].is_a?(Hash)
65
+ raise Error, "Key collision in #{language}: #{path[0..index].join('.')} is both a value and a namespace " \
66
+ "(#{key})"
67
+ end
68
+
69
+ node = node[segment]
70
+ end
71
+
72
+ if node[leaf].is_a?(Hash)
73
+ raise Error, "Key collision in #{language}: #{key} is both a value and a namespace"
74
+ end
75
+
76
+ node[leaf] = value
77
+ end
78
+ end
79
+
80
+ # The inverse of #nested: what a locale file read from disk has to go
81
+ # through before it can be pushed back as phrase rows.
82
+ def self.flatten(tree, prefix = nil)
83
+ tree.each_with_object({}) do |(key, value), flat|
84
+ path = [prefix, key].compact.join(".")
85
+
86
+ if value.is_a?(Hash)
87
+ flat.merge!(flatten(value, path))
88
+ else
89
+ flat[path] = value
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ # Percent-encoding helpers written by hand on purpose: `base64` and `cgi` are
5
+ # bundled gems on modern Rubies, and this gem has zero runtime dependencies.
6
+ module Encoding
7
+ UNRESERVED = /[^A-Za-z0-9\-._~]/
8
+
9
+ module_function
10
+
11
+ # RFC 3986 unreserved-set encoding. Used for path segments (project names
12
+ # can contain spaces and slashes) and as the manual layer for phrase keys.
13
+ def percent_encode(value)
14
+ value.to_s.b.gsub(UNRESERVED) { |byte| format("%%%02X", byte.ord) }
15
+ end
16
+
17
+ # `Authorization: Basic <base64(secret)>` — one value, no colon, no
18
+ # newlines. `pack("m0")` is the dependency-free strict-Base64 encoder.
19
+ def base64(value)
20
+ [value.to_s].pack("m0")
21
+ end
22
+
23
+ # Phrase keys cross TWO decoders on the way in: Express decodes the query
24
+ # string, then the handler calls decodeURIComponent() on the result again
25
+ # (multilocale/api/handlers/phrasesHandler.js). A key is therefore
26
+ # documented as "URL-encoded" and must be encoded once here, on top of the
27
+ # transport's own encoding.
28
+ #
29
+ # It only matters for keys containing a literal '%' — 'discount.100%_off'
30
+ # arrives as 'discount.100' plus a decode error without this — but getting
31
+ # it wrong silently corrupts exactly the keys nobody thinks to test.
32
+ def phrase_key(key)
33
+ percent_encode(key)
34
+ end
35
+
36
+ # Query strings, with spaces as %20 rather than '+': the second decode a
37
+ # phrase key goes through is decodeURIComponent, which leaves '+' alone.
38
+ def query(params)
39
+ params
40
+ .reject { |_, value| value.nil? }
41
+ .map { |name, value| "#{percent_encode(name)}=#{percent_encode(value)}" }
42
+ .join("&")
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multilocale
4
+ # Base class for everything this gem raises, so callers can rescue one thing.
5
+ class Error < StandardError; end
6
+
7
+ # Raised before any request is made: no credential, no project, a config file
8
+ # that does not say which project it describes.
9
+ class ConfigurationError < Error; end
10
+
11
+ # The request never got an HTTP response (DNS, TLS, timeout, reset) and the
12
+ # retry budget is spent.
13
+ class ConnectionError < Error; end
14
+
15
+ # The server answered with a status >= 400.
16
+ class ApiError < Error
17
+ attr_reader :status, :body, :request_method, :url
18
+
19
+ def initialize(message, status: nil, body: nil, request_method: nil, url: nil)
20
+ super(message)
21
+ @status = status
22
+ @body = body
23
+ @request_method = request_method
24
+ @url = url
25
+ end
26
+
27
+ STATUS_CLASSES = {
28
+ 401 => "AuthenticationError",
29
+ 403 => "PermissionError",
30
+ 404 => "NotFoundError",
31
+ 429 => "RateLimitedError"
32
+ }.freeze
33
+
34
+ # Maps an HTTP status onto the narrowest error class. 5xx is a single
35
+ # ServerError: the API returns the same `{status, message}` envelope for all
36
+ # of them and callers treat them identically (retry, then give up).
37
+ def self.class_for(status)
38
+ name = STATUS_CLASSES[status]
39
+ return Multilocale.const_get(name) if name
40
+ return ServerError if status >= 500
41
+
42
+ self
43
+ end
44
+
45
+ def self.build(status:, payload:, raw_body:, request_method:, url:)
46
+ message = payload.is_a?(Hash) ? payload["message"] : nil
47
+ message = "HTTP #{status}" if message.nil? || message.to_s.empty?
48
+
49
+ class_for(status).new(
50
+ "#{message} (#{request_method.to_s.upcase} #{url} -> #{status})#{hint_for(status)}",
51
+ status: status,
52
+ body: payload || raw_body,
53
+ request_method: request_method,
54
+ url: url
55
+ )
56
+ end
57
+
58
+ # The two failures every new integration hits, answered in the message
59
+ # itself rather than in a doc the reader is not looking at.
60
+ def self.hint_for(status)
61
+ case status
62
+ when 401
63
+ "\nCheck MULTILOCALE_API_KEY: the API expects Basic base64(secret) — the key secret alone, " \
64
+ "not key:secret."
65
+ when 403
66
+ "\nThe key is valid but not allowed here: a missing scope (projects:read, phrases:write, …) " \
67
+ "or a key scoped to a different project."
68
+ else
69
+ ""
70
+ end
71
+ end
72
+ end
73
+
74
+ class AuthenticationError < ApiError; end
75
+ class PermissionError < ApiError; end
76
+ class NotFoundError < ApiError; end
77
+ class RateLimitedError < ApiError; end
78
+ class ServerError < ApiError; end
79
+ end