basecamp-sdk 0.12.0 → 0.14.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +128 -8
  3. data/lib/basecamp/client.rb +35 -11
  4. data/lib/basecamp/config.rb +69 -0
  5. data/lib/basecamp/error.rb +1 -0
  6. data/lib/basecamp/error_code.rb +1 -0
  7. data/lib/basecamp/exit_code.rb +1 -0
  8. data/lib/basecamp/generated/metadata.json +291 -141
  9. data/lib/basecamp/generated/services/base_service.rb +37 -16
  10. data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
  11. data/lib/basecamp/generated/services/boosts_service.rb +12 -6
  12. data/lib/basecamp/generated/services/campfires_service.rb +54 -41
  13. data/lib/basecamp/generated/services/cards_service.rb +6 -3
  14. data/lib/basecamp/generated/services/checkins_service.rb +28 -15
  15. data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
  16. data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
  17. data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
  18. data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
  19. data/lib/basecamp/generated/services/comments_service.rb +6 -3
  20. data/lib/basecamp/generated/services/documents_service.rb +9 -6
  21. data/lib/basecamp/generated/services/drafts_service.rb +5 -4
  22. data/lib/basecamp/generated/services/events_service.rb +6 -3
  23. data/lib/basecamp/generated/services/everything_service.rb +70 -56
  24. data/lib/basecamp/generated/services/folders_service.rb +62 -0
  25. data/lib/basecamp/generated/services/forwards_service.rb +12 -17
  26. data/lib/basecamp/generated/services/gauges_service.rb +12 -7
  27. data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
  28. data/lib/basecamp/generated/services/message_types_service.rb +4 -3
  29. data/lib/basecamp/generated/services/messages_service.rb +6 -4
  30. data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
  31. data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
  32. data/lib/basecamp/generated/services/people_service.rb +17 -10
  33. data/lib/basecamp/generated/services/projects_service.rb +26 -4
  34. data/lib/basecamp/generated/services/recordings_service.rb +6 -13
  35. data/lib/basecamp/generated/services/reports_service.rb +15 -9
  36. data/lib/basecamp/generated/services/schedules_service.rb +90 -16
  37. data/lib/basecamp/generated/services/search_service.rb +6 -4
  38. data/lib/basecamp/generated/services/templates_service.rb +6 -4
  39. data/lib/basecamp/generated/services/timeline_service.rb +6 -3
  40. data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
  41. data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
  42. data/lib/basecamp/generated/services/todolists_service.rb +11 -9
  43. data/lib/basecamp/generated/services/todos_service.rb +6 -14
  44. data/lib/basecamp/generated/services/uploads_service.rb +28 -6
  45. data/lib/basecamp/generated/services/vaults_service.rb +6 -3
  46. data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
  47. data/lib/basecamp/generated/types.rb +603 -139
  48. data/lib/basecamp/http.rb +352 -163
  49. data/lib/basecamp/limit_exceeded_error.rb +22 -0
  50. data/lib/basecamp/list_enumerator.rb +29 -0
  51. data/lib/basecamp/list_meta.rb +44 -0
  52. data/lib/basecamp/services/authorization_service.rb +11 -2
  53. data/lib/basecamp/services/cards_extensions.rb +35 -27
  54. data/lib/basecamp/services/documents_extensions.rb +136 -0
  55. data/lib/basecamp/services/merge_safe.rb +255 -0
  56. data/lib/basecamp/services/schedules_extensions.rb +354 -0
  57. data/lib/basecamp/services/todolists_extensions.rb +274 -0
  58. data/lib/basecamp/services/todos_extensions.rb +22 -6
  59. data/lib/basecamp/validation_error.rb +11 -1
  60. data/lib/basecamp/version.rb +2 -2
  61. data/lib/basecamp.rb +98 -4
  62. data/scripts/generate-metadata.rb +3 -1
  63. data/scripts/generate-services.rb +78 -27
  64. data/scripts/generate-types.rb +4 -2
  65. data/scripts/go_type_spellings.rb +26 -0
  66. metadata +13 -2
@@ -3,13 +3,23 @@
3
3
  module Basecamp
