haveapi-rust-client 0.30.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/Gemfile.lock +200 -0
- data/LICENSE.txt +21 -0
- data/README.md +81 -0
- data/bin/haveapi-rust-client +3 -0
- data/docs/documentation.md +103 -0
- data/docs/generation.md +149 -0
- data/docs/releasing.md +61 -0
- data/lib/haveapi/rust_client/cli.rb +55 -0
- data/lib/haveapi/rust_client/documentation.rb +358 -0
- data/lib/haveapi/rust_client/generator.rb +307 -0
- data/lib/haveapi/rust_client/ir.rb +195 -0
- data/lib/haveapi/rust_client/naming.rb +46 -0
- data/lib/haveapi/rust_client/snapshot.rb +207 -0
- data/lib/haveapi/rust_client/version.rb +5 -0
- data/lib/haveapi/rust_client.rb +12 -0
- data/runtime/src/runtime/auth.rs +97 -0
- data/runtime/src/runtime/client.rs +380 -0
- data/runtime/src/runtime/error.rs +134 -0
- data/runtime/src/runtime/mod.rs +19 -0
- data/runtime/src/runtime/protocol.rs +421 -0
- data/runtime/src/runtime/transport.rs +152 -0
- data/runtime/src/runtime/types.rs +219 -0
- data/runtime/src/runtime/validation.rs +213 -0
- data/template/Cargo.toml.erb +19 -0
- data/template/action.rs.erb +103 -0
- data/template/mod.rs.erb +30 -0
- data/template/resource.rs.erb +30 -0
- data/template/resources.rs.erb +4 -0
- metadata +98 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
require 'erb'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'fiddle/import'
|
|
6
|
+
require 'tomlrb'
|
|
7
|
+
|
|
8
|
+
module HaveAPI::RustClient
|
|
9
|
+
class Generator
|
|
10
|
+
ROOT = File.expand_path('../../..', __dir__)
|
|
11
|
+
CONFIG_KEYS = %w[crate_name crate_version license edition rust_version rustfmt_version].freeze
|
|
12
|
+
OWNER_FILE = '.haveapi-generated.json'.freeze
|
|
13
|
+
attr_reader :schema, :config, :ir
|
|
14
|
+
|
|
15
|
+
def initialize(schema:, config:)
|
|
16
|
+
@schema = Snapshot.validate(schema)
|
|
17
|
+
@config = config
|
|
18
|
+
validate_config
|
|
19
|
+
@ir = IR.new(schema)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def generate(output:, check: false)
|
|
23
|
+
output = File.expand_path(output)
|
|
24
|
+
validate_destination(output)
|
|
25
|
+
validate_manifest(output)
|
|
26
|
+
formatter = ENV.fetch('RUSTFMT', 'rustfmt')
|
|
27
|
+
stdout, _stderr, status = Open3.capture3(formatter, '--version')
|
|
28
|
+
unless status.success? && stdout.strip == config.fetch('rustfmt_version')
|
|
29
|
+
raise Error, 'rustfmt version differs from generator configuration'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Dir.mktmpdir('haveapi-rust-') do |temp|
|
|
33
|
+
render_tree(temp)
|
|
34
|
+
generated = Dir.glob(File.join(temp, '**/*.rs')).reject { |path| path.start_with?(File.join(temp, 'runtime/')) }
|
|
35
|
+
_stdout, stderr, status = Open3.capture3(
|
|
36
|
+
formatter, '--edition', config.fetch('edition'), '--config', 'skip_children=true', *generated
|
|
37
|
+
)
|
|
38
|
+
raise Error, "rustfmt failed: #{stderr.lines.first&.strip}" unless status.success?
|
|
39
|
+
|
|
40
|
+
files = tree_files(temp)
|
|
41
|
+
manifest = {
|
|
42
|
+
'owner' => 'haveapi-rust-client', 'format' => 1,
|
|
43
|
+
'generator_version' => VERSION, 'generator_source_sha256' => generator_digest,
|
|
44
|
+
'schema_sha256' => Snapshot.sha256(schema), 'runtime_sha256' => runtime_digest,
|
|
45
|
+
'api_version' => schema.fetch('api_version'), 'protocol_version' => schema.fetch('protocol_version'),
|
|
46
|
+
'profile' => schema.fetch('profile'), 'config' => config,
|
|
47
|
+
'ruby_dependency_lock_sha256' => Digest::SHA256.file(File.join(ROOT, 'Gemfile.lock')).hexdigest,
|
|
48
|
+
'files' => files.to_h { |path| [path, Digest::SHA256.file(File.join(temp, path)).hexdigest] }
|
|
49
|
+
}
|
|
50
|
+
File.write(File.join(temp, OWNER_FILE), Snapshot.canonical(manifest))
|
|
51
|
+
return true if equal_trees?(temp, output)
|
|
52
|
+
raise Error, 'generated tree is stale; regenerate without --check' if check
|
|
53
|
+
|
|
54
|
+
install_tree(temp, output)
|
|
55
|
+
end
|
|
56
|
+
true
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
raise Error, 'required generation file/tool is missing'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def runtime_digest
|
|
62
|
+
digest_files(runtime_files, File.join(ROOT, 'runtime/src/runtime'))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def manifest_requirements
|
|
66
|
+
render('Cargo.toml', {})
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validate_config
|
|
72
|
+
raise Error, 'generator config keys do not match supported contract' unless config.keys.sort == CONFIG_KEYS.sort
|
|
73
|
+
unless config['crate_name'].is_a?(String) && config['crate_name'].match?(/\A[a-z][a-z0-9_-]*\z/) &&
|
|
74
|
+
config['crate_version'].is_a?(String) && config['crate_version'].match?(/\A\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?\z/)
|
|
75
|
+
raise Error, 'invalid crate name/version'
|
|
76
|
+
end
|
|
77
|
+
unless config['license'] == 'MIT' && config['edition'] == '2024' && config['rust_version'] == '1.95' &&
|
|
78
|
+
config['rustfmt_version'].is_a?(String) && !config['rustfmt_version'].empty?
|
|
79
|
+
raise Error, 'unsupported license, Rust edition/MSRV or formatter configuration'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_manifest(output)
|
|
84
|
+
return unless File.basename(output) == 'generated' && File.basename(File.dirname(output)) == 'src'
|
|
85
|
+
|
|
86
|
+
path = File.join(File.dirname(output, 2), 'Cargo.toml')
|
|
87
|
+
return unless File.exist?(path)
|
|
88
|
+
raise Error, 'crate manifest must not be a symlink' if File.symlink?(path)
|
|
89
|
+
|
|
90
|
+
manifest = Tomlrb.load_file(path)
|
|
91
|
+
package = manifest.fetch('package')
|
|
92
|
+
%w[name version license edition rust-version].zip(%w[crate_name crate_version license edition rust_version]).each do |cargo, key|
|
|
93
|
+
raise Error, "Cargo.toml #{cargo} differs from generator config" unless package[cargo] == config[key]
|
|
94
|
+
end
|
|
95
|
+
deps = manifest.fetch('dependencies')
|
|
96
|
+
{ 'serde' => '1', 'serde_json' => '1', 'url' => '2.5', 'reqwest' => '0.13.5' }.each do |name, version|
|
|
97
|
+
dependency = deps.fetch(name)
|
|
98
|
+
actual = dependency.is_a?(Hash) ? dependency['version'] : dependency
|
|
99
|
+
unless actual == version && (!dependency.is_a?(Hash) || !dependency.keys.intersect?(%w[path git]))
|
|
100
|
+
raise Error, "Cargo.toml dependency #{name} differs from runtime requirements"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
unless deps['serde'].is_a?(Hash) && deps['serde'].fetch('features', []).include?('derive')
|
|
104
|
+
raise Error, 'Cargo.toml serde derive feature is required'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
reqwest = deps.fetch('reqwest')
|
|
108
|
+
unless reqwest.is_a?(Hash) && reqwest['default-features'] == false &&
|
|
109
|
+
reqwest.fetch('features', []).sort == %w[json rustls] &&
|
|
110
|
+
manifest.dig('features', 'default') == [] &&
|
|
111
|
+
manifest.dig('features', 'blocking')&.include?('reqwest/blocking')
|
|
112
|
+
raise Error, 'Cargo.toml reqwest TLS/JSON/blocking requirements do not match runtime'
|
|
113
|
+
end
|
|
114
|
+
rescue KeyError, Tomlrb::ParseError
|
|
115
|
+
raise Error, 'crate manifest is incomplete or invalid TOML'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def render_tree(temp)
|
|
119
|
+
File.write(File.join(temp, 'mod.rs'), render('mod.rs', {}))
|
|
120
|
+
FileUtils.mkdir_p(File.join(temp, 'resources'))
|
|
121
|
+
File.write(File.join(temp, 'resources/mod.rs'), render('resources.rs', {}))
|
|
122
|
+
ir.resources.each do |resource|
|
|
123
|
+
dir = File.join(temp, 'resources', resource[:module])
|
|
124
|
+
FileUtils.mkdir_p(dir)
|
|
125
|
+
File.write(File.join(dir, 'mod.rs'), render('resource.rs', resource:))
|
|
126
|
+
resource[:actions].each do |action|
|
|
127
|
+
File.write(File.join(dir, "#{action[:method]}.rs"), render('action.rs', action:))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
runtime_files.each do |relative|
|
|
131
|
+
target = File.join(temp, 'runtime', relative)
|
|
132
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
133
|
+
FileUtils.cp(File.join(ROOT, 'runtime/src/runtime', relative), target)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def render(template, locals)
|
|
138
|
+
ERB.new(File.read(File.join(ROOT, 'template', "#{template}.erb")), trim_mode: '-').result_with_hash(
|
|
139
|
+
{ generator: self, schema:, config:, ir: }.merge(locals)
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def runtime_files
|
|
144
|
+
root = File.join(ROOT, 'runtime/src/runtime')
|
|
145
|
+
raise Error, 'maintained runtime source is missing' unless File.file?(File.join(root, 'mod.rs'))
|
|
146
|
+
|
|
147
|
+
ensure_no_symlinks(root)
|
|
148
|
+
tree_files(root)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def generator_digest
|
|
152
|
+
paths = Dir.chdir(ROOT) { Dir['lib/**/*.rb', 'template/**/*'].select { |path| File.file?(path) }.sort }
|
|
153
|
+
digest_files(paths, ROOT)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def digest_files(paths, root)
|
|
157
|
+
digest = Digest::SHA256.new
|
|
158
|
+
paths.sort.each { |path| digest.update(path).update("\0").update(File.binread(File.join(root, path))).update("\0") }
|
|
159
|
+
digest.hexdigest
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_destination(output)
|
|
163
|
+
raise Error, 'refusing filesystem root output' if output == '/'
|
|
164
|
+
|
|
165
|
+
cursor = output
|
|
166
|
+
loop do
|
|
167
|
+
raise Error, 'generated destination contains a symlink' if File.symlink?(cursor)
|
|
168
|
+
break if cursor == File.dirname(cursor)
|
|
169
|
+
|
|
170
|
+
cursor = File.dirname(cursor)
|
|
171
|
+
end
|
|
172
|
+
return unless File.exist?(output)
|
|
173
|
+
raise Error, 'generated destination is not a directory' unless File.directory?(output)
|
|
174
|
+
|
|
175
|
+
ensure_no_symlinks(output)
|
|
176
|
+
entries = tree_files(output)
|
|
177
|
+
return if entries.empty?
|
|
178
|
+
|
|
179
|
+
marker = File.join(output, OWNER_FILE)
|
|
180
|
+
raise Error, 'nonempty destination has no generator ownership manifest' unless File.file?(marker)
|
|
181
|
+
|
|
182
|
+
owner = JSON.parse(File.read(marker))
|
|
183
|
+
unless owner['owner'] == 'haveapi-rust-client' && owner['format'] == 1 && owner['files'].is_a?(Hash)
|
|
184
|
+
raise Error, 'invalid generator ownership manifest'
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
owner.fetch('files').each_key do |path|
|
|
188
|
+
unless path.is_a?(String) && !path.start_with?('/') && Snapshot.safe_segments?(path)
|
|
189
|
+
raise Error, 'unsafe owned path in manifest'
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
unknown = entries - owner.fetch('files').keys - [OWNER_FILE]
|
|
193
|
+
raise Error, 'destination contains files not owned by generator' unless unknown.empty?
|
|
194
|
+
rescue JSON::ParserError
|
|
195
|
+
raise Error, 'invalid generator ownership JSON'
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def ensure_no_symlinks(root)
|
|
199
|
+
return unless Dir.glob(File.join(root, '**/*'), File::FNM_DOTMATCH).any? { |path| File.symlink?(path) }
|
|
200
|
+
|
|
201
|
+
raise Error, 'symlink in generator-owned source/destination'
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def tree_files(root)
|
|
205
|
+
return [] unless File.directory?(root)
|
|
206
|
+
|
|
207
|
+
Dir.chdir(root) { Dir.glob('**/*', File::FNM_DOTMATCH).select { |path| File.file?(path) }.sort }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def equal_trees?(left, right)
|
|
211
|
+
files = tree_files(left)
|
|
212
|
+
files == tree_files(right) && files.all? do |path|
|
|
213
|
+
File.binread(File.join(left, path)) == File.binread(File.join(right, path))
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def install_tree(temp, output)
|
|
218
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
219
|
+
Dir.mktmpdir('.haveapi-rust-', File.dirname(output)) do |staging|
|
|
220
|
+
replacement = File.join(staging, 'generated')
|
|
221
|
+
FileUtils.cp_r(temp, replacement)
|
|
222
|
+
if File.exist?(output)
|
|
223
|
+
# Directory exchange is atomic even for a nonempty destination. Refuse
|
|
224
|
+
# unsupported hosts instead of exposing a half-replaced generated tree.
|
|
225
|
+
AtomicRename.exchange(replacement, output)
|
|
226
|
+
else
|
|
227
|
+
File.rename(replacement, output)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
module AtomicRename
|
|
233
|
+
extend Fiddle::Importer
|
|
234
|
+
|
|
235
|
+
dlload Fiddle.dlopen(nil)
|
|
236
|
+
|
|
237
|
+
def self.exchange(left, right)
|
|
238
|
+
function = Fiddle::Function.new(handler['renameat2'], [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
|
|
239
|
+
Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
|
|
240
|
+
Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
241
|
+
return if function.call(-100, left, -100, right, 2) == 0
|
|
242
|
+
|
|
243
|
+
raise Error, 'atomic generated-directory exchange failed'
|
|
244
|
+
rescue Fiddle::DLError
|
|
245
|
+
raise Error, 'atomic replacement requires renameat2 support on this host'
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
public
|
|
250
|
+
|
|
251
|
+
def literal(value)
|
|
252
|
+
Naming.literal(value)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def option(value)
|
|
256
|
+
value.nil? ? 'None' : "Some(#{literal(value)})"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def layout(value)
|
|
260
|
+
value.split('_').map(&:capitalize).join
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def rust_type(parameter, output: false)
|
|
264
|
+
case parameter[:desc].fetch('type')
|
|
265
|
+
when 'String', 'Text', 'Datetime' then 'String'
|
|
266
|
+
when 'Integer', 'Resource' then output && parameter[:desc]['type'] == 'Resource' ? 'runtime::Association' : 'i64'
|
|
267
|
+
when 'Float' then 'f64'
|
|
268
|
+
when 'Boolean' then 'bool'
|
|
269
|
+
when 'Custom' then 'serde_json::Value'
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def input_type(action)
|
|
274
|
+
return '()' unless action[:input]
|
|
275
|
+
|
|
276
|
+
action[:input][:layout].end_with?('_list') ? 'Vec<Input>' : 'Input'
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def output_type(action)
|
|
280
|
+
return '()' unless action[:output]
|
|
281
|
+
|
|
282
|
+
action[:output][:layout].end_with?('_list') ? 'Vec<Output>' : 'Output'
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def meta_parameters(action)
|
|
286
|
+
values = [action[:global_output]]
|
|
287
|
+
values << action[:object_output] if action[:output] && !action[:output][:layout].end_with?('_list')
|
|
288
|
+
fields = values.compact.flat_map { |io| io[:parameters] }
|
|
289
|
+
fields.group_by { |field| field[:name] }.map do |name, matches|
|
|
290
|
+
raise Error, "inconsistent metadata field #{name}" if matches.map { |field| field[:desc] }.uniq.length > 1
|
|
291
|
+
|
|
292
|
+
matches.first
|
|
293
|
+
end.sort_by { |field| field[:name] }
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def parameter_specs(io)
|
|
297
|
+
return '&[]' unless io
|
|
298
|
+
|
|
299
|
+
parameters = io[:parameters].map do |parameter|
|
|
300
|
+
desc = parameter[:desc]
|
|
301
|
+
"runtime::ParameterSpec { name: #{literal(parameter[:name])}, kind: runtime::ParameterKind::#{desc.fetch('type')}, " \
|
|
302
|
+
"required: #{desc['required'] == true}, nullable: #{desc['nullable'] == true}, protected: #{desc['protected'] == true} }"
|
|
303
|
+
end.join(',')
|
|
304
|
+
"&[#{parameters}]"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
module HaveAPI::RustClient
|
|
2
|
+
class IR
|
|
3
|
+
TYPES = %w[String Text Integer Float Boolean Datetime Resource Custom].freeze
|
|
4
|
+
LAYOUTS = %w[object object_list hash hash_list].freeze
|
|
5
|
+
METHODS = %w[GET DELETE POST PUT PATCH].freeze
|
|
6
|
+
attr_reader :snapshot, :resources, :actions
|
|
7
|
+
|
|
8
|
+
def initialize(snapshot)
|
|
9
|
+
@snapshot = Snapshot.validate(snapshot)
|
|
10
|
+
@resources = []
|
|
11
|
+
@actions = []
|
|
12
|
+
@module_scope = Naming::Scope.new(%w[runtime resources API_SPEC API_VERSION SCHEMA_SHA256 builder config])
|
|
13
|
+
description = snapshot.fetch('description')
|
|
14
|
+
walk(description.fetch('resources'), [])
|
|
15
|
+
description.fetch('authentication').each do |provider, auth|
|
|
16
|
+
next unless auth['resources']
|
|
17
|
+
|
|
18
|
+
walk(auth.fetch('resources'), ['authentication', provider], auth_provider: provider)
|
|
19
|
+
end
|
|
20
|
+
bind_ancestors
|
|
21
|
+
validate_auth(description.fetch('authentication'))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inventory
|
|
25
|
+
{
|
|
26
|
+
'api_version' => snapshot.fetch('api_version'), 'schema_sha256' => Snapshot.sha256(snapshot),
|
|
27
|
+
'profile' => snapshot.fetch('profile'), 'coverage' => 'captured description only; plugin coverage unknown',
|
|
28
|
+
'resource_count' => resources.length, 'action_count' => actions.length,
|
|
29
|
+
'parameter_count' => actions.sum { |action| all_parameters(action).length },
|
|
30
|
+
'resources' => resources.map { |resource| resource[:path] },
|
|
31
|
+
'actions' => actions.map do |action|
|
|
32
|
+
{ 'name' => action[:path], 'method' => action[:desc].fetch('method'),
|
|
33
|
+
'path' => action[:desc].fetch('path'),
|
|
34
|
+
'parameters' => all_parameters(action).map { |parameter| parameter[:path] } }
|
|
35
|
+
end
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def all_parameters(action)
|
|
40
|
+
%i[input output global_input global_output object_input object_output].flat_map do |key|
|
|
41
|
+
action[key] ? action[key][:parameters] : []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def walk(map, parents, auth_provider: nil)
|
|
48
|
+
raise Error, "resources must be an object at #{parents.join('/')}" unless map.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
map.sort.each do |name, desc|
|
|
51
|
+
path = parents + [name]
|
|
52
|
+
module_name = path.map { |item| Naming.snake(item) }.join('__')
|
|
53
|
+
@module_scope.add(module_name, path.join('/'))
|
|
54
|
+
resource = { name:, path:, module: module_name, desc:, actions: [], parent: parents }
|
|
55
|
+
resources << resource
|
|
56
|
+
scope = Naming::Scope.new
|
|
57
|
+
desc.fetch('actions').sort.each do |action_name, action_desc|
|
|
58
|
+
action_path = path + [action_name]
|
|
59
|
+
method_name = Naming.snake(action_name)
|
|
60
|
+
scope.add(method_name, action_path.join('/'))
|
|
61
|
+
aliases = action_desc.fetch('aliases', []).map do |item|
|
|
62
|
+
alias_name = Naming.snake(item)
|
|
63
|
+
next if alias_name == method_name
|
|
64
|
+
|
|
65
|
+
scope.add(alias_name, (action_path + [item]).join('/'))
|
|
66
|
+
end.compact
|
|
67
|
+
action = {
|
|
68
|
+
name: action_name, method: method_name, aliases:, path: action_path, resource:,
|
|
69
|
+
desc: action_desc, auth_provider:, route: route(action_desc.fetch('path'), auth_provider),
|
|
70
|
+
input: io(action_desc['input'], :input, action_path + ['input']),
|
|
71
|
+
output: io(action_desc['output'], :output, action_path + ['output'])
|
|
72
|
+
}
|
|
73
|
+
raise Error, "unsupported HTTP method at #{action_path.join('/')}" unless METHODS.include?(action_desc['method'])
|
|
74
|
+
|
|
75
|
+
%w[global object].each do |role|
|
|
76
|
+
%w[input output].each do |direction|
|
|
77
|
+
action[:"#{role}_#{direction}"] = io(
|
|
78
|
+
action_desc.dig('meta', role, direction), :metadata, action_path + ['meta', role, direction]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
action[:placeholders] = action[:route].scan(/\{([^}]+)\}/).flatten.uniq
|
|
83
|
+
argument_scope = Naming::Scope.new(%w[self path_params])
|
|
84
|
+
action[:arguments] = action[:placeholders].to_h do |placeholder|
|
|
85
|
+
[placeholder, argument_scope.add(Naming.snake(placeholder), action_path.join('/') + "/#{placeholder}")]
|
|
86
|
+
end
|
|
87
|
+
resource[:actions] << action
|
|
88
|
+
actions << action
|
|
89
|
+
end
|
|
90
|
+
walk(desc.fetch('resources'), path, auth_provider:)
|
|
91
|
+
end
|
|
92
|
+
rescue KeyError => e
|
|
93
|
+
raise Error, "incomplete resource description at #{parents.join('/')}: #{e.key}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def io(desc, direction, path)
|
|
97
|
+
return nil if desc.nil? || desc == { 'parameters' => {} }
|
|
98
|
+
raise Error, "invalid IO at #{path.join('/')}" unless desc.is_a?(Hash)
|
|
99
|
+
raise Error, "unsupported layout at #{path.join('/')}" unless LAYOUTS.include?(desc['layout'])
|
|
100
|
+
|
|
101
|
+
namespace = desc['namespace']
|
|
102
|
+
unless namespace == false || (namespace.is_a?(String) && !namespace.empty?)
|
|
103
|
+
raise Error, "missing/invalid namespace at #{path.join('/')}"
|
|
104
|
+
end
|
|
105
|
+
if direction == :output && namespace == false
|
|
106
|
+
raise Error, "ordinary output namespace=false unsupported at #{path.join('/')}"
|
|
107
|
+
end
|
|
108
|
+
if direction == :input && namespace == false && desc['layout'].end_with?('_list')
|
|
109
|
+
raise Error, "unnamespaced list input unsupported at #{path.join('/')}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
parameters = desc.fetch('parameters')
|
|
113
|
+
raise Error, "parameters must be an object at #{path.join('/')}" unless parameters.is_a?(Hash)
|
|
114
|
+
raise Error, "custom IO format unsupported at #{path.join('/')}" if desc.has_key?('format')
|
|
115
|
+
|
|
116
|
+
scope = Naming::Scope.new(direction == :input ? %w[new input global_meta object_meta send input_mut prepared] : %w[extra object_meta])
|
|
117
|
+
fields = parameters.sort.map do |name, parameter|
|
|
118
|
+
unless parameter.is_a?(Hash) && TYPES.include?(parameter['type'])
|
|
119
|
+
raise Error, "unknown parameter type at #{(path + [name]).join('/')}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
field = scope.add(Naming.snake(name), (path + [name]).join('/'))
|
|
123
|
+
{ name:, field:, desc: parameter, path: path + [name] }
|
|
124
|
+
end
|
|
125
|
+
if direction == :input
|
|
126
|
+
fields.select { |parameter| parameter[:desc]['nullable'] == true }.each do |parameter|
|
|
127
|
+
scope.add("#{parameter[:field]}_null", (parameter[:path] + ['null setter']).join('/'))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
{ desc:, parameters: fields, layout: desc.fetch('layout'), namespace: namespace == false ? nil : namespace }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def route(path, provider)
|
|
134
|
+
unless path.is_a?(String) && path.start_with?('/') && !path.match?(/[?#]/) && Snapshot.safe_segments?(path)
|
|
135
|
+
raise Error, 'unsafe action path in schema'
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
mount = snapshot.fetch('mount_path')
|
|
139
|
+
raise Error, 'action path escapes capture mount' unless path.start_with?(mount)
|
|
140
|
+
|
|
141
|
+
relative = path.delete_prefix(mount)
|
|
142
|
+
unless provider || relative.start_with?("v#{snapshot.fetch('api_version')}/")
|
|
143
|
+
raise Error, 'action path does not match selected API version'
|
|
144
|
+
end
|
|
145
|
+
unless relative.gsub(/\{[a-zA-Z0-9_-]+\}/, '').match?(%r{\A[a-zA-Z0-9_/.%~-]*\z})
|
|
146
|
+
raise Error, 'invalid action path/template'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
relative
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def bind_ancestors
|
|
153
|
+
resources.each do |resource|
|
|
154
|
+
parent = resources.find { |item| item[:path] == resource[:parent] }
|
|
155
|
+
resource[:bound] = []
|
|
156
|
+
next unless parent
|
|
157
|
+
|
|
158
|
+
parent_routes = parent[:actions].map { |action| action[:route] }
|
|
159
|
+
ancestors = resource[:actions].map do |action|
|
|
160
|
+
matches = parent_routes.filter_map do |route|
|
|
161
|
+
next unless action[:route].start_with?("#{route}/")
|
|
162
|
+
|
|
163
|
+
route.scan(/\{([^}]+)\}/).flatten.uniq
|
|
164
|
+
end
|
|
165
|
+
matches.max_by(&:length) || []
|
|
166
|
+
end
|
|
167
|
+
unique = ancestors.uniq
|
|
168
|
+
if unique.length > 1
|
|
169
|
+
raise Error, "ambiguous ancestor placeholders at #{resource[:path].join('/')}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
resource[:bound] = unique.first || []
|
|
173
|
+
resource[:actions].each do |action|
|
|
174
|
+
next if action[:placeholders].take(resource[:bound].length) == resource[:bound]
|
|
175
|
+
|
|
176
|
+
raise Error, "ancestor template mismatch at #{action[:path].join('/')}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate_auth(auth)
|
|
182
|
+
auth.each do |provider, config|
|
|
183
|
+
next unless config.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
config.each do |key, value|
|
|
186
|
+
Snapshot.endpoint!(value) if key.end_with?('_url') && value
|
|
187
|
+
next unless key.end_with?('_path') && value
|
|
188
|
+
next if value.start_with?('/') && !value.match?(/[?#]/) && Snapshot.safe_segments?(value)
|
|
189
|
+
|
|
190
|
+
raise Error, "unsafe authentication endpoint for #{provider}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module HaveAPI::RustClient
|
|
2
|
+
module Naming
|
|
3
|
+
RESERVED = %w[
|
|
4
|
+
as async await break const continue crate dyn else enum extern false fn for if impl in let loop
|
|
5
|
+
match mod move mut pub ref return self Self static struct super trait true type union unsafe use
|
|
6
|
+
where while abstract become box do final macro override priv typeof unsized virtual yield try gen
|
|
7
|
+
].freeze
|
|
8
|
+
|
|
9
|
+
def self.snake(value)
|
|
10
|
+
result = value.to_s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
11
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/[^a-zA-Z0-9_]/, '_').downcase
|
|
12
|
+
result = "field_#{result}" if result.empty? || result == '_' || result.match?(/\A\d/)
|
|
13
|
+
RESERVED.include?(result) ? "#{result}_" : result
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.literal(value)
|
|
17
|
+
escaped = value.to_s.codepoints.map do |char|
|
|
18
|
+
case char
|
|
19
|
+
when 34 then '\"'
|
|
20
|
+
when 92 then '\\\\'
|
|
21
|
+
when 10 then '\n'
|
|
22
|
+
when 13 then '\r'
|
|
23
|
+
when 9 then '\t'
|
|
24
|
+
when 0..31, 127 then "\\u{#{char.to_s(16)}}"
|
|
25
|
+
else char.chr(Encoding::UTF_8)
|
|
26
|
+
end
|
|
27
|
+
end.join
|
|
28
|
+
"\"#{escaped}\""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class Scope
|
|
32
|
+
def initialize(reserved = [])
|
|
33
|
+
@names = reserved.to_h { |name| [name, "reserved member #{name}"] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add(name, path)
|
|
37
|
+
if @names.has_key?(name)
|
|
38
|
+
raise Error, "Rust name collision #{name}: #{@names[name]} and #{path}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@names[name] = path
|
|
42
|
+
name
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|