basecamp-sdk 0.12.0 → 0.13.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +127 -8
  3. data/lib/basecamp/client.rb +35 -11
  4. data/lib/basecamp/config.rb +69 -0
  5. data/lib/basecamp/generated/metadata.json +280 -141
  6. data/lib/basecamp/generated/services/base_service.rb +37 -16
  7. data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
  8. data/lib/basecamp/generated/services/boosts_service.rb +12 -6
  9. data/lib/basecamp/generated/services/campfires_service.rb +54 -41
  10. data/lib/basecamp/generated/services/cards_service.rb +6 -3
  11. data/lib/basecamp/generated/services/checkins_service.rb +28 -15
  12. data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
  13. data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
  14. data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
  15. data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
  16. data/lib/basecamp/generated/services/comments_service.rb +6 -3
  17. data/lib/basecamp/generated/services/documents_service.rb +9 -6
  18. data/lib/basecamp/generated/services/drafts_service.rb +5 -4
  19. data/lib/basecamp/generated/services/events_service.rb +6 -3
  20. data/lib/basecamp/generated/services/everything_service.rb +70 -56
  21. data/lib/basecamp/generated/services/folders_service.rb +62 -0
  22. data/lib/basecamp/generated/services/forwards_service.rb +12 -17
  23. data/lib/basecamp/generated/services/gauges_service.rb +12 -7
  24. data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
  25. data/lib/basecamp/generated/services/message_types_service.rb +4 -3
  26. data/lib/basecamp/generated/services/messages_service.rb +6 -4
  27. data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
  28. data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
  29. data/lib/basecamp/generated/services/people_service.rb +17 -10
  30. data/lib/basecamp/generated/services/projects_service.rb +26 -4
  31. data/lib/basecamp/generated/services/recordings_service.rb +6 -13
  32. data/lib/basecamp/generated/services/reports_service.rb +15 -9
  33. data/lib/basecamp/generated/services/schedules_service.rb +90 -16
  34. data/lib/basecamp/generated/services/search_service.rb +6 -4
  35. data/lib/basecamp/generated/services/templates_service.rb +6 -4
  36. data/lib/basecamp/generated/services/timeline_service.rb +6 -3
  37. data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
  38. data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
  39. data/lib/basecamp/generated/services/todolists_service.rb +11 -9
  40. data/lib/basecamp/generated/services/todos_service.rb +6 -14
  41. data/lib/basecamp/generated/services/uploads_service.rb +10 -6
  42. data/lib/basecamp/generated/services/vaults_service.rb +6 -3
  43. data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
  44. data/lib/basecamp/generated/types.rb +527 -139
  45. data/lib/basecamp/http.rb +346 -163
  46. data/lib/basecamp/list_enumerator.rb +29 -0
  47. data/lib/basecamp/list_meta.rb +44 -0
  48. data/lib/basecamp/services/cards_extensions.rb +35 -27
  49. data/lib/basecamp/services/documents_extensions.rb +136 -0
  50. data/lib/basecamp/services/merge_safe.rb +255 -0
  51. data/lib/basecamp/services/schedules_extensions.rb +354 -0
  52. data/lib/basecamp/services/todolists_extensions.rb +274 -0
  53. data/lib/basecamp/services/todos_extensions.rb +22 -6
  54. data/lib/basecamp/validation_error.rb +11 -1
  55. data/lib/basecamp/version.rb +2 -2
  56. data/lib/basecamp.rb +94 -4
  57. data/scripts/generate-services.rb +74 -25
  58. data/scripts/generate-types.rb +2 -1
  59. data/scripts/go_type_spellings.rb +26 -0
  60. metadata +12 -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.13.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
@@ -141,7 +159,9 @@ module Basecamp
141
159
  "download"
142
160
  end
143
161
 
144
- # Parses error message from response body.
162
+ # Parses error message from response body. A key is used only when its
163
+ # value is a String (SPEC section 6), so a malformed scalar member such as
164
+ # {"error": {}} cannot raise or leak a non-string into the message.
145
165
  # @param body [String, nil]
146
166
  # @return [String, nil]
147
167
  def self.parse_error_message(body)
@@ -150,9 +170,79 @@ module Basecamp
150
170
  Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
151
171
 
152
172
  data = JSON.parse(body)
153
- msg = data["error"] || data["message"]
173
+ msg = data.is_a?(Hash) ? [ data["error"], data["message"] ].find { |value| value.is_a?(String) } : nil
154
174
  msg ? Security.truncate(msg) : nil