4
4
  # Raised for validation errors (400, 422).
5
5
  class ValidationError < Error
6
- def initialize(message, hint: nil, http_status: 400)
6
+ # @return [Hash{String => Array<String>}, nil] field-keyed validation
7
+ # messages from a 400/422 body — either {"errors" => {"field" => ["msg"]}},
8
+ # the Rails RecordInvalid rendering, or the same map with no wrapper at all
9
+ # ({"field" => ["msg"]}), which some controllers emit. Nil for every other
10
+ # error shape.
11
+ # The flattened form is also folded into the message; this slot preserves
12
+ # the raw, untruncated per-field messages.
13
+ attr_reader :field_errors
14
+
15
+ def initialize(message, hint: nil, http_status: 400, field_errors: nil)
7
16
  super(
8
17
  code: ErrorCode::VALIDATION,
9
18
  message: message,
10
19
  hint: hint,
11
20
  http_status: http_status
12
21
  )
22
+ @field_errors = field_errors
13
23
  end
14
24
  end
15
25
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.12.0"
5
- API_VERSION = "2026-07-31"
4
+ VERSION = "0.14.0"
5
+ API_VERSION = "2026-08-05"
6
6
  end
data/lib/basecamp.rb CHANGED
@@ -11,10 +11,26 @@ loader.on_load("Basecamp::Services::TodosService") do |klass, _abspath|
11
11
  klass.prepend(Basecamp::Services::TodosExtensions)
12
12
  end
13
13
  # Same shape for cards: the generated class owns the constant and the
14
- # merge-safe update is prepended over the generated update_verbatim.
14
+ # tri-state `due_on` update is prepended over the generated update_verbatim.
15
15
  loader.on_load("Basecamp::Services::CardsService") do |klass, _abspath|
16
16
  klass.prepend(Basecamp::Services::CardsExtensions)
17
17
  end
18
+ # And for todolists: PUT /todolists/{id} is a full replace, so the generated
19
+ # class owns `replace` and the merge-safe update/edit surface is prepended.
20
+ loader.on_load("Basecamp::Services::TodolistsService") do |klass, _abspath|
21
+ klass.prepend(Basecamp::Services::TodolistsExtensions)
22
+ end
23
+ # And for documents: PUT /documents/{id} is a full replace, so the generated
24
+ # class owns `replace` and the merge-safe update/edit surface is prepended.
25
+ loader.on_load("Basecamp::Services::DocumentsService") do |klass, _abspath|
26
+ klass.prepend(Basecamp::Services::DocumentsExtensions)
27
+ end
28
+ # And for schedule entries: PUT /schedule_entries/{id} is a full replace, so
29
+ # the generated class owns `replace_entry` and the merge-safe
30
+ # `update_entry`/`edit_entry` surface is prepended.
31
+ loader.on_load("Basecamp::Services::SchedulesService") do |klass, _abspath|
32
+ klass.prepend(Basecamp::Services::SchedulesExtensions)
33
+ end
18
34
  loader.setup
19
35
 
20
36
  # Load generated types if available
@@ -105,7 +121,9 @@ module Basecamp
105
121
 
106
122
  case status
107
123
  when 400, 422
108
- ValidationError.new(message, http_status: status)
124
+ field_errors = parse_field_errors(body)
125
+ message = Security.truncate(compose_validation_message(parse_error_message(body), field_errors) || "Request failed")
126
+ ValidationError.new(message, http_status: status, field_errors: field_errors)
109
127
  when 401
110
128
  AuthError.new(message)
111
129
  when 403
@@ -114,6 +132,10 @@ module Basecamp
114
132
  NotFoundError.new(message: message)
115
133
  when 429
116
134
  RateLimitError.new(retry_after: retry_after)
135
+ when 507
136
+ # Decided before the 5xx arms: a 507 is an account limit, not a
137
+ # transient server failure, and no retry can satisfy it.
138
+ LimitExceededError.new(Security.truncate(message))
117
139
  when 500
118
140
  ApiError.new("Server error (500)", http_status: 500, retryable: true)
119
141
  when 502, 503, 504
