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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/exe/multilocale-ruby +6 -0
- data/lib/multilocale/cli.rb +215 -0
- data/lib/multilocale/client.rb +256 -0
- data/lib/multilocale/config_file.rb +130 -0
- data/lib/multilocale/dictionary.rb +94 -0
- data/lib/multilocale/encoding.rb +45 -0
- data/lib/multilocale/errors.rb +79 -0
- data/lib/multilocale/locale_file.rb +118 -0
- data/lib/multilocale/phrase.rb +103 -0
- data/lib/multilocale/phrases.rb +96 -0
- data/lib/multilocale/project.rb +95 -0
- data/lib/multilocale/projects.rb +42 -0
- data/lib/multilocale/sync.rb +135 -0
- data/lib/multilocale/version.rb +5 -0
- data/lib/multilocale.rb +38 -0
- metadata +68 -0
|
@@ -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
|