basecamp-sdk 0.2.1

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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +14 -0
  3. data/.yardopts +6 -0
  4. data/README.md +293 -0
  5. data/Rakefile +26 -0
  6. data/basecamp-sdk.gemspec +46 -0
  7. data/lib/basecamp/auth_strategy.rb +38 -0
  8. data/lib/basecamp/chain_hooks.rb +45 -0
  9. data/lib/basecamp/client.rb +428 -0
  10. data/lib/basecamp/config.rb +143 -0
  11. data/lib/basecamp/errors.rb +289 -0
  12. data/lib/basecamp/generated/metadata.json +2281 -0
  13. data/lib/basecamp/generated/services/attachments_service.rb +24 -0
  14. data/lib/basecamp/generated/services/boosts_service.rb +70 -0
  15. data/lib/basecamp/generated/services/campfires_service.rb +122 -0
  16. data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
  17. data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
  18. data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
  19. data/lib/basecamp/generated/services/cards_service.rb +66 -0
  20. data/lib/basecamp/generated/services/checkins_service.rb +157 -0
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
  23. data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
  25. data/lib/basecamp/generated/services/comments_service.rb +49 -0
  26. data/lib/basecamp/generated/services/documents_service.rb +52 -0
  27. data/lib/basecamp/generated/services/events_service.rb +20 -0
  28. data/lib/basecamp/generated/services/forwards_service.rb +67 -0
  29. data/lib/basecamp/generated/services/lineup_service.rb +44 -0
  30. data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
  31. data/lib/basecamp/generated/services/message_types_service.rb +59 -0
  32. data/lib/basecamp/generated/services/messages_service.rb +75 -0
  33. data/lib/basecamp/generated/services/people_service.rb +73 -0
  34. data/lib/basecamp/generated/services/projects_service.rb +63 -0
  35. data/lib/basecamp/generated/services/recordings_service.rb +64 -0
  36. data/lib/basecamp/generated/services/reports_service.rb +56 -0
  37. data/lib/basecamp/generated/services/schedules_service.rb +92 -0
  38. data/lib/basecamp/generated/services/search_service.rb +31 -0
  39. data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
  40. data/lib/basecamp/generated/services/templates_service.rb +82 -0
  41. data/lib/basecamp/generated/services/timeline_service.rb +20 -0
  42. data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
  43. data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
  44. data/lib/basecamp/generated/services/todolists_service.rb +53 -0
  45. data/lib/basecamp/generated/services/todos_service.rb +106 -0
  46. data/lib/basecamp/generated/services/todosets_service.rb +20 -0
  47. data/lib/basecamp/generated/services/tools_service.rb +80 -0
  48. data/lib/basecamp/generated/services/uploads_service.rb +61 -0
  49. data/lib/basecamp/generated/services/vaults_service.rb +49 -0
  50. data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
  51. data/lib/basecamp/generated/types.rb +3196 -0
  52. data/lib/basecamp/hooks.rb +70 -0
  53. data/lib/basecamp/http.rb +440 -0
  54. data/lib/basecamp/logger_hooks.rb +46 -0
  55. data/lib/basecamp/noop_hooks.rb +9 -0
  56. data/lib/basecamp/oauth/discovery.rb +123 -0
  57. data/lib/basecamp/oauth/errors.rb +35 -0
  58. data/lib/basecamp/oauth/exchange.rb +291 -0
  59. data/lib/basecamp/oauth/pkce.rb +68 -0
  60. data/lib/basecamp/oauth/types.rb +133 -0
  61. data/lib/basecamp/oauth.rb +56 -0
  62. data/lib/basecamp/oauth_token_provider.rb +108 -0
  63. data/lib/basecamp/operation_info.rb +17 -0
  64. data/lib/basecamp/request_info.rb +10 -0
  65. data/lib/basecamp/request_result.rb +14 -0
  66. data/lib/basecamp/security.rb +112 -0
  67. data/lib/basecamp/services/attachments_service.rb +33 -0
  68. data/lib/basecamp/services/authorization_service.rb +47 -0
  69. data/lib/basecamp/services/base_service.rb +146 -0
  70. data/lib/basecamp/services/campfires_service.rb +141 -0
  71. data/lib/basecamp/services/card_columns_service.rb +106 -0
  72. data/lib/basecamp/services/card_steps_service.rb +86 -0
  73. data/lib/basecamp/services/card_tables_service.rb +23 -0
  74. data/lib/basecamp/services/cards_service.rb +93 -0
  75. data/lib/basecamp/services/checkins_service.rb +127 -0
  76. data/lib/basecamp/services/client_approvals_service.rb +33 -0
  77. data/lib/basecamp/services/client_correspondences_service.rb +33 -0
  78. data/lib/basecamp/services/client_replies_service.rb +35 -0
  79. data/lib/basecamp/services/comments_service.rb +63 -0
  80. data/lib/basecamp/services/documents_service.rb +74 -0
  81. data/lib/basecamp/services/events_service.rb +27 -0
  82. data/lib/basecamp/services/forwards_service.rb +80 -0
  83. data/lib/basecamp/services/lineup_service.rb +67 -0
  84. data/lib/basecamp/services/message_boards_service.rb +24 -0
  85. data/lib/basecamp/services/message_types_service.rb +79 -0
  86. data/lib/basecamp/services/messages_service.rb +133 -0
  87. data/lib/basecamp/services/people_service.rb +73 -0
  88. data/lib/basecamp/services/projects_service.rb +67 -0
  89. data/lib/basecamp/services/recordings_service.rb +127 -0
  90. data/lib/basecamp/services/reports_service.rb +80 -0
  91. data/lib/basecamp/services/schedules_service.rb +156 -0
  92. data/lib/basecamp/services/search_service.rb +36 -0
  93. data/lib/basecamp/services/subscriptions_service.rb +67 -0
  94. data/lib/basecamp/services/templates_service.rb +96 -0
  95. data/lib/basecamp/services/timeline_service.rb +62 -0
  96. data/lib/basecamp/services/timesheet_service.rb +68 -0
  97. data/lib/basecamp/services/todolist_groups_service.rb +100 -0
  98. data/lib/basecamp/services/todolists_service.rb +104 -0
  99. data/lib/basecamp/services/todos_service.rb +156 -0
  100. data/lib/basecamp/services/todosets_service.rb +23 -0
  101. data/lib/basecamp/services/tools_service.rb +89 -0
  102. data/lib/basecamp/services/uploads_service.rb +84 -0
  103. data/lib/basecamp/services/vaults_service.rb +84 -0
  104. data/lib/basecamp/services/webhooks_service.rb +88 -0
  105. data/lib/basecamp/static_token_provider.rb +24 -0
  106. data/lib/basecamp/token_provider.rb +42 -0
  107. data/lib/basecamp/version.rb +6 -0
  108. data/lib/basecamp/webhooks/event.rb +52 -0
  109. data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
  110. data/lib/basecamp/webhooks/receiver.rb +161 -0
  111. data/lib/basecamp/webhooks/verify.rb +36 -0
  112. data/lib/basecamp.rb +107 -0
  113. data/scripts/generate-metadata.rb +106 -0
  114. data/scripts/generate-services.rb +778 -0
  115. data/scripts/generate-types.rb +191 -0
  116. metadata +316 -0