155
175
  rescue JSON::ParserError, ApiError
156
176
  nil
157
177
  end
178
+
179
+ # Extracts the field-keyed validation errors map from a response body — the
180
+ # Rails RecordInvalid rendering {"errors" => {"field" => ["msg", ...]}}.
181
+ # Entries whose value is not an array are skipped, non-string elements are
182
+ # dropped, and a map with no usable entries is treated as absent (nil).
183
+ # @param body [String, nil]
184
+ # @return [Hash{String => Array<String>}, nil]
185
+ def self.parse_field_errors(body)
186
+ return nil if body.nil? || body.empty?
187
+
188
+ Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
189
+
190
+ data = JSON.parse(body)
191
+ errors = data.is_a?(Hash) ? data["errors"] : nil
192
+ if errors.is_a?(Hash)
193
+ field_errors = errors.each_with_object({}) do |(field, values), result|
194
+ next unless values.is_a?(Array)
195
+
196
+ messages = values.grep(String)
197
+ result[field.to_s] = messages unless messages.empty?
198
+ end
199
+ field_errors.empty? ? nil : field_errors
200
+ else
201
+ parse_bare_field_errors(data)
202
+ end
203
+ rescue JSON::ParserError, ApiError
204
+ nil
205
+ end
206
+
207
+ # Extracts an unwrapped field map — the `render json: @webhook.errors`
208
+ # rendering, where the whole body is {"field" => ["msg", ...]}. The gate is
209
+ # all-or-nothing by design (SPEC section 6 step 2): with no "errors" key to
210
+ # declare intent, only shape distinguishes a field map from any other JSON
211
+ # object, so a single non-conforming member means this is not one.
212
+ # @param data [Object] the parsed body
213
+ # @return [Hash{String => Array<String>}, nil]
214
+ def self.parse_bare_field_errors(data)
215
+ return nil unless data.is_a?(Hash) && !data.empty?
216
+ # Only "errors" is structurally reserved (it belongs to the wrapped path).
217
+ # "error" and "message" are not excluded by name: a flat body carries them
218
+ # as strings, which the shape gate below already rejects.
219
+ return nil if data.key?("errors")
220
+
221
+ data.each_with_object({}) do |(field, values), result|
222
+ return nil unless values.is_a?(Array) && !values.empty?
223
+ return nil unless values.all? { |message| message.is_a?(String) && !message.empty? }
224
+
225
+ result[field.to_s] = values
226
+ end
227
+ end
228
+
229
+ # Merges the top-level error message with the flattened field-keyed errors:
230
+ # appended in parentheses when both are present, standing alone when only the
231
+ # field errors are. The flattened shape — fields sorted lexicographically, a
232
+ # field's messages joined with "; ", fields joined with ", " — is shared by
233
+ # all six SDKs; change it everywhere or nowhere. Callers truncate the
234
+ # composed result so the appended tail is capped too.
235
+ # @param message [String, nil]
236
+ # @param field_errors [Hash{String => Array<String>}, nil]
237
+ # @return [String, nil]
238
+ def self.compose_validation_message(message, field_errors)
239
+ if field_errors.nil?
240
+ message
241
+ else
242
+ flat = field_errors.keys.sort \
243
+ .map { |field| "#{field}: #{field_errors[field].join("; ")}" } \
244
+ .join(", ")
245
+ message ? "#{message} (#{flat})" : flat
246
+ end
247
+ end
158
248
  end
@@ -67,11 +67,13 @@ class ServiceGenerator
67
67
  'Attachments' => %w[CreateAttachment],
68
68
  'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions],
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',
@@ -233,7 +239,11 @@ class ServiceGenerator
233
239
  'GetSchedule' => 'get',
234
240
  'UpdateScheduleSettings' => 'update_settings',
235
241
  'GetScheduleEntry' => 'get_entry',
236
- 'UpdateScheduleEntry' => 'update_entry',
242
+ # The plain `update_entry` name belongs to the merge-safe composite; the raw
243
+ # single-PUT path keeps a name that says what it does. Without the override
244
+ # the algorithm yields a bare `replace` (scheduleentry is a SIMPLE_RESOURCE),
245
+ # which reads as "replace the schedule". See #547.
246
+ 'ReplaceScheduleEntry' => 'replace_entry',
237
247
  'CreateScheduleEntry' => 'create_entry',
238
248
  'ListScheduleEntries' => 'list_entries',
239
249
  'GetScheduleEntryOccurrence' => 'get_entry_occurrence',
