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.
@@ -0,0 +1,55 @@
1
+ require 'optparse'
2
+ require 'tomlrb'
3
+ require 'haveapi/rust_client'
4
+
5
+ module HaveAPI::RustClient
6
+ class Cli
7
+ def self.run(argv = ARGV, out: $stdout, err: $stderr)
8
+ command = argv.shift
9
+ options = {}
10
+ parser = OptionParser.new do |opts|
11
+ opts.banner = 'Usage: haveapi-rust-client fetch URL | generate [options]'
12
+ opts.on('--api-version VERSION') { |value| options[:version] = value }
13
+ opts.on('--profile PROFILE') { |value| options[:profile] = value }
14
+ opts.on('--schema FILE') { |value| options[:schema] = value }
15
+ opts.on('--config FILE') { |value| options[:config] = value }
16
+ opts.on('--output PATH') { |value| options[:output] = value }
17
+ opts.on('--check', 'Check without modifying destination') { options[:check] = true }
18
+ opts.on('-h', '--help') do
19
+ out.puts(opts)
20
+ return 0
21
+ end
22
+ end
23
+ parser.parse!(argv)
24
+ Snapshot.version!(options[:version])
25
+ raise Error, '--output is required' unless options[:output]
26
+
27
+ case command
28
+ when 'fetch'
29
+ raise Error, 'fetch requires one explicit version URL and --profile' unless argv.length == 1 && options[:profile]
30
+ raise Error, '--check is supported only for generation' if options[:check]
31
+
32
+ diagnostics = []
33
+ snapshot = Snapshot.fetch(url: argv.first, api_version: options[:version], profile: options[:profile], diagnostics:)
34
+ diagnostics.each { |message| err.puts(message) }
35
+ File.write(options[:output], Snapshot.canonical(snapshot))
36
+ when 'generate'
37
+ unless argv.empty? && options[:schema] && options[:config]
38
+ raise Error, 'generate requires --schema and --config'
39
+ end
40
+
41
+ snapshot = Snapshot.load(options[:schema])
42
+ raise Error, 'requested API version differs from snapshot' unless options[:version] == snapshot.fetch('api_version')
43
+
44
+ config = Tomlrb.load_file(options[:config])
45
+ Generator.new(schema: snapshot, config:).generate(output: options[:output], check: options[:check])
46
+ else
47
+ raise Error, 'expected fetch or generate subcommand'
48
+ end
49
+ 0
50
+ rescue Error, OptionParser::ParseError, Tomlrb::ParseError, SystemCallError => e
51
+ err.puts("haveapi-rust-client: #{e.message}")
52
+ 1
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,358 @@
1
+ require 'haveapi/rust_client'
2
+ require 'date'
3
+
4
+ module HaveAPI::RustClient
5
+ # Resolves the complete saved SDK before checking a request's authorized help.
6
+ # Construction is deliberately limited to the generator's pure validation/IR.
7
+ class Documentation
8
+ class Unsupported < StandardError; end
9
+
10
+ def initialize(snapshot:, config:)
11
+ @generator = Generator.new(schema: JSON.parse(Snapshot.canonical(snapshot)), config: JSON.parse(Snapshot.canonical(config)))
12
+ @snapshot = @generator.schema
13
+ @import = @generator.config.fetch('crate_name').tr('-', '_')
14
+ raise Error, 'crate name cannot be imported in Rust' if %w[crate self super].include?(@import)
15
+
16
+ @import = "r##{@import}" if Naming::RESERVED.include?(@import)
17
+ @generator.ir.actions.each { |action| @generator.meta_parameters(action) }
18
+ freeze_data([@snapshot, @generator.config, @generator.ir.resources, @generator.ir.actions])
19
+ end
20
+
21
+ def resolve(resource_path:, action_name:)
22
+ @generator.ir.actions.find do |action|
23
+ action[:resource][:path] == resource_path.map(&:to_s) && action[:name] == action_name.to_s
24
+ end
25
+ end
26
+
27
+ def render(kind:, context:, mode:, auth_method: nil, auth_description: nil)
28
+ unsupported!('Unsupported Rust execution mode.') unless %i[async blocking].include?(mode)
29
+ unsupported!('The Rust SDK targets a different API version.') unless context.version.to_s == @snapshot['api_version']
30
+
31
+ source = case kind
32
+ when :init then program(context, mode, [])
33
+ when :auth then authentication(context, mode, auth_method, auth_description)
34
+ when :example then example(context, mode)
35
+ else unsupported!('Unsupported Rust documentation section.')
36
+ end
37
+ { status: :code, code: source.freeze }.freeze
38
+ rescue Unsupported => e
39
+ { status: :limitation, reason: e.message.freeze }.freeze
40
+ end
41
+
42
+ private
43
+
44
+ def unsupported!(reason)
45
+ raise Unsupported, reason
46
+ end
47
+
48
+ def freeze_data(value, seen = {}.compare_by_identity)
49
+ return if seen[value]
50
+
51
+ seen[value] = true
52
+ case value
53
+ when Hash then value.each do |key, item|
54
+ freeze_data(key, seen)
55
+ freeze_data(item, seen)
56
+ end
57
+ when Array then value.each { |item| freeze_data(item, seen) }
58
+ end
59
+ value.freeze
60
+ end
61
+
62
+ def normalize(value)
63
+ case value
64
+ when Hash then value.to_h { |key, item| [key.to_s, normalize(item)] }
65
+ when Array then value.map { |item| normalize(item) }
66
+ when Symbol then value.to_s
67
+ else value
68
+ end
69
+ end
70
+
71
+ def program(context, mode, body, credentials: [])
72
+ config = @generator.config
73
+ dependencies = ["// Cargo.toml: #{config.fetch('crate_name')} = { version = #{literal(config.fetch('crate_version'))}" +
74
+ (mode == :blocking ? ', features = ["blocking"] }' : ' }')]
75
+ dependencies << '// Cargo.toml: tokio = { version = "1", features = ["macros", "rt-multi-thread"] }' if mode == :async
76
+ dependencies << '// Cargo.toml: serde_json = "1"'
77
+ dependencies << '// Construct, use and drop this blocking client outside async runtime threads.' if mode == :blocking
78
+ client = mode == :blocking ? "#{@import}::blocking::Client" : "#{@import}::Client"
79
+ lines = dependencies + ["use #{client};", '']
80
+ lines << '#[tokio::main]' if mode == :async
81
+ lines << "#{'async ' if mode == :async}fn main() -> Result<(), Box<dyn std::error::Error>> {"
82
+ lines << " let base = std::env::var(\"HAVEAPI_BASE_URL\").unwrap_or_else(|_| #{literal(context.base_url)}.to_owned());"
83
+ lines << ' let api = Client::builder(&base)'
84
+ lines.concat(credentials.map { |line| " #{line}" })
85
+ lines << ' .build()?;'
86
+ lines.concat((body.empty? ? ['let _ = &api;'] : body).map { |line| line.empty? ? '' : " #{line}" })
87
+ lines.push(' Ok(())', '}', '').join("\n")
88
+ end
89
+
90
+ def authentication(context, mode, method, description)
91
+ provider = method.to_s
92
+ live = normalize(context.description).fetch('authentication', {})[provider]
93
+ saved = @snapshot.fetch('description').fetch('authentication')[provider]
94
+ unsupported!('This authentication provider is unavailable in the authorized Rust SDK profile.') unless live && saved
95
+ unsupported!('The authentication description differs from the authorized help.') unless normalize(description) == live
96
+
97
+ credentials = case provider
98
+ when 'basic'
99
+ ['.basic_auth(std::env::var("HAVEAPI_USER")?, std::env::var("HAVEAPI_PASSWORD")?)']
100
+ when 'token', 'oauth2'
101
+ unless saved['http_header'] == live['http_header']
102
+ unsupported!('The Rust SDK authentication header differs from this API.')
103
+ end
104
+ setter = provider == 'token' ? 'token' : 'oauth2_token'
105
+ [".#{setter}(std::env::var(\"HAVEAPI_TOKEN\")?)"]
106
+ else unsupported!('This authentication provider is unsupported by the Rust client.')
107
+ end
108
+ body = ['// Existing credentials only. Token acquisition and challenges require explicit advertised actions.',
109
+ 'let _ = &api;']
110
+ program(context, mode, body, credentials:)
111
+ end
112
+
113
+ def example(context, mode)
114
+ saved = resolve(resource_path: context.resource_path, action_name: context.action_name)
115
+ live = authorized_action(context)
116
+ unsupported!('This action is unavailable in the authorized Rust SDK profile.') unless saved && live
117
+ sample = normalize(context.sample)
118
+ unless sample.is_a?(Hash) && (%w[path_params request response errors].any? { |key| !sample[key].nil? } ||
119
+ sample['status'] == false || (sample['http_status'] && sample['http_status'] != 200))
120
+ unsupported!('This sample has no structured request or response to render.')
121
+ end
122
+ compatible_action!(saved, live, context)
123
+ paths = sample['path_params'] || []
124
+ unless paths.is_a?(Array) && paths.length == saved[:placeholders].length && paths.all? { |value| valid_path?(value) }
125
+ unsupported!('The sample path arguments cannot be represented by this Rust SDK.')
126
+ end
127
+ inputs = input_lines(saved, live, sample['request'])
128
+ validate_response!(saved, live, sample['response']) unless sample['response'].nil?
129
+ bound = saved[:resource][:bound].length
130
+ call = "api.#{saved[:resource][:module]}(#{paths.take(bound).map { |value| literal(value) }.join(', ')})"
131
+ call << ".#{saved[:method]}(#{paths.drop(bound).map { |value| literal(value) }.join(', ')})"
132
+ call << inputs.fetch(:chain)
133
+ send = mode == :async ? '.send().await' : '.send()'
134
+ body = inputs.fetch(:before)
135
+ if sample['status'] == false || (sample['http_status'].is_a?(Integer) && sample['http_status'] >= 400)
136
+ body += ["match #{call}#{send} {", ' Ok(_) => return Err("expected the documented request to fail".into()),',
137
+ ' Err(error) => {', ' let _ = error.http_status();',
138
+ ' // Inspect these explicitly; server diagnostics can contain sensitive values.',
139
+ ' let _ = (error.server_message(), error.validation_errors());', ' }', '}']
140
+ else
141
+ body += ["let response = #{call}#{send}?;", 'let _ = response.http_status;', 'let _ = &response.meta;']
142
+ body.concat(output_lines(saved, sample['response']))
143
+ end
144
+ body << '// Request success or a timeout does not establish completion of a server-side operation.'
145
+ body << '// Use only the separately documented action-state action; this SDK has no automatic wait helper.'
146
+ program(context, mode, body, credentials: action_credentials(saved, context))
147
+ end
148
+
149
+ def action_credentials(action, context)
150
+ return [] if action[:desc]['auth'] == false
151
+
152
+ live = normalize(context.description).fetch('authentication', {})
153
+ saved = @snapshot.fetch('description').fetch('authentication')
154
+ if live.has_key?('basic') && saved.has_key?('basic')
155
+ return ['.basic_auth(std::env::var("HAVEAPI_USER")?, std::env::var("HAVEAPI_PASSWORD")?)']
156
+ end
157
+
158
+ %w[token oauth2].each do |provider|
159
+ next unless live[provider] && saved[provider] && live[provider]['http_header'] == saved[provider]['http_header']
160
+
161
+ setter = provider == 'token' ? 'token' : 'oauth2_token'
162
+ return [".#{setter}(std::env::var(\"HAVEAPI_TOKEN\")?)"]
163
+ end
164
+ unsupported!('This action requires authentication unavailable in the authorized Rust SDK profile.')
165
+ end
166
+
167
+ def authorized_action(context)
168
+ resources = normalize(context.description).fetch('resources', {})
169
+ resource = nil
170
+ context.resource_path.each do |name|
171
+ resource = resources[name.to_s]
172
+ return nil unless resource
173
+
174
+ resources = resource.fetch('resources', {})
175
+ end
176
+ resource && resource.fetch('actions', {})[context.action_name.to_s]
177
+ end
178
+
179
+ def compatible_action!(saved, live, context)
180
+ mount = URI.parse(context.base_url).path
181
+ mount = '/' if mount.empty?
182
+ mount += '/' unless mount.end_with?('/')
183
+ unless live['method'] == saved[:desc]['method'] && live['path'] == mount + saved[:route] &&
184
+ (live['auth'] == false) == (saved[:desc]['auth'] == false)
185
+ unsupported!('The Rust SDK route or HTTP method differs from this API action.')
186
+ end
187
+ %w[input output].each do |direction|
188
+ saved_io = saved[direction.to_sym]
189
+ live_io = live[direction]
190
+ next if empty_io?(saved_io && saved_io[:desc]) && empty_io?(live_io)
191
+
192
+ unless saved_io && live_io && saved_io[:desc]['layout'] == live_io['layout'] &&
193
+ saved_io[:desc]['namespace'] == live_io['namespace'] && saved_io[:desc]['format'] == live_io['format']
194
+ unsupported!('The Rust SDK input or output layout differs from this API action.')
195
+ end
196
+ end
197
+ %i[global_input object_input].each do |key|
198
+ next unless saved[key] && saved[key][:parameters].any? { |parameter| parameter[:desc]['required'] == true }
199
+
200
+ unsupported!('This Rust SDK action requires metadata that is absent from the sample.')
201
+ end
202
+ %w[global object].each do |role|
203
+ if live.dig('meta', role, 'input', 'parameters')&.any? { |_name, parameter| parameter['required'] == true }
204
+ unsupported!('This action requires metadata that is absent from the sample.')
205
+ end
206
+ end
207
+ end
208
+
209
+ def empty_io?(io)
210
+ io.nil? || io == { 'parameters' => {} }
211
+ end
212
+
213
+ def input_lines(saved, live, request)
214
+ io = saved[:input]
215
+ if io.nil?
216
+ unsupported!('This Rust SDK action cannot represent the sample input.') unless request.nil? || request == {}
217
+ return { before: [], chain: '' }
218
+ end
219
+ list = io[:layout].end_with?('_list')
220
+ unsupported!('Rust GET requests do not support list input.') if list && saved[:desc]['method'] == 'GET'
221
+ values = if request.nil?
222
+ list ? [] : {}
223
+ else
224
+ request
225
+ end
226
+ unless list ? values.is_a?(Array) && values.all?(Hash) : values.is_a?(Hash)
227
+ unsupported!('The sample input does not match the Rust SDK layout.')
228
+ end
229
+ objects = list ? values : [values]
230
+ objects.each { |object| validate_object!(io, live['input'], object, required: true, method: saved[:desc]['method']) }
231
+ return { before: [], chain: setter_chain(io, values) } unless list
232
+
233
+ input = "#{@import}::resources::#{saved[:resource][:module]}::#{saved[:method]}::Input"
234
+ lines = ['let input = vec![']
235
+ objects.each do |object|
236
+ lines << " #{input} {"
237
+ object.each do |name, value|
238
+ parameter = io[:parameters].find { |item| item[:name] == name }
239
+ value_code = value.nil? ? "#{@import}::InputValue::Null" : "#{@import}::InputValue::Value(#{typed_literal(parameter, value)})"
240
+ lines << " #{parameter[:field]}: #{value_code},"
241
+ end
242
+ lines << ' ..Default::default()'
243
+ lines << ' },'
244
+ end
245
+ lines << '];'
246
+ { before: lines, chain: '.input(input)' }
247
+ end
248
+
249
+ def validate_object!(saved_io, live_io, object, required:, method: nil)
250
+ live_params = live_io && live_io.fetch('parameters', {})
251
+ object.each do |name, value|
252
+ parameter = saved_io[:parameters].find { |item| item[:name] == name }
253
+ live_parameter = live_params && live_params[name]
254
+ unless parameter && live_parameter && compatible_parameter?(parameter[:desc], live_parameter)
255
+ unsupported!('A sample parameter is unavailable or has changed in this Rust SDK.')
256
+ end
257
+ unless valid_value?(parameter[:desc], value, output: !required)
258
+ unsupported!('A sample value cannot be represented by this Rust SDK.')
259
+ end
260
+ if method == 'GET' && parameter[:desc]['type'] == 'Custom' && (value.is_a?(Hash) || value.is_a?(Array))
261
+ unsupported!('Rust GET requests do not support nested Custom query values.')
262
+ end
263
+ end
264
+ return unless required
265
+
266
+ [saved_io[:desc].fetch('parameters'), live_params || {}].each do |parameters|
267
+ if parameters.any? { |name, desc| desc['required'] == true && !object.has_key?(name) }
268
+ unsupported!('The sample omits a required Rust SDK input.')
269
+ end
270
+ end
271
+ end
272
+
273
+ def compatible_parameter?(saved, live)
274
+ %w[type resource value_id value_label].all? { |key| saved[key] == live[key] } &&
275
+ %w[nullable required].all? { |key| (saved[key] == true) == (live[key] == true) }
276
+ end
277
+
278
+ def valid_value?(desc, value, output: false)
279
+ return output || desc['nullable'] == true if value.nil?
280
+
281
+ case desc['type']
282
+ when 'String', 'Text' then value.is_a?(String)
283
+ when 'Datetime' then value.is_a?(String) || value.is_a?(Time) || value.is_a?(DateTime)
284
+ when 'Integer' then value.is_a?(Integer) && value.between?(-(2**63), (2**63) - 1)
285
+ when 'Resource'
286
+ output ? value.is_a?(Hash) : value.is_a?(Integer) && value.between?(0, (2**63) - 1)
287
+ when 'Float' then value.is_a?(Numeric) && value.real? && value.finite? && value.to_f.finite?
288
+ when 'Boolean' then [true, false].include?(value)
289
+ when 'Custom' then json_value?(value)
290
+ else false
291
+ end
292
+ end
293
+
294
+ def json_value?(value)
295
+ case value
296
+ when Hash then value.all? { |key, item| key.is_a?(String) && json_value?(item) }
297
+ when Array then value.all? { |item| json_value?(item) }
298
+ when String, Integer, TrueClass, FalseClass, NilClass then true
299
+ when Float then value.finite?
300
+ else false
301
+ end
302
+ end
303
+
304
+ def valid_path?(value)
305
+ (value.is_a?(String) || value.is_a?(Integer)) && !value.to_s.empty? && !%w[. ..].include?(value.to_s)
306
+ end
307
+
308
+ def setter_chain(io, object)
309
+ object.map do |name, value|
310
+ parameter = io[:parameters].find { |item| item[:name] == name }
311
+ value.nil? ? ".#{parameter[:field]}_null()" : ".#{parameter[:field]}(#{typed_literal(parameter, value)})"
312
+ end.join
313
+ end
314
+
315
+ def typed_literal(parameter, value)
316
+ # Ask the generator for the real type instead of duplicating name/type rules.
317
+ case @generator.rust_type(parameter)
318
+ when 'String' then "#{literal(value.respond_to?(:iso8601) ? value.iso8601 : value)}.to_owned()"
319
+ when 'i64' then value == -(2**63) ? 'i64::MIN' : "#{value}_i64"
320
+ when 'f64' then "#{value.to_f}_f64"
321
+ when 'bool' then value.to_s
322
+ when 'serde_json::Value' then "serde_json::from_str::<serde_json::Value>(#{literal(JSON.generate(value))})?"
323
+ end
324
+ end
325
+
326
+ def validate_response!(saved, live, response)
327
+ io = saved[:output]
328
+ unless io
329
+ unsupported!('The sample response does not match this Rust SDK.') unless response == {}
330
+ return
331
+ end
332
+ list = io[:layout].end_with?('_list')
333
+ unless list ? response.is_a?(Array) && response.all?(Hash) : response.is_a?(Hash)
334
+ unsupported!('The sample response does not match this Rust SDK layout.')
335
+ end
336
+ (list ? response : [response]).each { |object| validate_object!(io, live['output'], object, required: false) }
337
+ end
338
+
339
+ def output_lines(action, response)
340
+ return ['let _ = &response.output;'] unless action[:output] && response
341
+
342
+ list = action[:output][:layout].end_with?('_list')
343
+ names = (list ? response : [response]).flat_map(&:keys).uniq
344
+ lines = list ? ['for item in &response.output {'] : []
345
+ names.each do |name|
346
+ parameter = action[:output][:parameters].find { |item| item[:name] == name }
347
+ target = list ? 'item' : 'response.output'
348
+ lines << "#{' ' if list}let _ = &#{target}.#{parameter[:field]};"
349
+ end
350
+ lines << '}' if list
351
+ lines
352
+ end
353
+
354
+ def literal(value)
355
+ Naming.literal(value)
356
+ end
357
+ end
358
+ end