@@ -141,7 +163,9 @@ module Basecamp
141
163
  "download"
142
164
  end
143
165
 
144
- # Parses error message from response body.
166
+ # Parses error message from response body. A key is used only when its
167
+ # value is a String (SPEC section 6), so a malformed scalar member such as
168
+ # {"error": {}} cannot raise or leak a non-string into the message.
145
169
  # @param body [String, nil]
146
170
  # @return [String, nil]
147
171
  def self.parse_error_message(body)
@@ -150,9 +174,79 @@ module Basecamp
150
174
  Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
151
175
 
152
176
  data = JSON.parse(body)
153
- msg = data["error"] || data["message"]
177
+ msg = data.is_a?(Hash) ? [ data["error"], data["message"] ].find { |value| value.is_a?(String) } : nil
154
178
  msg ? Security.truncate(msg) : nil
155
179
  rescue JSON::ParserError, ApiError
156
180
  nil
157
181
  end
182
+
183
+ # Extracts the field-keyed validation errors map from a response body — the
184
+ # Rails RecordInvalid rendering {"errors" => {"field" => ["msg", ...]}}.
185
+ # Entries whose value is not an array are skipped, non-string elements are
186
+ # dropped, and a map with no usable entries is treated as absent (nil).
187
+ # @param body [String, nil]
188
+ # @return [Hash{String => Array<String>}, nil]
189
+ def self.parse_field_errors(body)
190
+ return nil if body.nil? || body.empty?
191
+
192
+ Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
193
+
194
+ data = JSON.parse(body)
195
+ errors = data.is_a?(Hash) ? data["errors"] : nil
196
+ if errors.is_a?(Hash)
197
+ field_errors = errors.each_with_object({}) do |(field, values), result|
198
+ next unless values.is_a?(Array)
199
+
200
+ messages = values.grep(String)
201
+ result[field.to_s] = messages unless messages.empty?
202
+ end
203
+ field_errors.empty? ? nil : field_errors
204
+ else
205
+ parse_bare_field_errors(data)
206
+ end
207
+ rescue JSON::ParserError, ApiError
208
+ nil
209
+ end
210
+
211
+ # Extracts an unwrapped field map — the `render json: @webhook.errors`
212
+ # rendering, where the whole body is {"field" => ["msg", ...]}. The gate is
213
+ # all-or-nothing by design (SPEC section 6 step 2): with no "errors" key to
214
+ # declare intent, only shape distinguishes a field map from any other JSON
215
+ # object, so a single non-conforming member means this is not one.
216
+ # @param data [Object] the parsed body
217
+ # @return [Hash{String => Array<String>}, nil]
218
+ def self.parse_bare_field_errors(data)
219
+ return nil unless data.is_a?(Hash) && !data.empty?
220
+ # Only "errors" is structurally reserved (it belongs to the wrapped path).
221
+ # "error" and "message" are not excluded by name: a flat body carries them
222
+ # as strings, which the shape gate below already rejects.
223
+ return nil if data.key?("errors")
224
+
225
+ data.each_with_object({}) do |(field, values), result|
226
+ return nil unless values.is_a?(Array) && !values.empty?
227
+ return nil unless values.all? { |message| message.is_a?(String) && !message.empty? }
228
+
229
+ result[field.to_s] = values
230
+ end
231
+ end
232
+
233
+ # Merges the top-level error message with the flattened field-keyed errors:
234
+ # appended in parentheses when both are present, standing alone when only the
235
+ # field errors are. The flattened shape — fields sorted lexicographically, a
236
+ # field's messages joined with "; ", fields joined with ", " — is shared by
237
+ # all six SDKs; change it everywhere or nowhere. Callers truncate the
238
+ # composed result so the appended tail is capped too.
239
+ # @param message [String, nil]
240
+ # @param field_errors [Hash{String => Array<String>}, nil]
241
+ # @return [String, nil]
242
+ def self.compose_validation_message(message, field_errors)
243
+ if field_errors.nil?
244
+ message
245
+ else
246
+ flat = field_errors.keys.sort \
247
+ .map { |field| "#{field}: #{field_errors[field].join("; ")}" } \
248
+ .join(", ")
249
+ message ? "#{message} (#{flat})" : flat
250
+ end
251
+ end
158
252
  end
