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.
- checksums.yaml +7 -0
- data/.rubocop.yml +18 -0
- data/Rakefile +26 -0
- data/fizzy-sdk.gemspec +45 -0
- data/lib/fizzy/auth_strategy.rb +38 -0
- data/lib/fizzy/bulkhead.rb +68 -0
- data/lib/fizzy/cache.rb +101 -0
- data/lib/fizzy/chain_hooks.rb +45 -0
- data/lib/fizzy/circuit_breaker.rb +115 -0
- data/lib/fizzy/client.rb +212 -0
- data/lib/fizzy/config.rb +143 -0
- data/lib/fizzy/cookie_auth.rb +27 -0
- data/lib/fizzy/errors.rb +291 -0
- data/lib/fizzy/generated/metadata.json +1341 -0
- data/lib/fizzy/generated/services/boards_service.rb +91 -0
- data/lib/fizzy/generated/services/cards_service.rb +313 -0
- data/lib/fizzy/generated/services/columns_service.rb +69 -0
- data/lib/fizzy/generated/services/comments_service.rb +68 -0
- data/lib/fizzy/generated/services/devices_service.rb +35 -0
- data/lib/fizzy/generated/services/identity_service.rb +19 -0
- data/lib/fizzy/generated/services/miscellaneous_service.rb +256 -0
- data/lib/fizzy/generated/services/notifications_service.rb +65 -0
- data/lib/fizzy/generated/services/pins_service.rb +19 -0
- data/lib/fizzy/generated/services/reactions_service.rb +80 -0
- data/lib/fizzy/generated/services/sessions_service.rb +58 -0
- data/lib/fizzy/generated/services/steps_service.rb +69 -0
- data/lib/fizzy/generated/services/tags_service.rb +20 -0
- data/lib/fizzy/generated/services/uploads_service.rb +24 -0
- data/lib/fizzy/generated/services/users_service.rb +52 -0
- data/lib/fizzy/generated/services/webhooks_service.rb +83 -0
- data/lib/fizzy/generated/types.rb +988 -0
- data/lib/fizzy/hooks.rb +70 -0
- data/lib/fizzy/http.rb +411 -0
- data/lib/fizzy/logger_hooks.rb +46 -0
- data/lib/fizzy/magic_link_flow.rb +57 -0
- data/lib/fizzy/noop_hooks.rb +9 -0
- data/lib/fizzy/operation_info.rb +17 -0
- data/lib/fizzy/rate_limiter.rb +68 -0
- data/lib/fizzy/request_info.rb +10 -0
- data/lib/fizzy/request_result.rb +14 -0
- data/lib/fizzy/resilience.rb +59 -0
- data/lib/fizzy/security.rb +103 -0
- data/lib/fizzy/services/base_service.rb +116 -0
- data/lib/fizzy/static_token_provider.rb +24 -0
- data/lib/fizzy/token_provider.rb +42 -0
- data/lib/fizzy/version.rb +6 -0
- data/lib/fizzy/webhooks/verify.rb +36 -0
- data/lib/fizzy.rb +95 -0
- data/scripts/generate-metadata.rb +105 -0
- data/scripts/generate-services.rb +681 -0
- data/scripts/generate-types.rb +160 -0
- 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
|