@@ -571,6 +581,34 @@ class ServiceGenerator
571
581
  .downcase
572
582
  end
573
583
 
584
+ # Folds a multi-line description into the body of a YARD `@param` tag.
585
+ #
586
+ # The first line is returned bare — the caller has already written
587
+ # `# @param name [Type] ` in front of it. Every later line is indented under
588
+ # it, EXCEPT a blank one: a paragraph break must emit a bare `#`, not `#`
589
+ # followed by the continuation padding. Padding an empty line is trailing
590
+ # whitespace, which `git diff --check` fails on, and it stayed fixed only
591
+ # because this lives in the generator — patching the emitted file put it back
592
+ # on the next `make generate`.
593
+ #
594
+ # Any trailing whitespace already in the description is stripped for the same
595
+ # reason: a Smithy doc comment can carry it, and the generator should not
596
+ # launder it into a generated file.
597
+ YARD_CONTINUATION_INDENT = ' # '
598
+
599
+ def yard_param_description(text)
600
+ text.to_s.split("\n", -1).each_with_index.map do |line, index|
601
+ stripped = line.rstrip
602
+ if index.zero?
603
+ stripped
604
+ elsif stripped.empty?
605
+ ' #'
606
+ else
607
+ "#{YARD_CONTINUATION_INDENT}#{stripped}"
608
+ end
609
+ end.join("\n")
610
+ end
611
+
574
612
  def generate_service(service)
575
613
  lines = []
576
614
 
@@ -611,8 +649,11 @@ class ServiceGenerator
611
649
  def generate_method(op, service_name:)
612
650
  lines = []
613
651
 
614
- # Method signature
615
- params = build_params(op)
652
+ is_paginated = (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
653
+ is_wrapped_paginated = op[:has_pagination] && op[:pagination_key]
654
+
655
+ # Method signature (paginated operations gain a trailing max_items: kwarg)
656
+ params = build_params(op, paginated: is_paginated || is_wrapped_paginated)
616
657
 
617
658
  # YARD documentation
618
659
  lines << " # #{op[:description]}"
@@ -644,7 +685,7 @@ class ServiceGenerator
644
685
  ruby_name = to_snake_case(b[:name])
645
686
  type = b[:type] || 'Object'
646
687
  type = "#{type}, nil" unless b[:required]
647
- desc = (b[:description] || ruby_name.gsub('_', ' ')).gsub("\n", "\n # ")
688
+ desc = yard_param_description(b[:description] || ruby_name.gsub('_', ' '))
648
689
  format_hint = b[:format_hint] ? " (#{b[:format_hint]})" : ''
649
690
  lines << " # @param #{ruby_name} [#{type}] #{desc}#{format_hint}"
650
691
  end
@@ -655,20 +696,22 @@ class ServiceGenerator
655
696
  ruby_name = to_snake_case(q[:name])
656
697
  type = q[:type] || 'String'
657
698
  type = "#{type}, nil" unless q[:required]
658
- desc = (q[:description] || ruby_name.gsub('_', ' ')).gsub("\n", "\n # ")
699
+ desc = yard_param_description(q[:description] || ruby_name.gsub('_', ' '))
659
700
  lines << " # @param #{ruby_name} [#{type}] #{desc}"
660
701
  end
661
702
 
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]
703
+ # Add @param tag for the pagination cap on paginated operations
704
+ if is_paginated || is_wrapped_paginated
705
+ lines << ' # @param max_items [Integer, nil] cap on items yielded across pages; nil or non-positive means no cap'
706
+ end
665
707
 
708
+ # Add @return tag
666
709
  if op[:returns_void]
667
710
  lines << ' # @return [void]'
668
711
  elsif is_wrapped_paginated
669
- lines << ' # @return [Hash] response data'
712
+ lines << ' # @return [Hash] wrapper fields merged with a ListEnumerator of the paginated items'
670
713
  elsif is_paginated
671
- lines << ' # @return [Enumerator<Hash>] paginated results'
714
+ lines << ' # @return [ListEnumerator<Hash>] lazily paginated results (#meta carries pagination metadata)'
672
715
  elsif op[:returns_bare_array]
673
716
  # Unpaginated bare array (single request, no Link-following) — e.g. the
674
717
  # overdue todo/card feeds. Returns the parsed JSON array, not a Hash.
@@ -691,7 +734,8 @@ class ServiceGenerator
691
734
  body_lines.each { |l| lines << " #{l}" }
692
735
  lines << ' end'
693
736
  elsif is_paginated
