fizzy-sdk 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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +18 -0
  3. data/Rakefile +26 -0
  4. data/fizzy-sdk.gemspec +45 -0
  5. data/lib/fizzy/auth_strategy.rb +38 -0
  6. data/lib/fizzy/bulkhead.rb +68 -0
  7. data/lib/fizzy/cache.rb +101 -0
  8. data/lib/fizzy/chain_hooks.rb +45 -0
  9. data/lib/fizzy/circuit_breaker.rb +115 -0
  10. data/lib/fizzy/client.rb +212 -0
  11. data/lib/fizzy/config.rb +143 -0
  12. data/lib/fizzy/cookie_auth.rb +27 -0
  13. data/lib/fizzy/errors.rb +291 -0
  14. data/lib/fizzy/generated/metadata.json +1341 -0
  15. data/lib/fizzy/generated/services/boards_service.rb +91 -0
  16. data/lib/fizzy/generated/services/cards_service.rb +313 -0
  17. data/lib/fizzy/generated/services/columns_service.rb +69 -0
  18. data/lib/fizzy/generated/services/comments_service.rb +68 -0
  19. data/lib/fizzy/generated/services/devices_service.rb +35 -0
  20. data/lib/fizzy/generated/services/identity_service.rb +19 -0
  21. data/lib/fizzy/generated/services/miscellaneous_service.rb +256 -0
  22. data/lib/fizzy/generated/services/notifications_service.rb +65 -0
  23. data/lib/fizzy/generated/services/pins_service.rb +19 -0
  24. data/lib/fizzy/generated/services/reactions_service.rb +80 -0
  25. data/lib/fizzy/generated/services/sessions_service.rb +58 -0
  26. data/lib/fizzy/generated/services/steps_service.rb +69 -0
  27. data/lib/fizzy/generated/services/tags_service.rb +20 -0
  28. data/lib/fizzy/generated/services/uploads_service.rb +24 -0
  29. data/lib/fizzy/generated/services/users_service.rb +52 -0
  30. data/lib/fizzy/generated/services/webhooks_service.rb +83 -0
  31. data/lib/fizzy/generated/types.rb +988 -0
  32. data/lib/fizzy/hooks.rb +70 -0
  33. data/lib/fizzy/http.rb +411 -0
  34. data/lib/fizzy/logger_hooks.rb +46 -0
  35. data/lib/fizzy/magic_link_flow.rb +57 -0
  36. data/lib/fizzy/noop_hooks.rb +9 -0
  37. data/lib/fizzy/operation_info.rb +17 -0
  38. data/lib/fizzy/rate_limiter.rb +68 -0
  39. data/lib/fizzy/request_info.rb +10 -0
  40. data/lib/fizzy/request_result.rb +14 -0
  41. data/lib/fizzy/resilience.rb +59 -0
  42. data/lib/fizzy/security.rb +103 -0
  43. data/lib/fizzy/services/base_service.rb +116 -0
  44. data/lib/fizzy/static_token_provider.rb +24 -0
  45. data/lib/fizzy/token_provider.rb +42 -0
  46. data/lib/fizzy/version.rb +6 -0
  47. data/lib/fizzy/webhooks/verify.rb +36 -0
  48. data/lib/fizzy.rb +95 -0
  49. data/scripts/generate-metadata.rb +105 -0
  50. data/scripts/generate-services.rb +681 -0
  51. data/scripts/generate-types.rb +160 -0
  52. metadata +252 -0