@@ -14,7 +14,9 @@ class MetadataExtractor
14
14
  METHODS = %w[get post put patch delete].freeze
15
15
 
16
16
  def initialize(openapi_path)
17
- @openapi = JSON.parse(File.read(openapi_path))
17
+ # Read as UTF-8 regardless of process locale (LC_ALL=C would otherwise read
18
+ # as US-ASCII and JSON.parse dies on the spec's multibyte characters)
19
+ @openapi = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))
18
20
  end
19
21
 
20
22
  def extract
@@ -65,13 +65,15 @@ class ServiceGenerator
65
65
  },
66
66
  'Files' => {
67
67
  'Attachments' => %w[CreateAttachment],
68
- 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions],
68
+ 'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions CreateUploadVersion],
69
69
  'Vaults' => %w[GetVault UpdateVault ListVaults CreateVault],
70
- 'Documents' => %w[GetDocument UpdateDocument ListDocuments CreateDocument]
70
+ 'Documents' => %w[GetDocument ReplaceDocument ListDocuments CreateDocument],
71
+ 'CloudFiles' => %w[GetCloudFile CreateCloudFile UpdateCloudFile],
72
+ 'GoogleDocuments' => %w[GetGoogleDocument CreateGoogleDocument UpdateGoogleDocument]
71
73
  },