694
- # wrap_paginated defers hooks to actual iteration time (lazy-safe)
737
+ # wrap_paginated fires the start hook eagerly (page 1 is fetched inside
738
+ # the block) and the end hook when iteration completes
695
739
  lines << " wrap_paginated(#{hook_kwargs}) do"
696
740
  body_lines = generate_list_method_body(op, path_expr)
697
741
  body_lines.each { |l| lines << " #{l}" }
@@ -729,7 +773,7 @@ class ServiceGenerator
729
773
  kwargs.join(', ')
730
774
  end
731
775
 
732
- def build_params(op)
776
+ def build_params(op, paginated: false)
733
777
  params = []
734
778
 
735
779
  # Path parameters as keyword args
@@ -772,6 +816,9 @@ class ServiceGenerator
772
816
  params << "#{to_snake_case(q[:name])}: nil"
773
817
  end
774
818
 
819
+ # Client-side pagination cap, threaded into the base paginators
820
+ params << 'max_items: nil' if paginated
821
+
775
822
  params.join(', ')
776
823
  end
777
824
 
@@ -818,9 +865,9 @@ class ServiceGenerator
818
865
  if op[:query_params].any?
819
866
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
820
867
  lines << " params = compact_query_params(#{param_names.join(', ')})"
821
- lines << " paginate(#{path_expr}, params: params, operation: \"#{op[:operation_id]}\")"
868
+ lines << " paginate(#{path_expr}, params: params, operation: \"#{op[:operation_id]}\", max_items: max_items)"
822
869
  else
823
- lines << " paginate(#{path_expr}, operation: \"#{op[:operation_id]}\")"
870
+ lines << " paginate(#{path_expr}, operation: \"#{op[:operation_id]}\", max_items: max_items)"
824
871
  end
825
872
 
826
873
  lines
@@ -832,9 +879,11 @@ class ServiceGenerator
832
879
  if op[:query_params].any?
833
880
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
834
881
  lines << " params = compact_query_params(#{param_names.join(', ')})"
835
- lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params, operation: \"#{op[:operation_id]}\")"
882
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params, " \
883
+ "operation: \"#{op[:operation_id]}\", max_items: max_items)"
836
884
  else
837
- lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", operation: \"#{op[:operation_id]}\")"
885
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", " \
886
+ "operation: \"#{op[:operation_id]}\", max_items: max_items)"
838
887
  end
839
888
 
840
889
  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 = [
@@ -207,7 +208,7 @@ if __FILE__ == $PROGRAM_NAME
207
208
  "parse_float(data[\"#{prop_name}\"])"
208
209
  elsif prop_schema['type'] == 'boolean'
209
210
  "parse_boolean(data[\"#{prop_name}\"])"
210
- elsif prop_schema['x-go-type'] == 'time.Time'
211
+ elsif GoTypeSpellings.timestamp_go_type?(prop_schema['x-go-type'])
211
212
  "parse_datetime(data[\"#{prop_name}\"])"
212
213
  else
213
214
  "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.13.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-07 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,8 @@ files:
232
235
  - lib/basecamp/generated/types.rb
233
236
  - lib/basecamp/hooks.rb
234
237
  - lib/basecamp/http.rb
238
+ - lib/basecamp/list_enumerator.rb
239
+ - lib/basecamp/list_meta.rb
235
240
  - lib/basecamp/logger_hooks.rb
236
241
  - lib/basecamp/network_error.rb
237
242
  - lib/basecamp/noop_hooks.rb
@@ -262,6 +267,10 @@ files:
262
267
  - lib/basecamp/security.rb
263
268
  - lib/basecamp/services/authorization_service.rb
264
269
  - lib/basecamp/services/cards_extensions.rb
270
+ - lib/basecamp/services/documents_extensions.rb
271
+ - lib/basecamp/services/merge_safe.rb
272
+ - lib/basecamp/services/schedules_extensions.rb
273
+ - lib/basecamp/services/todolists_extensions.rb
265
274
  - lib/basecamp/services/todos_extensions.rb
266
275
  - lib/basecamp/static_token_provider.rb
267
276
  - lib/basecamp/token_provider.rb
@@ -276,6 +285,7 @@ files:
276
285
  - scripts/generate-metadata.rb
277
286
  - scripts/generate-services.rb
278
287
  - scripts/generate-types.rb
288
+ - scripts/go_type_spellings.rb
279
289
  homepage: https://github.com/basecamp/basecamp-sdk
280
290
  licenses:
281
291
  - MIT