@@ -0,0 +1,778 @@
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/basecamp/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 overrides
25
+ TAG_TO_SERVICE = {
26
+ 'Card Tables' => 'CardTables',
27
+ 'Campfire' => 'Campfires',
28
+ 'Todos' => 'Todos',
29
+ 'Messages' => 'Messages',
30
+ 'Files' => 'Files',
31
+ 'Forwards' => 'Forwards',
32
+ 'Schedule' => 'Schedules',
33
+ 'People' => 'People',
34
+ 'Projects' => 'Projects',
35
+ 'Automation' => 'Automation',
36
+ 'ClientFeatures' => 'ClientFeatures',
37
+ 'Boosts' => 'Boosts',
38
+ 'Untagged' => 'Miscellaneous'
39
+ }.freeze
40
+
41
+ # Service splits - some tags map to multiple services
42
+ SERVICE_SPLITS = {
43
+ 'Campfire' => {
44
+ 'Campfires' => %w[
45
+ GetCampfire ListCampfires
46
+ ListChatbots CreateChatbot GetChatbot UpdateChatbot DeleteChatbot
47
+ ListCampfireLines CreateCampfireLine GetCampfireLine DeleteCampfireLine
48
+ ]
49
+ },
50
+ 'Card Tables' => {
51
+ 'CardTables' => %w[GetCardTable],
52
+ 'Cards' => %w[GetCard UpdateCard MoveCard CreateCard ListCards],
53
+ 'CardColumns' => %w[
54
+ GetCardColumn UpdateCardColumn SetCardColumnColor
55
+ EnableCardColumnOnHold DisableCardColumnOnHold
56
+ CreateCardColumn MoveCardColumn
57
+ ],
58
+ 'CardSteps' => %w[
59
+ CreateCardStep UpdateCardStep SetCardStepCompletion
60
+ RepositionCardStep
61
+ ]
62
+ },
63
+ 'Files' => {
64
+ 'Attachments' => %w[CreateAttachment],
65
+ 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions],
66
+ 'Vaults' => %w[GetVault UpdateVault ListVaults CreateVault],
67
+ 'Documents' => %w[GetDocument UpdateDocument ListDocuments CreateDocument]
68
+ },
69
+ 'Automation' => {
70
+ 'Tools' => %w[GetTool UpdateTool DeleteTool CloneTool EnableTool DisableTool RepositionTool],
71
+ 'Recordings' => %w[GetRecording ArchiveRecording UnarchiveRecording TrashRecording ListRecordings],
72
+ 'Webhooks' => %w[ListWebhooks CreateWebhook GetWebhook UpdateWebhook DeleteWebhook],
73
+ 'Events' => %w[ListEvents],
74
+ 'Lineup' => %w[CreateLineupMarker UpdateLineupMarker DeleteLineupMarker],
75
+ 'Search' => %w[Search GetSearchMetadata],
76
+ 'Templates' => %w[
77
+ ListTemplates CreateTemplate GetTemplate UpdateTemplate
78
+ DeleteTemplate CreateProjectFromTemplate GetProjectConstruction
79
+ ],
80
+ 'Checkins' => %w[
81
+ GetQuestionnaire ListQuestions CreateQuestion GetQuestion
82
+ UpdateQuestion ListAnswers CreateAnswer GetAnswer UpdateAnswer
83
+ ]
84
+ },
85
+ 'Messages' => {
86
+ 'Messages' => %w[GetMessage UpdateMessage CreateMessage ListMessages PinMessage UnpinMessage],
87
+ 'MessageBoards' => %w[GetMessageBoard],
88
+ 'MessageTypes' => %w[
89
+ ListMessageTypes CreateMessageType GetMessageType
90
+ UpdateMessageType DeleteMessageType
91
+ ],
92
+ 'Comments' => %w[GetComment UpdateComment ListComments CreateComment]
93
+ },
94
+ 'People' => {
95
+ 'People' => %w[
96
+ GetMyProfile ListPeople GetPerson ListProjectPeople
97
+ UpdateProjectAccess ListPingablePeople
98
+ ],
99
+ 'Subscriptions' => %w[GetSubscription Subscribe Unsubscribe UpdateSubscription]
100
+ },
101
+ 'Schedule' => {
102
+ 'Schedules' => %w[
103
+ GetSchedule UpdateScheduleSettings ListScheduleEntries
104
+ CreateScheduleEntry GetScheduleEntry UpdateScheduleEntry
105
+ GetScheduleEntryOccurrence
106
+ ],
107
+ 'Timesheets' => %w[GetRecordingTimesheet GetProjectTimesheet GetTimesheetReport GetTimesheetEntry CreateTimesheetEntry UpdateTimesheetEntry]
108
+ },
109
+ 'ClientFeatures' => {
110
+ 'ClientApprovals' => %w[ListClientApprovals GetClientApproval],
111
+ 'ClientCorrespondences' => %w[ListClientCorrespondences GetClientCorrespondence],
112
+ 'ClientReplies' => %w[ListClientReplies GetClientReply],
113
+ 'ClientVisibility' => %w[SetClientVisibility]
114
+ },
115
+ 'Todos' => {
116
+ 'Todos' => %w[ListTodos CreateTodo GetTodo UpdateTodo CompleteTodo UncompleteTodo TrashTodo],
117
+ 'Todolists' => %w[GetTodolistOrGroup UpdateTodolistOrGroup ListTodolists CreateTodolist],
118
+ 'Todosets' => %w[GetTodoset],
119
+ 'TodolistGroups' => %w[ListTodolistGroups CreateTodolistGroup RepositionTodolistGroup]
120
+ },
121
+ 'Untagged' => {
122
+ 'Timeline' => %w[GetProjectTimeline],
123
+ 'Reports' => %w[GetProgressReport GetUpcomingSchedule GetAssignedTodos GetOverdueTodos GetPersonProgress],
124
+ 'Checkins' => %w[
125
+ GetQuestionReminders ListQuestionAnswerers GetAnswersByPerson
126
+ UpdateQuestionNotificationSettings PauseQuestion ResumeQuestion
127
+ ],
128
+ 'Todos' => %w[RepositionTodo],
129
+ 'People' => %w[ListAssignablePeople],
130
+ 'CardColumns' => %w[SubscribeToCardColumn UnsubscribeFromCardColumn]
131
+ }
132
+ }.freeze
133
+
134
+ # Method name overrides
135
+ METHOD_NAME_OVERRIDES = {
136
+ 'GetMyProfile' => 'my_profile',
137
+ 'GetTodolistOrGroup' => 'get',
138
+ 'UpdateTodolistOrGroup' => 'update',
139
+ 'SetCardColumnColor' => 'set_color',
140
+ 'EnableCardColumnOnHold' => 'enable_on_hold',
141
+ 'DisableCardColumnOnHold' => 'disable_on_hold',
142
+ 'RepositionCardStep' => 'reposition',
143
+ 'CreateCardStep' => 'create',
144
+ 'UpdateCardStep' => 'update',
145
+ 'SetCardStepCompletion' => 'set_completion',
146
+ 'GetQuestionnaire' => 'get_questionnaire',
147
+ 'GetQuestion' => 'get_question',
148
+ 'GetAnswer' => 'get_answer',
149
+ 'ListQuestions' => 'list_questions',
150
+ 'ListAnswers' => 'list_answers',
151
+ 'CreateQuestion' => 'create_question',
152
+ 'CreateAnswer' => 'create_answer',
153
+ 'UpdateQuestion' => 'update_question',
154
+ 'UpdateAnswer' => 'update_answer',
155
+ 'GetQuestionReminders' => 'reminders',
156
+ 'GetAnswersByPerson' => 'by_person',
157
+ 'ListQuestionAnswerers' => 'answerers',
158
+ 'UpdateQuestionNotificationSettings' => 'update_notification_settings',
159
+ 'PauseQuestion' => 'pause',
160
+ 'ResumeQuestion' => 'resume',
161
+ 'GetSearchMetadata' => 'metadata',
162
+ 'Search' => 'search',
163
+ 'CreateProjectFromTemplate' => 'create_project',
164
+ 'GetProjectConstruction' => 'get_construction',
165
+ 'GetRecordingTimesheet' => 'for_recording',
166
+ 'GetProjectTimesheet' => 'for_project',
167
+ 'GetTimesheetReport' => 'report',
168
+ 'GetTimesheetEntry' => 'get',
169
+ 'CreateTimesheetEntry' => 'create',
170
+ 'UpdateTimesheetEntry' => 'update',
171
+ 'GetProgressReport' => 'progress',
172
+ 'GetUpcomingSchedule' => 'upcoming',
173
+ 'GetAssignedTodos' => 'assigned',
174
+ 'GetOverdueTodos' => 'overdue',
175
+ 'GetPersonProgress' => 'person_progress',
176
+ 'SubscribeToCardColumn' => 'subscribe_to_column',
177
+ 'UnsubscribeFromCardColumn' => 'unsubscribe_from_column',
178
+ 'SetClientVisibility' => 'set_visibility',
179
+ # Campfires - use specific names to avoid conflicts between campfire, chatbots, and lines
180
+ 'GetCampfire' => 'get',
181
+ 'ListCampfires' => 'list',
182
+ 'ListChatbots' => 'list_chatbots',
183
+ 'CreateChatbot' => 'create_chatbot',
184
+ 'GetChatbot' => 'get_chatbot',
185
+ 'UpdateChatbot' => 'update_chatbot',
186
+ 'DeleteChatbot' => 'delete_chatbot',
187
+ 'ListCampfireLines' => 'list_lines',
188
+ 'CreateCampfireLine' => 'create_line',
189
+ 'GetCampfireLine' => 'get_line',
190
+ 'DeleteCampfireLine' => 'delete_line',
191
+ # Forwards - use specific names to avoid conflicts between forwards, replies, and inbox
192
+ 'GetForward' => 'get',
193
+ 'ListForwards' => 'list',
194
+ 'GetForwardReply' => 'get_reply',
195
+ 'ListForwardReplies' => 'list_replies',
196
+ 'CreateForwardReply' => 'create_reply',
197
+ 'GetInbox' => 'get_inbox',
198
+ # Uploads - use specific names to avoid conflicts with versions
199
+ 'GetUpload' => 'get',
200
+ 'UpdateUpload' => 'update',
201
+ 'ListUploads' => 'list',
202
+ 'CreateUpload' => 'create',
203
+ 'ListUploadVersions' => 'list_versions',
204
+ 'GetMessage' => 'get',
205
+ 'UpdateMessage' => 'update',
206
+ 'CreateMessage' => 'create',
207
+ 'ListMessages' => 'list',
208
+ 'PinMessage' => 'pin',
209
+ 'UnpinMessage' => 'unpin',
210
+ 'GetMessageBoard' => 'get',
211
+ 'GetMessageType' => 'get',
212
+ 'UpdateMessageType' => 'update',
213
+ 'CreateMessageType' => 'create',
214
+ 'ListMessageTypes' => 'list',
215
+ 'DeleteMessageType' => 'delete',
216
+ 'GetComment' => 'get',
217
+ 'UpdateComment' => 'update',
218
+ 'CreateComment' => 'create',
219
+ 'ListComments' => 'list',
220
+ 'ListProjectPeople' => 'list_for_project',
221
+ 'ListPingablePeople' => 'list_pingable',
222
+ 'ListAssignablePeople' => 'list_assignable',
223
+ 'GetSchedule' => 'get',
224
+ 'UpdateScheduleSettings' => 'update_settings',
225
+ 'GetScheduleEntry' => 'get_entry',
226
+ 'UpdateScheduleEntry' => 'update_entry',
227
+ 'CreateScheduleEntry' => 'create_entry',
228
+ 'ListScheduleEntries' => 'list_entries',
229
+ 'GetScheduleEntryOccurrence' => 'get_entry_occurrence'
230
+ }.freeze
231
+
232
+ # Verb patterns for extracting method names
233
+ VERB_PATTERNS = [
234
+ { prefix: 'Subscribe', method: 'subscribe' },
235
+ { prefix: 'Unsubscribe', method: 'unsubscribe' },
236
+ { prefix: 'List', method: 'list' },
237
+ { prefix: 'Get', method: 'get' },
238
+ { prefix: 'Create', method: 'create' },
239
+ { prefix: 'Update', method: 'update' },
240
+ { prefix: 'Delete', method: 'delete' },
241
+ { prefix: 'Trash', method: 'trash' },
242
+ { prefix: 'Archive', method: 'archive' },
243
+ { prefix: 'Unarchive', method: 'unarchive' },
244
+ { prefix: 'Complete', method: 'complete' },
245
+ { prefix: 'Uncomplete', method: 'uncomplete' },
246
+ { prefix: 'Enable', method: 'enable' },
247
+ { prefix: 'Disable', method: 'disable' },
248
+ { prefix: 'Reposition', method: 'reposition' },
249
+ { prefix: 'Move', method: 'move' },
250
+ { prefix: 'Clone', method: 'clone' },
251
+ { prefix: 'Set', method: 'set' },
252
+ { prefix: 'Pin', method: 'pin' },
253
+ { prefix: 'Unpin', method: 'unpin' },
254
+ { prefix: 'Pause', method: 'pause' },
255
+ { prefix: 'Resume', method: 'resume' },
256
+ { prefix: 'Search', method: 'search' }
257
+ ].freeze
258
+
259
+ SIMPLE_RESOURCES = %w[
260
+ todo todos todolist todolists todoset message messages comment comments
261
+ card cards cardtable cardcolumn cardstep column step project projects
262
+ person people campfire campfires chatbot chatbots webhook webhooks
263
+ vault vaults document documents upload uploads schedule scheduleentry
264
+ scheduleentries event events recording recordings template templates
265
+ attachment question questions answer answers questionnaire subscription
266
+ forward forwards inbox messageboard messagetype messagetypes tool
267
+ lineupmarker clientapproval clientapprovals clientcorrespondence
268
+ clientcorrespondences clientreply clientreplies forwardreply
269
+ forwardreplies campfireline campfirelines todolistgroup todolistgroups
270
+ todolistorgroup uploadversions
271
+ ].freeze
272
+
273
+ def initialize(openapi_path)
274
+ @openapi = JSON.parse(File.read(openapi_path))
275
+ @schemas = @openapi.dig('components', 'schemas') || {}
276
+ end
277
+
278
+ def generate(output_dir)
279
+ FileUtils.mkdir_p(output_dir)
280
+
281
+ services = group_operations
282
+ generated_files = []
283
+
284
+ services.each do |name, service|
285
+ code = generate_service(service)
286
+ filename = "#{to_snake_case(name)}_service.rb"
287
+ filepath = File.join(output_dir, filename)
288
+ File.write(filepath, code)
289
+ generated_files << filename
290
+ puts "Generated #{filename} (#{service[:operations].length} operations)"
291
+ end
292
+
293
+ puts "\nGenerated #{services.length} services with #{services.values.sum { |s| s[:operations].length }} operations total."
294
+ generated_files
295
+ end
296
+
297
+ private
298
+
299
+ def group_operations
300
+ services = {}
301
+
302
+ @openapi['paths'].each do |path, path_item|
303
+ METHODS.each do |method|
304
+ operation = path_item[method]
305
+ next unless operation
306
+
307
+ tag = operation['tags']&.first || 'Untagged'
308
+ parsed = parse_operation(path, method, operation)
309
+
310
+ # Determine which service this operation belongs to
311
+ service_name = find_service_for_operation(tag, operation['operationId'])
312
+
313
+ services[service_name] ||= {
314
+ name: service_name,
315
+ class_name: "#{service_name}Service",
316
+ description: "Service for #{service_name} operations",
317
+ operations: []
318
+ }
319
+
320
+ services[service_name][:operations] << parsed
321
+ end
322
+ end
323
+
324
+ services
325
+ end
326
+
327
+ def find_service_for_operation(tag, operation_id)
328
+ if SERVICE_SPLITS[tag]
329
+ SERVICE_SPLITS[tag].each do |svc, op_ids|
330
+ return svc if op_ids.include?(operation_id)
331
+ end
332
+ end
333
+
334
+ TAG_TO_SERVICE[tag] || tag.gsub(/\s+/, '')
335
+ end
336
+
337
+ def parse_operation(path, method, operation)
338
+ operation_id = operation['operationId']
339
+ method_name = extract_method_name(operation_id)
340
+ http_method = method.upcase
341
+ description = operation['description']&.lines&.first&.strip || "#{method_name} operation"
342
+
343
+ # Extract path parameters (excluding accountId)
344
+ path_params = (operation['parameters'] || [])
345
+ .select { |p| p['in'] == 'path' && p['name'] != 'accountId' }
346
+ .map { |p| { name: p['name'], type: schema_to_ruby_type(p['schema']), description: p['description'] } }
347
+
348
+ # Extract query parameters
349
+ query_params = (operation['parameters'] || [])
350
+ .select { |p| p['in'] == 'query' }
351
+ .map do |p|
352
+ {
353
+ name: p['name'],
354
+ type: schema_to_ruby_type(p['schema']),
355
+ required: p['required'] || false,
356
+ description: p['description']
357
+ }
358
+ end
359
+
360
+ # Check for request body (JSON or binary)
361
+ body_schema_ref = operation.dig('requestBody', 'content', 'application/json', 'schema')
362
+ has_binary_body = operation.dig('requestBody', 'content', 'application/octet-stream', 'schema')
363
+
364
+ # Extract body parameters from schema
365
+ body_params = extract_body_params(body_schema_ref)
366
+
367
+ # Check response
368
+ success_response = operation.dig('responses', '200') || operation.dig('responses', '201')
369
+ response_schema = success_response&.dig('content', 'application/json', 'schema')
370
+ returns_void = response_schema.nil?
371
+ returns_array = response_schema&.dig('type') == 'array'
372
+
373
+ {
374
+ operation_id: operation_id,
375
+ method_name: method_name,
376
+ http_method: http_method,
377
+ path: convert_path(path),
378
+ description: description,
379
+ path_params: path_params,
380
+ query_params: query_params,
381
+ body_params: body_params,
382
+ has_body: body_params.any?,
383
+ has_binary_body: !!has_binary_body,
384
+ returns_void: returns_void,
385
+ returns_array: returns_array,
386
+ is_mutation: http_method != 'GET',
387
+ has_pagination: !!operation['x-basecamp-pagination']
388
+ }
389
+ end
390
+
391
+ # Extract body parameters from a schema reference
392
+ def extract_body_params(schema_ref)
393
+ return [] unless schema_ref
394
+
395
+ # Resolve $ref
396
+ schema = resolve_schema_ref(schema_ref)
397
+ return [] unless schema && schema['properties']
398
+
399
+ required_fields = schema['required'] || []
400
+
401
+ schema['properties'].map do |name, prop|
402
+ type = schema_to_ruby_type(prop)
403
+ format_hint = extract_format_hint(prop)
404
+ {
405
+ name: name,
406
+ type: type,
407
+ required: required_fields.include?(name),
408
+ description: prop['description'],
409
+ format_hint: format_hint
410
+ }
411
+ end
412
+ end
413
+
414
+ # Resolve a schema reference to its definition
415
+ def resolve_schema_ref(schema_or_ref)
416
+ return schema_or_ref unless schema_or_ref['$ref']
417
+
418
+ ref_path = schema_or_ref['$ref']
419
+ # Handle #/components/schemas/SchemaName format
420
+ if ref_path.start_with?('#/components/schemas/')
421
+ schema_name = ref_path.split('/').last
422
+ @schemas[schema_name]
423
+ else
424
+ nil
425
+ end
426
+ end
427
+
428
+ # Extract format hint for documentation
429
+ def extract_format_hint(prop)
430
+ return nil unless prop
431
+
432
+ # Check for x-go-type hints (dates)
433
+ case prop['x-go-type']
434
+ when 'types.Date'
435
+ return 'YYYY-MM-DD'
436
+ when 'types.DateTime', 'time.Time'
437
+ return 'RFC3339 (e.g., 2024-12-15T09:00:00Z)'
438
+ end
439
+
440
+ # Check for format field
441
+ case prop['format']
442
+ when 'date'
443
+ 'YYYY-MM-DD'
444
+ when 'date-time'
445
+ 'RFC3339 (e.g., 2024-12-15T09:00:00Z)'
446
+ end
447
+ end
448
+
449
+ def extract_method_name(operation_id)
450
+ return METHOD_NAME_OVERRIDES[operation_id] if METHOD_NAME_OVERRIDES.key?(operation_id)
451
+
452
+ VERB_PATTERNS.each do |pattern|
453
+ if operation_id.start_with?(pattern[:prefix])
454
+ remainder = operation_id[pattern[:prefix].length..]
455
+ return pattern[:method] if remainder.empty?
456
+
457
+ resource = to_snake_case(remainder)
458
+ return pattern[:method] if simple_resource?(resource)
459
+
460
+ return "#{pattern[:method]}_#{resource}"
461
+ end
462
+ end
463
+
464
+ to_snake_case(operation_id)
465
+ end
466
+
467
+ def simple_resource?(resource)
468
+ SIMPLE_RESOURCES.include?(resource.downcase.gsub('_', ''))
469
+ end
470
+
471
+ def convert_path(path)
472
+ # Remove /{accountId} prefix
473
+ path = path.sub(%r{^/\{accountId\}}, '')
474
+ # Convert {camelCaseParam} to #{snake_case_param}
475
+ path.gsub(/\{(\w+)\}/) do |_match|
476
+ param = ::Regexp.last_match(1)
477
+ snake_param = to_snake_case(param)
478
+ "\#{#{snake_param}}"
479
+ end
480
+ end
481
+
482
+ def schema_to_ruby_type(schema)
483
+ return 'Object' unless schema
484
+
485
+ case schema['type']
486
+ when 'integer' then 'Integer'
487
+ when 'boolean' then 'Boolean'
488
+ when 'array' then 'Array'
489
+ else 'String'
490
+ end
491
+ end
492
+
493
+ def to_snake_case(str)
494
+ str.gsub(/([a-z\d])([A-Z])/, '\1_\2')
495
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
496
+ .downcase
497
+ end
498
+
499
+ def generate_service(service)
500
+ lines = []
501
+
502
+ # Check if any operation uses URI encoding (binary uploads with query params)
503
+ needs_uri = service[:operations].any? { |op| op[:has_binary_body] && op[:query_params].any? }
504
+
505
+ lines << '# frozen_string_literal: true'
506
+ lines << ''
507
+ lines << 'require "uri"' if needs_uri
508
+ lines << '' if needs_uri
509
+ lines << 'module Basecamp'
510
+ lines << ' module Services'
511
+ lines << " # #{service[:description]}"
512
+ lines << ' #'
513
+ lines << ' # @generated from OpenAPI spec'
514
+ lines << " class #{service[:class_name]} < BaseService"
515
+
516
+ service[:operations].each do |op|
517
+ lines << ''
518
+ lines.concat(generate_method(op, service_name: service[:name]))
519
+ end
520
+
521
+ lines << ' end'
522
+ lines << ' end'
523
+ lines << 'end'
524
+ lines << ''
525
+
526
+ lines.join("\n")
527
+ end
528
+
529
+ def generate_method(op, service_name:)
530
+ lines = []
531
+
532
+ # Method signature
533
+ params = build_params(op)
534
+
535
+ # YARD documentation
536
+ lines << " # #{op[:description]}"
537
+
538
+ # Add @param tags for path params
539
+ op[:path_params].each do |p|
540
+ ruby_name = to_snake_case(p[:name])
541
+ type = p[:type] || 'Integer'
542
+ desc = p[:description] || "#{ruby_name.gsub('_', ' ')} ID"
543
+ lines << " # @param #{ruby_name} [#{type}] #{desc}"
544
+ end
545
+
546
+ # Add @param tags for binary upload params
547
+ if op[:has_binary_body]
548
+ lines << ' # @param data [String] Binary file data to upload'
549
+ lines << ' # @param content_type [String] MIME type of the file (e.g., "application/pdf", "image/png")'
550
+ end
551
+
552
+ # Add @param tags for body params
553
+ if op[:body_params]&.any?
554
+ op[:body_params].each do |b|
555
+ ruby_name = to_snake_case(b[:name])
556
+ type = b[:type] || 'Object'
557
+ type = "#{type}, nil" unless b[:required]
558
+ desc = b[:description] || ruby_name.gsub('_', ' ')
559
+ format_hint = b[:format_hint] ? " (#{b[:format_hint]})" : ''
560
+ lines << " # @param #{ruby_name} [#{type}] #{desc}#{format_hint}"
561
+ end
562
+ end
563
+
564
+ # Add @param tags for query params
565
+ op[:query_params].each do |q|
566
+ ruby_name = to_snake_case(q[:name])
567
+ type = q[:type] || 'String'
568
+ type = "#{type}, nil" unless q[:required]
569
+ desc = q[:description] || ruby_name.gsub('_', ' ')
570
+ lines << " # @param #{ruby_name} [#{type}] #{desc}"
571
+ end
572
+
573
+ # Add @return tag
574
+ if op[:returns_void]
575
+ lines << ' # @return [void]'
576
+ elsif op[:returns_array] || op[:has_pagination]
577
+ lines << ' # @return [Enumerator<Hash>] paginated results'
578
+ else
579
+ lines << ' # @return [Hash] response data'
580
+ end
581
+
582
+ lines << " def #{op[:method_name]}(#{params})"
583
+
584
+ # Build the path
585
+ path_expr = build_path_expression(op)
586
+
587
+ is_paginated = op[:returns_array] || op[:has_pagination]
588
+ hook_kwargs = build_hook_kwargs(op, service_name)
589
+
590
+ if is_paginated
591
+ # wrap_paginated defers hooks to actual iteration time (lazy-safe)
592
+ lines << " wrap_paginated(#{hook_kwargs}) do"
593
+ body_lines = generate_list_method_body(op, path_expr)
594
+ body_lines.each { |l| lines << " #{l}" }
595
+ lines << ' end'
596
+ else
597
+ lines << " with_operation(#{hook_kwargs}) do"
598
+
599
+ body_lines = if op[:returns_void]
600
+ generate_void_method_body(op, path_expr)
601
+ else
602
+ generate_get_method_body(op, path_expr)
603
+ end
604
+
605
+ body_lines.each { |l| lines << " #{l}" }
606
+ lines << ' end'
607
+ end
608
+
609
+ lines << ' end'
610
+ lines
611
+ end
612
+
613
+ def build_hook_kwargs(op, service_name)
614
+ kwargs = []
615
+ kwargs << "service: \"#{service_name.downcase}\""
616
+ kwargs << "operation: \"#{op[:method_name]}\""
617
+ kwargs << "is_mutation: #{op[:is_mutation]}"
618
+
619
+ project_param = op[:path_params].find { |p| p[:name] == 'projectId' }
620
+ resource_param = op[:path_params].reject { |p| p[:name] == 'projectId' }.last
621
+
622
+ kwargs << "project_id: project_id" if project_param
623
+ kwargs << "resource_id: #{to_snake_case(resource_param[:name])}" if resource_param
624
+
625
+ kwargs.join(', ')
626
+ end
627
+
628
+ def build_params(op)
629
+ params = []
630
+
631
+ # Path parameters as keyword args
632
+ op[:path_params].each do |p|
633
+ params << "#{to_snake_case(p[:name])}:"
634
+ end
635
+
636
+ # Binary upload parameters
637
+ if op[:has_binary_body]
638
+ params << 'data:'
639
+ params << 'content_type:'
640
+ elsif op[:has_body]
641
+ # Request body parameters as explicit keyword args (not **body)
642
+ # Required body params first (no default), then optional (with nil default)
643
+ required_body_params = op[:body_params].select { |b| b[:required] }
644
+ optional_body_params = op[:body_params].reject { |b| b[:required] }
645
+
646
+ required_body_params.each do |b|
647
+ params << "#{to_snake_case(b[:name])}:"
648
+ end
649
+
650
+ optional_body_params.each do |b|
651
+ params << "#{to_snake_case(b[:name])}: nil"
652
+ end
653
+ end
654
+
655
+ # Query parameters - required first (no default), then optional (with nil default)
656
+ required_query_params = op[:query_params].select { |q| q[:required] }
657
+ optional_query_params = op[:query_params].reject { |q| q[:required] }
658
+
659
+ required_query_params.each do |q|
660
+ params << "#{to_snake_case(q[:name])}:"
661
+ end
662
+
663
+ optional_query_params.each do |q|
664
+ params << "#{to_snake_case(q[:name])}: nil"
665
+ end
666
+
667
+ params.join(', ')
668
+ end
669
+
670
+ # Build body hash expression from explicit body params
671
+ def build_body_expression(op)
672
+ return '{}' unless op[:body_params]&.any?
673
+
674
+ # Build compact_params call with all body params
675
+ param_mappings = op[:body_params].map do |b|
676
+ ruby_name = to_snake_case(b[:name])
677
+ # Use original API name as key (snake_case), ruby variable as value
678
+ "#{b[:name]}: #{ruby_name}"
679
+ end
680
+
681
+ "compact_params(#{param_mappings.join(', ')})"
682
+ end
683
+
684
+ def build_path_expression(op)
685
+ "\"#{op[:path]}\""
686
+ end
687
+
688
+ def generate_void_method_body(op, path_expr)
689
+ lines = []
690
+ http_method = op[:http_method].downcase
691
+
692
+ if op[:has_body]
693
+ body_expr = build_body_expression(op)
694
+ lines << " http_#{http_method}(#{path_expr}, body: #{body_expr})"
695
+ else
696
+ lines << " http_#{http_method}(#{path_expr})"
697
+ end
698
+ lines << ' nil'
699
+ lines
700
+ end
701
+
702
+ def generate_list_method_body(op, path_expr)
703
+ lines = []
704
+
705
+ # Build params hash for query params
706
+ if op[:query_params].any?
707
+ param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
708
+ lines << " params = compact_params(#{param_names.join(', ')})"
709
+ lines << " paginate(#{path_expr}, params: params)"
710
+ else
711
+ lines << " paginate(#{path_expr})"
712
+ end
713
+
714
+ lines
715
+ end
716
+
717
+ def generate_get_method_body(op, path_expr)
718
+ lines = []
719
+ http_method = op[:http_method].downcase
720
+
721
+ if op[:has_binary_body]
722
+ # Binary upload - use raw body and set Content-Type header
723
+ # post_raw accepts (path, body:, content_type:) - no params keyword
724
+ # Query params must be embedded in the URL
725
+ if op[:query_params].any?
726
+ # Build URL with query string
727
+ query_parts = op[:query_params].map { |q| "#{q[:name]}=\#{URI.encode_www_form_component(#{to_snake_case(q[:name])}.to_s)}" }
728
+ query_string = query_parts.join('&')
729
+ # Modify path_expr to include query string
730
+ path_expr_with_query = path_expr.sub(/"$/, "?#{query_string}\"")
731
+ lines << " http_#{http_method}_raw(#{path_expr_with_query}, body: data, content_type: content_type).json"
732
+ else
733
+ lines << " http_#{http_method}_raw(#{path_expr}, body: data, content_type: content_type).json"
734
+ end
735
+ elsif op[:has_body]
736
+ body_expr = build_body_expression(op)
737
+ lines << " http_#{http_method}(#{path_expr}, body: #{body_expr}).json"
738
+ elsif op[:query_params].any?
739
+ param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
740
+ lines << " http_#{http_method}(#{path_expr}, params: compact_params(#{param_names.join(', ')})).json"
741
+ else
742
+ lines << " http_#{http_method}(#{path_expr}).json"
743
+ end
744
+
745
+ lines
746
+ end
747
+ end
748
+
749
+ # Main execution
750
+ if __FILE__ == $PROGRAM_NAME
751
+ openapi_path = nil
752
+ output_dir = nil
753
+
754
+ i = 0
755
+ while i < ARGV.length
756
+ case ARGV[i]
757
+ when '--openapi'
758
+ openapi_path = ARGV[i + 1]
759
+ i += 2
760
+ when '--output'
761
+ output_dir = ARGV[i + 1]
762
+ i += 2
763
+ else
764
+ i += 1
765
+ end
766
+ end
767
+
768
+ openapi_path ||= File.expand_path('../../openapi.json', __dir__)
769
+ output_dir ||= File.expand_path('../lib/basecamp/generated/services', __dir__)
770
+
771
+ unless File.exist?(openapi_path)
772
+ warn "Error: OpenAPI file not found: #{openapi_path}"
773
+ exit 1
774
+ end
775
+
776
+ generator = ServiceGenerator.new(openapi_path)
777
+ generator.generate(output_dir)
778
+ end