72
74
  'Automation' => {
73
75
  'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool],
74
- 'Recordings' => %w[GetRecording ArchiveRecording UnarchiveRecording TrashRecording ListRecordings],
76
+ 'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings],
75
77
  'Webhooks' => %w[ListWebhooks CreateWebhook GetWebhook UpdateWebhook DeleteWebhook],
76
78
  'Events' => %w[ListEvents],
77
79
  'Lineup' => %w[CreateLineupMarker UpdateLineupMarker DeleteLineupMarker],
@@ -104,10 +106,10 @@ class ServiceGenerator
104
106
  'Schedule' => {
105
107
  'Schedules' => %w[
106
108
  GetSchedule UpdateScheduleSettings ListScheduleEntries
107
- CreateScheduleEntry GetScheduleEntry UpdateScheduleEntry
109
+ CreateScheduleEntry GetScheduleEntry ReplaceScheduleEntry
108
110
  GetScheduleEntryOccurrence
109
111
  ],
110
- 'Timesheets' => %w[GetRecordingTimesheet GetProjectTimesheet GetTimesheetReport GetTimesheetEntry CreateTimesheetEntry UpdateTimesheetEntry]
112
+ 'Timesheets' => %w[GetRecordingTimesheet GetProjectTimesheet GetTimesheetReport GetTimesheetEntry CreateTimesheetEntry UpdateTimesheetEntry DestroyTimesheetEntry]
111
113
  },
112
114
  'ClientFeatures' => {
113
115
  'ClientApprovals' => %w[ListClientApprovals GetClientApproval],
@@ -116,7 +118,7 @@ class ServiceGenerator
116
118
  'ClientVisibility' => %w[SetClientVisibility]
117
119
  },
118
120
  'Todos' => {
119
- 'Todos' => %w[ListTodos CreateTodo CreateTodosetTodo GetTodo ReplaceTodo CompleteTodo UncompleteTodo TrashTodo],
121
+ 'Todos' => %w[ListTodos CreateTodo CreateTodosetTodo GetTodo ReplaceTodo CompleteTodo UncompleteTodo],
120
122
  'Todolists' => %w[GetTodolistOrGroup UpdateTodolistOrGroup ListTodolists CreateTodolist RepositionTodolist],
121
123
  'Todosets' => %w[GetTodoset],
122
124
  'HillCharts' => %w[GetHillChart UpdateHillChartSettings],
@@ -139,15 +141,19 @@ class ServiceGenerator
139
141
  METHOD_NAME_OVERRIDES = {
140
142
  'GetMyProfile' => 'my_profile',
141
143
  'GetTodolistOrGroup' => 'get',
142
- 'UpdateTodolistOrGroup' => 'update',
144
+ # The plain `update` name belongs to the merge-safe composite; the raw
145
+ # single-PUT path keeps a name that says what it does. BC3 rebuilds the
146
+ # todolist from the permitted params, so omission clears. See #374.
147
+ 'UpdateTodolistOrGroup' => 'replace',
143
148
  'SetCardColumnColor' => 'set_color',
144
149
  'EnableCardColumnOnHold' => 'enable_on_hold',
145
150
  'DisableCardColumnOnHold' => 'disable_on_hold',
146
151
  'RepositionCardStep' => 'reposition',
147
152
  'CreateCardStep' => 'create',
148
153
  'UpdateCardStep' => 'update',
149
- # The plain `update` name belongs to the merge-safe composite; the raw
150
- # single-PUT path keeps a name that says what it does. See #467.
154
+ # The plain `update` name belongs to the tri-state `due_on` wrapper in
155
+ # CardsExtensions; the unnormalised path keeps a name that says what it
156
+ # does. See #467.
151
157
  'UpdateCard' => 'update_verbatim',
152
158
  'SetCardStepCompletion' => 'set_completion',
153
159
  'GetQuestionnaire' => 'get_questionnaire',
@@ -175,6 +181,7 @@ class ServiceGenerator
175
181
  'GetTimesheetEntry' => 'get',
176
182
  'CreateTimesheetEntry' => 'create',
177
183
  'UpdateTimesheetEntry' => 'update',
184
+ 'DestroyTimesheetEntry' => 'destroy',
178
185
  'GetProgressReport' => 'progress',
179
186
  'GetUpcomingSchedule' => 'upcoming',
180
187
  'GetAssignedTodos' => 'assigned',
@@ -203,7 +210,6 @@ class ServiceGenerator
203
210
  'ListForwards' => 'list',
204
211
  'GetForwardReply' => 'get_reply',
205
212
  'ListForwardReplies' => 'list_replies',
206
- 'CreateForwardReply' => 'create_reply',
207
213
  'GetInbox' => 'get_inbox',
208
214
  # Uploads - use specific names to avoid conflicts with versions
209
215
  'GetUpload' => 'get',
@@ -211,6 +217,7 @@ class ServiceGenerator
211
217
  'ListUploads' => 'list',
212
218
  'CreateUpload' => 'create',
213
219
  'ListUploadVersions' => 'list_versions',
220
+ 'CreateUploadVersion' => 'create_version',
214
221
  'GetMessage' => 'get',
215
222
  'UpdateMessage' => 'update',
216
223
  'CreateMessage' => 'create',
@@ -233,7 +240,11 @@ class ServiceGenerator
233
240
  'GetSchedule' => 'get',
234
241
  'UpdateScheduleSettings' => 'update_settings',
235
242
  'GetScheduleEntry' => 'get_entry',
236
- 'UpdateScheduleEntry' => 'update_entry',
243
+ # The plain `update_entry` name belongs to the merge-safe composite; the raw
244
+ # single-PUT path keeps a name that says what it does. Without the override
245
+ # the algorithm yields a bare `replace` (scheduleentry is a SIMPLE_RESOURCE),
246
+ # which reads as "replace the schedule". See #547.
247
+ 'ReplaceScheduleEntry' => 'replace_entry',
237
248
  'CreateScheduleEntry' => 'create_entry',
238
249
  'ListScheduleEntries' => 'list_entries',
239
250
  'GetScheduleEntryOccurrence' => 'get_entry_occurrence',
@@ -310,7 +321,8 @@ class ServiceGenerator
310
321
  ].freeze
311
322
 
312
323
  def initialize(openapi_path)
313
- @openapi = JSON.parse(File.read(openapi_path))
324
+ # UTF-8 regardless of process locale — see generate-metadata.rb
325
+ @openapi = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))
314
326
  @schemas = @openapi.dig('components', 'schemas') || {}
315
327
  end
316
328
 
@@ -571,6 +583,34 @@ class ServiceGenerator
571
583
  .downcase
572
584
  end
573
585
 