@@ -0,0 +1,681 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Generates Ruby service classes from OpenAPI spec.
5
+ #
6
+ # Usage: ruby scripts/generate-services.rb [--openapi ../openapi.json] [--output lib/fizzy/generated/services]
7
+ #
8
+ # This generator:
9
+ # 1. Parses openapi.json
10
+ # 2. Groups operations by tag
11
+ # 3. Maps operationIds to method names
12
+ # 4. Generates Ruby service files
13
+
14
+ require 'json'
15
+ require 'fileutils'
16
+
17
+ # Service generator for Ruby SDK
18
+ class ServiceGenerator
19
+ METHODS = %w[get post put patch delete].freeze
20
+
21
+ # Schema reference cache for resolving $ref
22
+ attr_reader :schemas
23
+
24
+ # Tag to service name mapping for Fizzy's 15 services
25
+ TAG_TO_SERVICE = {
26
+ 'Identity' => 'Identity',
27
+ 'Boards' => 'Boards',
28
+ 'Columns' => 'Columns',
29
+ 'Cards' => 'Cards',
30
+ 'Comments' => 'Comments',
31
+ 'Steps' => 'Steps',
32
+ 'Reactions' => 'Reactions',
33
+ 'Notifications' => 'Notifications',
34
+ 'Tags' => 'Tags',
35
+ 'Users' => 'Users',
36
+ 'Pins' => 'Pins',
37
+ 'Uploads' => 'Uploads',
38
+ 'Webhooks' => 'Webhooks',
39
+ 'Sessions' => 'Sessions',
40
+ 'Devices' => 'Devices',
41
+ 'Untagged' => 'Miscellaneous'
42
+ }.freeze
43
+
44
+ # Method name overrides for Fizzy operations
45
+ METHOD_NAME_OVERRIDES = {
46
+ 'GetMyIdentity' => 'me',
47
+ 'ListCardReactions' => 'list_for_card',
48
+ 'CreateCardReaction' => 'create_for_card',
49
+ 'DeleteCardReaction' => 'delete_for_card',
50
+ 'ListCommentReactions' => 'list_for_comment',
51
+ 'CreateCommentReaction' => 'create_for_comment',
52
+ 'DeleteCommentReaction' => 'delete_for_comment',
53
+ 'ReadNotification' => 'read',
54
+ 'UnreadNotification' => 'unread',
55
+ 'BulkReadNotifications' => 'bulk_read',
56
+ 'GetNotificationTray' => 'tray',
57
+ 'CreateDirectUpload' => 'create_direct',
58
+ 'ActivateWebhook' => 'activate',
59
+ 'CreateSession' => 'create',
60
+ 'RedeemMagicLink' => 'redeem_magic_link',
61
+ 'DestroySession' => 'destroy',
62
+ 'CompleteJoin' => 'complete_join',
63
+ 'CompleteSignup' => 'complete_signup',
64
+ 'RegisterDevice' => 'register',
65
+ 'UnregisterDevice' => 'unregister',
66
+ 'CloseCard' => 'close',
67
+ 'ReopenCard' => 'reopen',
68
+ 'PostponeCard' => 'postpone',
69
+ 'TriageCard' => 'triage',
70
+ 'UnTriageCard' => 'untriage',
71
+ 'GoldCard' => 'gold',
72
+ 'UngoldCard' => 'ungold',
73
+ 'AssignCard' => 'assign',
74
+ 'SelfAssignCard' => 'self_assign',
75
+ 'TagCard' => 'tag',
76
+ 'WatchCard' => 'watch',
77
+ 'UnwatchCard' => 'unwatch',
78
+ 'PinCard' => 'pin',
79
+ 'UnpinCard' => 'unpin',
80
+ 'MoveCard' => 'move',
81
+ 'DeleteCardImage' => 'delete_image',
82
+ 'DeactivateUser' => 'deactivate'
83
+ }.freeze
84
+
85
+ # Verb patterns for extracting method names
86
+ VERB_PATTERNS = [
87
+ { prefix: 'List', method: 'list' },
88
+ { prefix: 'Get', method: 'get' },
89
+ { prefix: 'Create', method: 'create' },
90
+ { prefix: 'Update', method: 'update' },
91
+ { prefix: 'Delete', method: 'delete' },
92
+ { prefix: 'Close', method: 'close' },
93
+ { prefix: 'Reopen', method: 'reopen' },
94
+ { prefix: 'Postpone', method: 'postpone' },
95
+ { prefix: 'Triage', method: 'triage' },
96
+ { prefix: 'Assign', method: 'assign' },
97
+ { prefix: 'Watch', method: 'watch' },
98
+ { prefix: 'Unwatch', method: 'unwatch' },
99
+ { prefix: 'Pin', method: 'pin' },
100
+ { prefix: 'Unpin', method: 'unpin' },
101
+ { prefix: 'Move', method: 'move' },
102
+ { prefix: 'Tag', method: 'tag' },
103
+ { prefix: 'Read', method: 'read' },
104
+ { prefix: 'Unread', method: 'unread' },
105
+ { prefix: 'Activate', method: 'activate' },
106
+ { prefix: 'Deactivate', method: 'deactivate' },
107
+ { prefix: 'Register', method: 'register' },
108
+ { prefix: 'Unregister', method: 'unregister' },
109
+ { prefix: 'Redeem', method: 'redeem' },
110
+ { prefix: 'Destroy', method: 'destroy' },
111
+ { prefix: 'Complete', method: 'complete' },
112
+ { prefix: 'Self', method: 'self' },
113
+ { prefix: 'Gold', method: 'gold' },
114
+ { prefix: 'Ungold', method: 'ungold' },
115
+ { prefix: 'Bulk', method: 'bulk' }
116
+ ].freeze
117
+
118
+ SIMPLE_RESOURCES = %w[
119
+ board boards column columns card cards comment comments step steps
120
+ reaction reactions notification notifications tag tags user users
121
+ pin pins upload uploads webhook webhooks session sessions device devices
122
+ identity cardimage directupload magiclink signup
123
+ ].freeze
124
+
125
+ def initialize(openapi_path, behavior_model_path: nil)
126
+ @openapi = JSON.parse(File.read(openapi_path))
127
+ @schemas = @openapi.dig('components', 'schemas') || {}
128
+ behavior_model_path ||= File.join(File.dirname(openapi_path), 'behavior-model.json')
129
+ @behavior_model = File.exist?(behavior_model_path) ? JSON.parse(File.read(behavior_model_path)) : {}
130
+ end
131
+
132
+ def generate(output_dir)
133
+ FileUtils.mkdir_p(output_dir)
134
+
135
+ services = group_operations
136
+ generated_files = []
137
+
138
+ services.each do |name, service|
139
+ code = generate_service(service)
140
+ filename = "#{to_snake_case(name)}_service.rb"
141
+ filepath = File.join(output_dir, filename)
142
+ File.write(filepath, code)
143
+ generated_files << filename
144
+ puts "Generated #{filename} (#{service[:operations].length} operations)"
145
+ end
146
+
147
+ puts "\nGenerated #{services.length} services with " \
148
+ "#{services.values.sum { |s| s[:operations].length }} operations total."
149
+ generated_files
150
+ end
151
+
152
+ private
153
+
154
+ def group_operations
155
+ services = {}
156
+
157
+ @openapi['paths'].each do |path, path_item|
158
+ METHODS.each do |method|
159
+ operation = path_item[method]
160
+ next unless operation
161
+
162
+ tag = operation['tags']&.first
163
+ parsed = parse_operation(path, method, operation)
164
+ next if parsed.nil?
165
+
166
+ service_name = if tag && TAG_TO_SERVICE.key?(tag)
167
+ TAG_TO_SERVICE[tag]
168
+ else
169
+ derive_service_name(operation['operationId'])
170
+ end
171
+
172
+ services[service_name] ||= {
173
+ name: service_name,
174
+ class_name: "#{service_name}Service",
175
+ description: "Service for #{service_name} operations",
176
+ operations: []
177
+ }
178
+
179
+ services[service_name][:operations] << parsed
180
+ end
181
+ end
182
+
183
+ services
184
+ end
185
+
186
+ # Derive service name from operationId when tags are absent.
187
+ OPERATION_SERVICE_OVERRIDES = {
188
+ 'GetMyIdentity' => 'Identity',
189
+ 'CreateDirectUpload' => 'Uploads',
190
+ 'RedeemMagicLink' => 'Sessions',
191
+ 'CompleteJoin' => 'Sessions',
192
+ 'CompleteSignup' => 'Sessions',
193
+ 'GetNotificationTray' => 'Notifications',
194
+ 'BulkReadNotifications' => 'Notifications',
195
+ 'DeleteCardImage' => 'Cards'
196
+ }.freeze
197
+
198
+ SERVICE_SUFFIXES = [
199
+ [ 'CommentReactions', 'Reactions' ],
200
+ [ 'CommentReaction', 'Reactions' ],
201
+ [ 'CardReactions', 'Reactions' ],
202
+ [ 'CardReaction', 'Reactions' ],
203
+ [ 'Notifications', 'Notifications' ],
204
+ [ 'Notification', 'Notifications' ],
205
+ [ 'Comments', 'Comments' ],
206
+ [ 'Comment', 'Comments' ],
207
+ [ 'Webhooks', 'Webhooks' ],
208
+ [ 'Webhook', 'Webhooks' ],
209
+ [ 'Columns', 'Columns' ],
210
+ [ 'Column', 'Columns' ],
211
+ [ 'Boards', 'Boards' ],
212
+ [ 'Board', 'Boards' ],
213
+ [ 'Cards', 'Cards' ],
214
+ [ 'Card', 'Cards' ],
215
+ [ 'Steps', 'Steps' ],
216
+ [ 'Step', 'Steps' ],
217
+ [ 'Users', 'Users' ],
218
+ [ 'User', 'Users' ],
219
+ [ 'Tags', 'Tags' ],
220
+ [ 'Pins', 'Pins' ],
221
+ [ 'Session', 'Sessions' ],
222
+ [ 'Device', 'Devices' ]
223
+ ].freeze
224
+
225
+ def derive_service_name(operation_id)
226
+ return OPERATION_SERVICE_OVERRIDES[operation_id] if OPERATION_SERVICE_OVERRIDES.key?(operation_id)
227
+
228
+ SERVICE_SUFFIXES.each do |suffix, service|
229
+ return service if operation_id.end_with?(suffix)
230
+ end
231
+
232
+ 'Miscellaneous'
233
+ end
234
+
235
+ def parse_operation(path, method, operation)
236
+ operation_id = operation['operationId']
237
+ return nil if operation_id.nil?
238
+ method_name = extract_method_name(operation_id)
239
+ http_method = method.upcase
240
+ description = operation['description']&.lines&.first&.strip || "#{method_name} operation"
241
+
242
+ # Extract path parameters
243
+ path_params = (operation['parameters'] || [])
244
+ .select { |p| p['in'] == 'path' }
245
+ .map { |p| { name: p['name'], type: schema_to_ruby_type(p['schema']), description: p['description'] } }
246
+
247
+ # Extract query parameters
248
+ query_params = (operation['parameters'] || [])
249
+ .select { |p| p['in'] == 'query' }
250
+ .map do |p|
251
+ {
252
+ name: p['name'],
253
+ type: schema_to_ruby_type(p['schema']),
254
+ required: p['required'] || false,
255
+ description: p['description']
256
+ }
257
+ end
258
+
259
+ # Check for request body (JSON or binary)
260
+ body_schema_ref = operation.dig('requestBody', 'content', 'application/json', 'schema')
261
+ has_binary_body = operation.dig('requestBody', 'content', 'application/octet-stream', 'schema')
262
+
263
+ # Extract body parameters from schema
264
+ body_params = extract_body_params(body_schema_ref)
265
+
266
+ # Check response
267
+ success_response = operation.dig('responses', '200') || operation.dig('responses', '201')
268
+ response_schema = success_response&.dig('content', 'application/json', 'schema')
269
+ returns_void = response_schema.nil?
270
+ returns_array = response_schema&.dig('type') == 'array'
271
+
272
+ # Check behavior model for retry overrides
273
+ no_retry = false
274
+ idempotent_post = false
275
+ behavior = @behavior_model.dig('operations', operation_id)
276
+ if behavior
277
+ if behavior.dig('retry', 'retry_on').nil? && http_method != 'POST'
278
+ no_retry = true
279
+ end
280
+ # POST operations marked idempotent in the behavior model should be retried
281
+ if http_method == 'POST' && behavior['idempotent']
282
+ idempotent_post = true
283
+ end
284
+ end
285
+
286
+ {
287
+ operation_id: operation_id,
288
+ method_name: method_name,
289
+ http_method: http_method,
290
+ path: convert_path(path),
291
+ description: description,
292
+ path_params: path_params,
293
+ query_params: query_params,
294
+ body_params: body_params,
295
+ has_body: body_params.any?,
296
+ has_binary_body: !!has_binary_body,
297
+ returns_void: returns_void,
298
+ returns_array: returns_array,
299
+ is_mutation: http_method != 'GET',
300
+ has_pagination: !!operation['x-fizzy-pagination'],
301
+ no_retry: no_retry,
302
+ idempotent_post: idempotent_post
303
+ }
304
+ end
305
+
306
+ # Extract body parameters from a schema reference
307
+ def extract_body_params(schema_ref)
308
+ return [] unless schema_ref
309
+
310
+ # Resolve $ref
311
+ schema = resolve_schema_ref(schema_ref)
312
+ return [] unless schema && schema['properties']
313
+
314
+ required_fields = schema['required'] || []
315
+
316
+ schema['properties'].map do |name, prop|
317
+ type = schema_to_ruby_type(prop)
318
+ format_hint = extract_format_hint(prop)
319
+ {
320
+ name: name,
321
+ type: type,
322
+ required: required_fields.include?(name),
323
+ description: prop['description'],
324
+ format_hint: format_hint
325
+ }
326
+ end
327
+ end
328
+
329
+ # Resolve a schema reference to its definition
330
+ def resolve_schema_ref(schema_or_ref)
331
+ return schema_or_ref unless schema_or_ref['$ref']
332
+
333
+ ref_path = schema_or_ref['$ref']
334
+ if ref_path.start_with?('#/components/schemas/')
335
+ schema_name = ref_path.split('/').last
336
+ @schemas[schema_name]
337
+ end
338
+ end
339
+
340
+ # Extract format hint for documentation
341
+ def extract_format_hint(prop)
342
+ return nil unless prop
343
+
344
+ case prop['format']
345
+ when 'date'
346
+ 'YYYY-MM-DD'
347
+ when 'date-time'
348
+ 'RFC3339 (e.g., 2024-12-15T09:00:00Z)'
349
+ end
350
+ end
351
+
352
+ def extract_method_name(operation_id)
353
+ return METHOD_NAME_OVERRIDES[operation_id] if METHOD_NAME_OVERRIDES.key?(operation_id)
354
+
355
+ VERB_PATTERNS.each do |pattern|
356
+ if operation_id.start_with?(pattern[:prefix])
357
+ remainder = operation_id[pattern[:prefix].length..]
358
+ return pattern[:method] if remainder.empty?
359
+
360
+ resource = to_snake_case(remainder)
361
+ return pattern[:method] if simple_resource?(resource)
362
+
363
+ return "#{pattern[:method]}_#{resource}"
364
+ end
365
+ end
366
+
367
+ to_snake_case(operation_id)
368
+ end
369
+
370
+ def simple_resource?(resource)
371
+ SIMPLE_RESOURCES.include?(resource.downcase.gsub('_', ''))
372
+ end
373
+
374
+ def convert_path(path)
375
+ # Convert {camelCaseParam} to #{snake_case_param}
376
+ path.gsub(/\{(\w+)\}/) do |_match|
377
+ param = ::Regexp.last_match(1)
378
+ snake_param = to_snake_case(param)
379
+ "\#{#{snake_param}}"
380
+ end
381
+ end
382
+
383
+ def schema_to_ruby_type(schema)
384
+ return 'Object' unless schema
385
+
386
+ case schema['type']
387
+ when 'integer' then 'Integer'
388
+ when 'boolean' then 'Boolean'
389
+ when 'array' then 'Array'
390
+ else 'String'
391
+ end
392
+ end
393
+
394
+ def to_snake_case(str)
395
+ str.gsub(/([a-z\d])([A-Z])/, '\1_\2')
396
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
397
+ .downcase
398
+ end
399
+
400
+ def generate_service(service)
401
+ lines = []
402
+
403
+ # Check if any operation uses URI encoding (binary uploads with query params)
404
+ needs_uri = service[:operations].any? { |op| op[:has_binary_body] && op[:query_params].any? }
405
+
406
+ lines << '# frozen_string_literal: true'
407
+ lines << ''
408
+ lines << 'require "uri"' if needs_uri
409
+ lines << '' if needs_uri
410
+ lines << 'module Fizzy'
411
+ lines << ' module Services'
412
+ lines << " # #{service[:description]}"
413
+ lines << ' #'
414
+ lines << ' # @generated from OpenAPI spec'
415
+ lines << " class #{service[:class_name]} < BaseService"
416
+
417
+ service[:operations].each do |op|
418
+ lines << ''
419
+ lines.concat(generate_method(op, service_name: service[:name]))
420
+ end
421
+
422
+ lines << ' end'
423
+ lines << ' end'
424
+ lines << 'end'
425
+ lines << ''
426
+
427
+ lines.join("\n")
428
+ end
429
+
430
+ def generate_method(op, service_name:)
431
+ lines = []
432
+
433
+ # Method signature
434
+ params = build_params(op)
435
+
436
+ # YARD documentation
437
+ lines << " # #{op[:description]}"
438
+
439
+ # Add @param tags for path params
440
+ op[:path_params].each do |p|
441
+ ruby_name = to_snake_case(p[:name])
442
+ type = p[:type] || 'Integer'
443
+ desc = p[:description] || "#{ruby_name.gsub('_', ' ')} ID"
444
+ lines << " # @param #{ruby_name} [#{type}] #{desc}"
445
+ end
446
+
447
+ # Add @param tags for binary upload params
448
+ if op[:has_binary_body]
449
+ lines << ' # @param data [String] Binary file data to upload'
450
+ lines << ' # @param content_type [String] MIME type of the file'
451
+ end
452
+
453
+ # Add @param tags for body params
454
+ if op[:body_params]&.any?
455
+ op[:body_params].each do |b|
456
+ ruby_name = to_snake_case(b[:name])
457
+ type = b[:type] || 'Object'
458
+ type = "#{type}, nil" unless b[:required]
459
+ desc = b[:description] || ruby_name.gsub('_', ' ')
460
+ format_hint = b[:format_hint] ? " (#{b[:format_hint]})" : ''
461
+ lines << " # @param #{ruby_name} [#{type}] #{desc}#{format_hint}"
462
+ end
463
+ end
464
+
465
+ # Add @param tags for query params
466
+ op[:query_params].each do |q|
467
+ ruby_name = to_snake_case(q[:name])
468
+ type = q[:type] || 'String'
469
+ type = "#{type}, nil" unless q[:required]
470
+ desc = q[:description] || ruby_name.gsub('_', ' ')
471
+ lines << " # @param #{ruby_name} [#{type}] #{desc}"
472
+ end
473
+
474
+ # Add @return tag
475
+ if op[:returns_void]
476
+ lines << ' # @return [void]'
477
+ elsif op[:returns_array] || op[:has_pagination]
478
+ lines << ' # @return [Enumerator<Hash>] paginated results'
479
+ else
480
+ lines << ' # @return [Hash] response data'
481
+ end
482
+
483
+ lines << " def #{op[:method_name]}(#{params})"
484
+
485
+ # Build the path
486
+ path_expr = build_path_expression(op)
487
+
488
+ is_paginated = op[:returns_array] || op[:has_pagination]
489
+ hook_kwargs = build_hook_kwargs(op, service_name)
490
+
491
+ if is_paginated
492
+ # wrap_paginated defers hooks to actual iteration time (lazy-safe)
493
+ lines << " wrap_paginated(#{hook_kwargs}) do"
494
+ body_lines = generate_list_method_body(op, path_expr)
495
+ body_lines.each { |l| lines << " #{l}" }
496
+ lines << ' end'
497
+ else
498
+ lines << " with_operation(#{hook_kwargs}) do"
499
+
500
+ body_lines = if op[:returns_void]
501
+ generate_void_method_body(op, path_expr)
502
+ else
503
+ generate_get_method_body(op, path_expr)
504
+ end
505
+
506
+ body_lines.each { |l| lines << " #{l}" }
507
+ lines << ' end'
508
+ end
509
+
510
+ lines << ' end'
511
+ lines
512
+ end
513
+
514
+ def build_hook_kwargs(op, service_name)
515
+ kwargs = []
516
+ kwargs << "service: \"#{service_name.downcase}\""
517
+ kwargs << "operation: \"#{op[:operation_id]}\""
518
+ kwargs << "is_mutation: #{op[:is_mutation]}"
519
+
520
+ resource_param = op[:path_params].last
521
+ kwargs << "resource_id: #{to_snake_case(resource_param[:name])}" if resource_param
522
+
523
+ kwargs.join(', ')
524
+ end
525
+
526
+ def build_params(op)
527
+ params = []
528
+
529
+ # Path parameters as keyword args
530
+ op[:path_params].each do |p|
531
+ params << "#{to_snake_case(p[:name])}:"
532
+ end
533
+
534
+ # Binary upload parameters
535
+ if op[:has_binary_body]
536
+ params << 'data:'
537
+ params << 'content_type:'
538
+ elsif op[:has_body]
539
+ # Required body params first (no default), then optional (with nil default)
540
+ required_body_params = op[:body_params].select { |b| b[:required] }
541
+ optional_body_params = op[:body_params].reject { |b| b[:required] }
542
+
543
+ required_body_params.each do |b|
544
+ params << "#{to_snake_case(b[:name])}:"
545
+ end
546
+
547
+ optional_body_params.each do |b|
548
+ params << "#{to_snake_case(b[:name])}: nil"
549
+ end
550
+ end
551
+
552
+ # Query parameters - required first, then optional
553
+ required_query_params = op[:query_params].select { |q| q[:required] }
554
+ optional_query_params = op[:query_params].reject { |q| q[:required] }
555
+
556
+ required_query_params.each do |q|
557
+ params << "#{to_snake_case(q[:name])}:"
558
+ end
559
+
560
+ optional_query_params.each do |q|
561
+ params << "#{to_snake_case(q[:name])}: nil"
562
+ end
563
+
564
+ params.join(', ')
565
+ end
566
+
567
+ # Build body hash expression from explicit body params
568
+ def build_body_expression(op)
569
+ return '{}' unless op[:body_params]&.any?
570
+
571
+ param_mappings = op[:body_params].map do |b|
572
+ ruby_name = to_snake_case(b[:name])
573
+ "#{b[:name]}: #{ruby_name}"
574
+ end
575
+
576
+ "compact_params(#{param_mappings.join(', ')})"
577
+ end
578
+
579
+ def build_path_expression(op)
580
+ "\"#{op[:path]}\""
581
+ end
582
+
583
+ def retryable_kwarg_for(op)
584
+ if op[:no_retry]
585
+ ', retryable: false'
586
+ elsif op[:idempotent_post]
587
+ ', retryable: true'
588
+ else
589
+ ''
590
+ end
591
+ end
592
+
593
+ def generate_void_method_body(op, path_expr)
594
+ lines = []
595
+ http_method = op[:http_method].downcase
596
+ retryable_kwarg = retryable_kwarg_for(op)
597
+
598
+ if op[:has_body]
599
+ body_expr = build_body_expression(op)
600
+ lines << " http_#{http_method}(#{path_expr}, body: #{body_expr}#{retryable_kwarg})"
601
+ else
602
+ lines << " http_#{http_method}(#{path_expr}#{retryable_kwarg})"
603
+ end
604
+ lines << ' nil'
605
+ lines
606
+ end
607
+
608
+ def generate_list_method_body(op, path_expr)
609
+ lines = []
610
+
611
+ if op[:query_params].any?
612
+ param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
613
+ lines << " params = compact_params(#{param_names.join(', ')})"
614
+ lines << " paginate(#{path_expr}, params: params)"
615
+ else
616
+ lines << " paginate(#{path_expr})"
617
+ end
618
+
619
+ lines
620
+ end
621
+
622
+ def generate_get_method_body(op, path_expr)
623
+ lines = []
624
+ http_method = op[:http_method].downcase
625
+ retryable_kwarg = retryable_kwarg_for(op)
626
+
627
+ if op[:has_binary_body]
628
+ if op[:query_params].any?
629
+ query_parts = op[:query_params].map do |q|
630
+ "#{q[:name]}=\#{URI.encode_www_form_component(#{to_snake_case(q[:name])}.to_s)}"
631
+ end
632
+ query_string = query_parts.join('&')
633
+ path_expr_with_query = path_expr.sub(/"$/, "?#{query_string}\"")
634
+ lines << " http_#{http_method}_raw(#{path_expr_with_query}, body: data, content_type: content_type).json"
635
+ else
636
+ lines << " http_#{http_method}_raw(#{path_expr}, body: data, content_type: content_type).json"
637
+ end
638
+ elsif op[:has_body]
639
+ body_expr = build_body_expression(op)
640
+ lines << " http_#{http_method}(#{path_expr}, body: #{body_expr}#{retryable_kwarg}).json"
641
+ elsif op[:query_params].any?
642
+ param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
643
+ lines << " http_#{http_method}(#{path_expr}, params: compact_params(#{param_names.join(', ')})#{retryable_kwarg}).json"
644
+ else
645
+ lines << " http_#{http_method}(#{path_expr}#{retryable_kwarg}).json"
646
+ end
647
+
648
+ lines
649
+ end
650
+ end
651
+
652
+ # Main execution
653
+ if __FILE__ == $PROGRAM_NAME
654
+ openapi_path = nil
655
+ output_dir = nil
656
+
657
+ i = 0
658
+ while i < ARGV.length
659
+ case ARGV[i]
660
+ when '--openapi'
661
+ openapi_path = ARGV[i + 1]
662
+ i += 2
663
+ when '--output'
664
+ output_dir = ARGV[i + 1]
665
+ i += 2
666
+ else
667
+ i += 1
668
+ end
669
+ end
670
+
671
+ openapi_path ||= File.expand_path('../../openapi.json', __dir__)
672
+ output_dir ||= File.expand_path('../lib/fizzy/generated/services', __dir__)
673
+
674
+ unless File.exist?(openapi_path)
675
+ warn "Error: OpenAPI file not found: #{openapi_path}"
676
+ exit 1
677
+ end
678
+
679
+ generator = ServiceGenerator.new(openapi_path)
680
+ generator.generate(output_dir)
681
+ end