586
+ # Folds a multi-line description into the body of a YARD `@param` tag.
587
+ #
588
+ # The first line is returned bare — the caller has already written
589
+ # `# @param name [Type] ` in front of it. Every later line is indented under
590
+ # it, EXCEPT a blank one: a paragraph break must emit a bare `#`, not `#`
591
+ # followed by the continuation padding. Padding an empty line is trailing
592
+ # whitespace, which `git diff --check` fails on, and it stayed fixed only
593
+ # because this lives in the generator — patching the emitted file put it back
594
+ # on the next `make generate`.
595
+ #
596
+ # Any trailing whitespace already in the description is stripped for the same
597
+ # reason: a Smithy doc comment can carry it, and the generator should not
598
+ # launder it into a generated file.
599
+ YARD_CONTINUATION_INDENT = ' # '
600
+
601
+ def yard_param_description(text)
602
+ text.to_s.split("\n", -1).each_with_index.map do |line, index|
603
+ stripped = line.rstrip
604
+ if index.zero?
605
+ stripped
606
+ elsif stripped.empty?
607
+ ' #'
608
+ else
609
+ "#{YARD_CONTINUATION_INDENT}#{stripped}"
610
+ end
611
+ end.join("\n")
612
+ end
613
+
574
614
  def generate_service(service)
575
615
  lines = []
576
616
 
@@ -611,8 +651,11 @@ class ServiceGenerator
611
651
  def generate_method(op, service_name:)
612
652
  lines = []
613
653
 
614
- # Method signature
615
- params = build_params(op)
654
+ is_paginated = (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
655
+ is_wrapped_paginated = op[:has_pagination] && op[:pagination_key]
656
+
657
+ # Method signature (paginated operations gain a trailing max_items: kwarg)
658
+ params = build_params(op, paginated: is_paginated || is_wrapped_paginated)
616
659
 
617
660
  # YARD documentation
618
661
  lines << " # #{op[:description]}"
@@ -644,7 +687,7 @@ class ServiceGenerator
644
687
  ruby_name = to_snake_case(b[:name])
645
688
  type = b[:type] || 'Object'
646
689
  type = "#{type}, nil" unless b[:required]
647
- desc = (b[:description] || ruby_name.gsub('_', ' ')).gsub("\n", "\n # ")
690
+ desc = yard_param_description(b[:description] || ruby_name.gsub('_', ' '))
648
691
  format_hint = b[:format_hint] ? " (#{b[:format_hint]})" : ''
649
692
  lines << " # @param #{ruby_name} [#{type}] #{desc}#{format_hint}"
650
693
  end
@@ -655,20 +698,22 @@ class ServiceGenerator
655
698
  ruby_name = to_snake_case(q[:name])
656
699
  type = q[:type] || 'String'
657
700
  type = "#{type}, nil" unless q[:required]
658
- desc = (q[:description] || ruby_name.gsub('_', ' ')).gsub("\n", "\n # ")
701
+ desc = yard_param_description(q[:description] || ruby_name.gsub('_', ' '))
659
702
  lines << " # @param #{ruby_name} [#{type}] #{desc}"
660
703
  end
661
704
 
662
- # Add @return tag
663
- is_paginated = (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
664
- is_wrapped_paginated = op[:has_pagination] && op[:pagination_key]
705
+ # Add @param tag for the pagination cap on paginated operations
706
+ if is_paginated || is_wrapped_paginated
707
+ lines << ' # @param max_items [Integer, nil] cap on items yielded across pages; nil or non-positive means no cap'
708
+ end
665
709
 
710
+ # Add @return tag
666
711
  if op[:returns_void]
667
712
  lines << ' # @return [void]'
668
713
  elsif is_wrapped_paginated
669
- lines << ' # @return [Hash] response data'
714
+ lines << ' # @return [Hash] wrapper fields merged with a ListEnumerator of the paginated items'
670
715
  elsif is_paginated
671
- lines << ' # @return [Enumerator<Hash>] paginated results'
716
+ lines << ' # @return [ListEnumerator<Hash>] lazily paginated results (#meta carries pagination metadata)'
672
717
  elsif op[:returns_bare_array]
673
718
  # Unpaginated bare array (single request, no Link-following) — e.g. the
674
719
  # overdue todo/card feeds. Returns the parsed JSON array, not a Hash.
@@ -691,7 +736,8 @@ class ServiceGenerator
691
736
  body_lines.each { |l| lines << " #{l}" }
692
737
  lines << ' end'
693
738
  elsif is_paginated
694
- # wrap_paginated defers hooks to actual iteration time (lazy-safe)
739
+ # wrap_paginated fires the start hook eagerly (page 1 is fetched inside
740
+ # the block) and the end hook when iteration completes
695
741
  lines << " wrap_paginated(#{hook_kwargs}) do"
696
742
  body_lines = generate_list_method_body(op, path_expr)
697
743
  body_lines.each { |l| lines << " #{l}" }
@@ -729,7 +775,7 @@ class ServiceGenerator
729
775
  kwargs.join(', ')
730
776
  end
731
777
 
732
- def build_params(op)
778
+ def build_params(op, paginated: false)
733
779
  params = []
734
780
 
735
781
  # Path parameters as keyword args
@@ -772,6 +818,9 @@ class ServiceGenerator
772
818
  params << "#{to_snake_case(q[:name])}: nil"
773
819
  end
774
820
 
821
+ # Client-side pagination cap, threaded into the base paginators
822
+ params << 'max_items: nil' if paginated
823
+
775
824
  params.join(', ')
776
825
  end
777
826
 
@@ -818,9 +867,9 @@ class ServiceGenerator
818
867
  if op[:query_params].any?
819
868
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
820
869
  lines << " params = compact_query_params(#{param_names.join(', ')})"
821
- lines << " paginate(#{path_expr}, params: params, operation: \"#{op[:operation_id]}\")"
870
+ lines << " paginate(#{path_expr}, params: params, operation: \"#{op[:operation_id]}\", max_items: max_items)"
822
871
  else
823
- lines << " paginate(#{path_expr}, operation: \"#{op[:operation_id]}\")"
872
+ lines << " paginate(#{path_expr}, operation: \"#{op[:operation_id]}\", max_items: max_items)"
824
873
  end
825
874
 
826
875
  lines
@@ -832,9 +881,11 @@ class ServiceGenerator
832
881
  if op[:query_params].any?
833
882
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
834
883
  lines << " params = compact_query_params(#{param_names.join(', ')})"
835
- lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params, operation: \"#{op[:operation_id]}\")"
884
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params, " \
885
+ "operation: \"#{op[:operation_id]}\", max_items: max_items)"
836
886
  else
837
- lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", operation: \"#{op[:operation_id]}\")"
887
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", " \
888
+ "operation: \"#{op[:operation_id]}\", max_items: max_items)"
838
889
  end
839
890
 
840
891
  lines
@@ -9,6 +9,7 @@
9
9
  require 'json'
10
10
  require 'set'
11
11
  require 'time'
12
+ require_relative 'go_type_spellings'
12
13
 
13
14
  # Schemas to skip (internal/generated response wrappers)
14
15
  SKIP_PATTERNS = [
@@ -130,7 +131,8 @@ if __FILE__ == $PROGRAM_NAME
130
131
  puts ' module Types'
131
132
  puts ' include TypeHelpers'
132
133
 
133
- schemas = JSON.parse(File.read(openapi_path))['components']['schemas'] || {}
134
+ # UTF-8 regardless of process locale — see generate-metadata.rb
135
+ schemas = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))['components']['schemas'] || {}
134
136
  sorted = schemas.keys.sort
135
137
 
136
138
  sorted.each do |name|
@@ -207,7 +209,7 @@ if __FILE__ == $PROGRAM_NAME
207
209
  "parse_float(data[\"#{prop_name}\"])"
208
210
  elsif prop_schema['type'] == 'boolean'
209
211
  "parse_boolean(data[\"#{prop_name}\"])"
210
- elsif prop_schema['x-go-type'] == 'time.Time'
212
+ elsif GoTypeSpellings.timestamp_go_type?(prop_schema['x-go-type'])
211
213
  "parse_datetime(data[\"#{prop_name}\"])"
212
214
  else
213
215
  "data[\"#{prop_name}\"]"
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Go type spellings the Ruby generator needs to reason about, extracted so the
4
+ # generator and its tests can share one definition — the test used to `load`
5
+ # the whole generator script, which defined every one of its helpers on the
6
+ # test process.
7
+ module GoTypeSpellings
8
+ module_function
9
+
10
+ # Spellings that mean "full timestamp" and therefore get Time coercion in
11
+ # Ruby. Matched after stripping a leading `*`: the Go optional-pointer policy
12
+ # (SPEC.md §10) means a schema may carry either `time.Time` or `*time.Time`
13
+ # for the same wire contract, and an exact-string match silently degrades the
14
+ # pointer spelling to a raw String (#537).
15
+ #
16
+ # types.FlexibleTime is deliberately NOT here: it also accepts date-only
17
+ # values, and Ruby has passed those through as strings since it was
18
+ # introduced. Adding it is a behavior change, not a spelling fix.
19
+ TIMESTAMP_GO_TYPES = [ 'time.Time' ].freeze
20
+
21
+ def timestamp_go_type?(go_type)
22
+ return false unless go_type.is_a?(String)
23
+
24
+ TIMESTAMP_GO_TYPES.include?(go_type.delete_prefix('*'))
25
+ end
26
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecamp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Basecamp
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-01 00:00:00.000000000 Z
11
+ date: 2026-08-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -195,13 +195,16 @@ files:
195
195
  - lib/basecamp/generated/services/client_correspondences_service.rb
196
196
  - lib/basecamp/generated/services/client_replies_service.rb
197
197
  - lib/basecamp/generated/services/client_visibility_service.rb
198
+ - lib/basecamp/generated/services/cloud_files_service.rb
198
199
  - lib/basecamp/generated/services/comments_service.rb
199
200
  - lib/basecamp/generated/services/documents_service.rb
200
201
  - lib/basecamp/generated/services/drafts_service.rb
201
202
  - lib/basecamp/generated/services/events_service.rb
202
203
  - lib/basecamp/generated/services/everything_service.rb
204
+ - lib/basecamp/generated/services/folders_service.rb
203
205
  - lib/basecamp/generated/services/forwards_service.rb
204
206
  - lib/basecamp/generated/services/gauges_service.rb
207
+ - lib/basecamp/generated/services/google_documents_service.rb
205
208
  - lib/basecamp/generated/services/hill_charts_service.rb
206
209
  - lib/basecamp/generated/services/lineup_service.rb
207
210
  - lib/basecamp/generated/services/message_boards_service.rb
@@ -232,6 +235,9 @@ files:
232
235
  - lib/basecamp/generated/types.rb
233
236
  - lib/basecamp/hooks.rb
234
237
  - lib/basecamp/http.rb
238
+ - lib/basecamp/limit_exceeded_error.rb
239
+ - lib/basecamp/list_enumerator.rb
240
+ - lib/basecamp/list_meta.rb
235
241
  - lib/basecamp/logger_hooks.rb
236
242
  - lib/basecamp/network_error.rb
237
243
  - lib/basecamp/noop_hooks.rb
@@ -262,6 +268,10 @@ files:
262
268
  - lib/basecamp/security.rb
263
269
  - lib/basecamp/services/authorization_service.rb
264
270
  - lib/basecamp/services/cards_extensions.rb
271
+ - lib/basecamp/services/documents_extensions.rb
272
+ - lib/basecamp/services/merge_safe.rb
273
+ - lib/basecamp/services/schedules_extensions.rb
274
+ - lib/basecamp/services/todolists_extensions.rb
265
275
  - lib/basecamp/services/todos_extensions.rb
266
276
  - lib/basecamp/static_token_provider.rb
267
277
  - lib/basecamp/token_provider.rb
@@ -276,6 +286,7 @@ files:
276
286
  - scripts/generate-metadata.rb
277
287
  - scripts/generate-services.rb
278
288
  - scripts/generate-types.rb
289
+ - scripts/go_type_spellings.rb
279
290
  homepage: https://github.com/basecamp/basecamp-sdk
280
291
  licenses:
281
292
  - MIT