basecamp-sdk 0.8.0 → 0.10.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/lib/basecamp/client.rb +10 -0
  4. data/lib/basecamp/generated/metadata.json +343 -12
  5. data/lib/basecamp/generated/services/automation_service.rb +1 -1
  6. data/lib/basecamp/generated/services/base_service.rb +13 -0
  7. data/lib/basecamp/generated/services/campfires_service.rb +2 -2
  8. data/lib/basecamp/generated/services/card_columns_service.rb +3 -3
  9. data/lib/basecamp/generated/services/cards_service.rb +2 -2
  10. data/lib/basecamp/generated/services/checkins_service.rb +5 -4
  11. data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
  12. data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
  13. data/lib/basecamp/generated/services/documents_service.rb +3 -2
  14. data/lib/basecamp/generated/services/everything_service.rb +179 -0
  15. data/lib/basecamp/generated/services/forwards_service.rb +1 -1
  16. data/lib/basecamp/generated/services/gauges_service.rb +4 -4
  17. data/lib/basecamp/generated/services/message_types_service.rb +20 -15
  18. data/lib/basecamp/generated/services/messages_service.rb +4 -3
  19. data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
  20. data/lib/basecamp/generated/services/my_notifications_service.rb +16 -2
  21. data/lib/basecamp/generated/services/people_service.rb +3 -3
  22. data/lib/basecamp/generated/services/projects_service.rb +2 -2
  23. data/lib/basecamp/generated/services/recordings_service.rb +2 -2
  24. data/lib/basecamp/generated/services/reports_service.rb +2 -2
  25. data/lib/basecamp/generated/services/schedules_service.rb +14 -5
  26. data/lib/basecamp/generated/services/search_service.rb +14 -3
  27. data/lib/basecamp/generated/services/templates_service.rb +4 -5
  28. data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
  29. data/lib/basecamp/generated/services/todolists_service.rb +15 -3
  30. data/lib/basecamp/generated/services/todos_service.rb +1 -1
  31. data/lib/basecamp/generated/services/tools_service.rb +4 -3
  32. data/lib/basecamp/generated/services/uploads_service.rb +3 -2
  33. data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
  34. data/lib/basecamp/generated/services/wormholes_service.rb +44 -0
  35. data/lib/basecamp/generated/types.rb +656 -71
  36. data/lib/basecamp/http.rb +2 -1
  37. data/lib/basecamp/services/cards_extensions.rb +67 -0
  38. data/lib/basecamp/version.rb +2 -2
  39. data/lib/basecamp.rb +5 -0
  40. data/scripts/generate-services.rb +39 -10
  41. data/scripts/generate-types.rb +60 -1
  42. metadata +5 -2
data/lib/basecamp/http.rb CHANGED
@@ -377,7 +377,8 @@ module Basecamp
377
377
  end
378
378
  end
379
379
 
380
- raise last_error || Basecamp::ApiError.new("Request failed after #{@config.max_retries} retries")
380
+ noun = @config.max_retries == 1 ? "attempt" : "attempts"
381
+ raise last_error || Basecamp::ApiError.new("Request failed after #{@config.max_retries} #{noun}")
381
382
  end
382
383
 
383
384
  def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Merge-safe +update+ for cards, prepended onto the generated
6
+ # {CardsService} (see the +on_load+ hook in +basecamp.rb+).
7
+ #
8
+ # BC3 builds the card's update params as
9
+ # <tt>{ due_on: nil }.merge(card_params)</tt>
10
+ # (+kanban/cards_controller.rb+), so *any* update whose body omits +due_on+
11
+ # erases the card's due date. A sparse PUT — the natural thing to write —
12
+ # is therefore destructive on the raw endpoint, which remains available as
13
+ # {#update_verbatim}.
14
+ #
15
+ # +update+ composes the public +get+ and +update_verbatim+ methods, so
16
+ # hooks observe the two wire operations, not a synthetic composite.
17
+ #
18
+ # Not atomic: a concurrent due-date change landing between the GET and the
19
+ # PUT is overwritten with the value this call read. The window is one
20
+ # round-trip.
21
+ module CardsExtensions
22
+ # Updates a card without disturbing fields the caller did not mention.
23
+ #
24
+ # +due_on+ is tri-state, which is what makes this safe:
25
+ #
26
+ # * +nil+ (omitted) — the current due date is fetched and resent
27
+ # * <tt>""</tt> — the due date is cleared
28
+ # * a date — the due date is set
29
+ #
30
+ # The extra GET is only paid for in the +nil+ case, the one where the
31
+ # API would otherwise destroy something.
32
+ #
33
+ # Assignees are never resent on the caller's behalf: BC3 filters incoming
34
+ # IDs through +reachable_people+, so echoing back an id belonging to
35
+ # someone who has since lost board access would silently unassign them.
36
+ #
37
+ # @param card_id [Integer] card id
38
+ # @param title [String, nil] new title (nil = keep current)
39
+ # @param content [String, nil] new content (nil = keep current, "" clears)
40
+ # @param due_on [String, nil] new due date (nil = keep current, "" clears)
41
+ # @param assignee_ids [Array, nil] new assignees (nil = keep current, [] clears)
42
+ # @return [Hash] the updated card
43
+ def update(card_id:, title: nil, content: nil, due_on: nil, assignee_ids: nil)
44
+ resolved_due_on =
45
+ if due_on.nil?
46
+ get(card_id: card_id)["due_on"]
47
+ elsif due_on.to_s.empty?
48
+ # Clearing is encoded by OMITTING due_on — compact_params strips the
49
+ # nil below, and BC3 nils an omitted due date. Sending an explicit
50
+ # null would violate body compaction (SPEC §18), and sending ""
51
+ # risks a date-format error.
52
+ nil
53
+ else
54
+ due_on
55
+ end
56
+
57
+ update_verbatim(
58
+ card_id: card_id,
59
+ title: title,
60
+ content: content,
61
+ due_on: resolved_due_on,
62
+ assignee_ids: assignee_ids
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.8.0"
5
- API_VERSION = "2026-07-22"
4
+ VERSION = "0.10.0"
5
+ API_VERSION = "2026-07-28"
6
6
  end
data/lib/basecamp.rb CHANGED
@@ -10,6 +10,11 @@ loader.collapse("#{__dir__}/basecamp/generated")
10
10
  loader.on_load("Basecamp::Services::TodosService") do |klass, _abspath|
11
11
  klass.prepend(Basecamp::Services::TodosExtensions)
12
12
  end
13
+ # Same shape for cards: the generated class owns the constant and the
14
+ # merge-safe update is prepended over the generated update_verbatim.
15
+ loader.on_load("Basecamp::Services::CardsService") do |klass, _abspath|
16
+ klass.prepend(Basecamp::Services::CardsExtensions)
17
+ end
13
18
  loader.setup
14
19
 
15
20
  # Load generated types if available
@@ -60,7 +60,8 @@ class ServiceGenerator
60
60
  'CardSteps' => %w[
61
61
  GetCardStep CreateCardStep UpdateCardStep SetCardStepCompletion
62
62
  RepositionCardStep
63
- ]
63
+ ],
64
+ 'Wormholes' => %w[CreateWormhole UpdateWormhole DeleteWormhole]
64
65
  },
65
66
  'Files' => {
66
67
  'Attachments' => %w[CreateAttachment],
@@ -116,7 +117,7 @@ class ServiceGenerator
116
117
  },
117
118
  'Todos' => {
118
119
  'Todos' => %w[ListTodos CreateTodo GetTodo ReplaceTodo CompleteTodo UncompleteTodo TrashTodo],
119
- 'Todolists' => %w[GetTodolistOrGroup UpdateTodolistOrGroup ListTodolists CreateTodolist],
120
+ 'Todolists' => %w[GetTodolistOrGroup UpdateTodolistOrGroup ListTodolists CreateTodolist RepositionTodolist],
120
121
  'Todosets' => %w[GetTodoset],
121
122
  'HillCharts' => %w[GetHillChart UpdateHillChartSettings],
122
123
  'TodolistGroups' => %w[ListTodolistGroups CreateTodolistGroup RepositionTodolistGroup]
@@ -145,6 +146,9 @@ class ServiceGenerator
145
146
  'RepositionCardStep' => 'reposition',
146
147
  'CreateCardStep' => 'create',
147
148
  '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.
151
+ 'UpdateCard' => 'update_verbatim',
148
152
  'SetCardStepCompletion' => 'set_completion',
149
153
  'GetQuestionnaire' => 'get_questionnaire',
150
154
  'GetQuestion' => 'get_question',
@@ -302,6 +306,7 @@ class ServiceGenerator
302
306
  clientcorrespondences clientreply clientreplies forwardreply
303
307
  forwardreplies campfireline campfirelines todolistgroup todolistgroups
304
308
  todolistorgroup uploadversions hillchart hillcharts
309
+ wormhole wormholes
305
310
  ].freeze
306
311
 
307
312
  def initialize(openapi_path)
@@ -396,7 +401,12 @@ class ServiceGenerator
396
401
  .select { |p| p['in'] == 'query' }
397
402
  .map do |p|
398
403
  {
399
- name: p['name'],
404
+ # Strip a trailing `[]` from bracketed array wire names (e.g.
405
+ # `bucket_ids[]`): the kwarg and the params-hash key are both clean
406
+ # `bucket_ids`, and Faraday's NestedParamsEncoder re-adds the `[]` when
407
+ # serializing the array value (a raw `bucket_ids[]` key would double to
408
+ # `bucket_ids[][]=`).
409
+ name: p['name'].sub(/\[\]\z/, ''),
400
410
  type: schema_to_ruby_type(p['schema']),
401
411
  required: p['required'] || false,
402
412
  description: p['description']
@@ -417,6 +427,13 @@ class ServiceGenerator
417
427
  response_schema = success_response&.dig('content', 'application/json', 'schema')
418
428
  returns_void = response_schema.nil?
419
429
  returns_array = response_schema&.dig('type') == 'array'
430
+ # Bare-array responses are usually a $ref to a single-member output alias, so
431
+ # the inline `type` is absent. Resolve the ref to detect them — used only for
432
+ # the YARD @return doc (an unpaginated bare array returns an Array, not a
433
+ # Hash); the pagination/body logic deliberately keys off the raw `type` so a
434
+ # bare array without x-basecamp-pagination stays a single request.
435
+ resolved_response = response_schema && response_schema['$ref'] ? resolve_schema_ref(response_schema) : response_schema
436
+ returns_bare_array = resolved_response&.dig('type') == 'array'
420
437
 
421
438
  {
422
439
  operation_id: operation_id,
@@ -433,6 +450,7 @@ class ServiceGenerator
433
450
  multipart_field: multipart_field,
434
451
  returns_void: returns_void,
435
452
  returns_array: returns_array,
453
+ returns_bare_array: returns_bare_array,
436
454
  is_mutation: http_method != 'GET',
437
455
  has_pagination: !!operation['x-basecamp-pagination'],
438
456
  pagination_key: operation.dig('x-basecamp-pagination', 'key')
@@ -533,7 +551,13 @@ class ServiceGenerator
533
551
  def schema_to_ruby_type(schema)
534
552
  return 'Object' unless schema
535
553
 
536
- case schema['type']
554
+ # Object-valued members (e.g. a `project`/`gauge`/`schedule` envelope) are passed
555
+ # as a Hash. Resolve $refs and treat only object-typed schemas as Hash; a string
556
+ # ref such as FirstWeekDay must stay String.
557
+ resolved = schema['$ref'] ? resolve_schema_ref(schema) : schema
558
+ return 'Hash' if resolved && resolved['type'] == 'object'
559
+
560
+ case resolved&.fetch('type', nil)
537
561
  when 'integer' then 'Integer'
538
562
  when 'boolean' then 'Boolean'
539
563
  when 'array' then 'Array'
@@ -645,6 +669,10 @@ class ServiceGenerator
645
669
  lines << ' # @return [Hash] response data'
646
670
  elsif is_paginated
647
671
  lines << ' # @return [Enumerator<Hash>] paginated results'
672
+ elsif op[:returns_bare_array]
673
+ # Unpaginated bare array (single request, no Link-following) — e.g. the
674
+ # overdue todo/card feeds. Returns the parsed JSON array, not a Hash.
675
+ lines << ' # @return [Array<Hash>] response data'
648
676
  else
649
677
  lines << ' # @return [Hash] response data'
650
678
  end
@@ -691,10 +719,11 @@ class ServiceGenerator
691
719
  kwargs << "operation: \"#{op[:method_name]}\""
692
720
  kwargs << "is_mutation: #{op[:is_mutation]}"
693
721
 
694
- project_param = op[:path_params].find { |p| p[:name] == 'projectId' }
695
- resource_param = op[:path_params].reject { |p| p[:name] == 'projectId' }.last
722
+ project_param = op[:path_params].find { |p| %w[projectId bucketId].include?(p[:name]) }
723
+ resource_param = op[:path_params].reject { |p| %w[projectId bucketId].include?(p[:name]) }
724
+ .select { |p| p[:name].end_with?("Id") || p[:name] == "id" }.last
696
725
 
697
- kwargs << "project_id: project_id" if project_param
726
+ kwargs << "project_id: #{to_snake_case(project_param[:name])}" if project_param
698
727
  kwargs << "resource_id: #{to_snake_case(resource_param[:name])}" if resource_param
699
728
 
700
729
  kwargs.join(', ')
@@ -788,7 +817,7 @@ class ServiceGenerator
788
817
  # Build params hash for query params
789
818
  if op[:query_params].any?
790
819
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
791
- lines << " params = compact_params(#{param_names.join(', ')})"
820
+ lines << " params = compact_query_params(#{param_names.join(', ')})"
792
821
  lines << " paginate(#{path_expr}, params: params)"
793
822
  else
794
823
  lines << " paginate(#{path_expr})"
@@ -802,7 +831,7 @@ class ServiceGenerator
802
831
 
803
832
  if op[:query_params].any?
804
833
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
805
- lines << " params = compact_params(#{param_names.join(', ')})"
834
+ lines << " params = compact_query_params(#{param_names.join(', ')})"
806
835
  lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params)"
807
836
  else
808
837
  lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\")"
@@ -834,7 +863,7 @@ class ServiceGenerator
834
863
  lines << " http_#{http_method}(#{path_expr}, body: #{body_expr}).json"
835
864
  elsif op[:query_params].any?
836
865
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
837
- lines << " http_#{http_method}(#{path_expr}, params: compact_params(#{param_names.join(', ')})).json"
866
+ lines << " http_#{http_method}(#{path_expr}, params: compact_query_params(#{param_names.join(', ')})).json"
838
867
  else
839
868
  lines << " http_#{http_method}(#{path_expr}).json"
840
869
  end
@@ -87,6 +87,33 @@ def generate_helpers
87
87
  HELPERS
88
88
  end
89
89
 
90
+ # Renders a deprecation +reason+ as one or more Ruby comment lines (#406).
91
+ #
92
+ # A reason can be sourced from a multi-line OpenAPI description. Interpolating
93
+ # one into a single "# @deprecated ..." line would leave every continuation on a
94
+ # bare, un-commented source line, which is a syntax error in the generated
95
+ # types.rb — Ruby is the only owned generator exposed this way (Python escapes
96
+ # the reason, Kotlin/TypeScript emit block comments). Splitting on line
97
+ # boundaries and prefixing each line with the comment leader keeps the output
98
+ # valid for any reason.
99
+ #
100
+ # +indent+ is the leading source indentation; +tag_prefix+ is inserted between
101
+ # the "# " leader and the YARD tag (the per-attribute site nests its tag under
102
+ # an @!attribute directive). Continuation lines are indented two columns past
103
+ # the tag so YARD folds them into the same tag's text.
104
+ #
105
+ # Single-line reasons render byte-identically to the previous single-line
106
+ # interpolation, so this is output-neutral for the current spec.
107
+ def deprecation_doc_lines(reason, indent:, tag_prefix: '')
108
+ lines = reason.to_s.split(/\r?\n/, -1)
109
+ out = [ "#{indent}# #{tag_prefix}@deprecated #{lines.first}" ]
110
+ continuation = "#{indent}# #{tag_prefix} "
111
+ lines.drop(1).each do |line|
112
+ out << (line.empty? ? "#{indent}#" : "#{continuation}#{line}")
113
+ end
114
+ out
115
+ end
116
+
90
117
  # Main execution
91
118
  if __FILE__ == $PROGRAM_NAME
92
119
  openapi_path = ARGV[0] || File.expand_path('../../openapi.json', __dir__)
@@ -124,6 +151,12 @@ if __FILE__ == $PROGRAM_NAME
124
151
 
125
152
  puts ''
126
153
  puts " # #{name}"
154
+ # Documentation-only deprecation (see #406): YARD marks a whole class/method,
155
+ # not individual params, so a class-level @deprecated tag documents a wholly
156
+ # deprecated type.
157
+ if schema['deprecated']
158
+ puts deprecation_doc_lines(schema['x-deprecated-reason'] || 'deprecated', indent: ' ')
159
+ end
127
160
  puts " class #{name}"
128
161
  puts ' include TypeHelpers'
129
162
 
@@ -131,6 +164,19 @@ if __FILE__ == $PROGRAM_NAME
131
164
  # Add system_label for schemas with flexible integer fields
132
165
  has_flexible = ordered_props.any? { |k| properties[k]['x-go-type']&.include?('FlexibleInt64') }
133
166
  attr_names << 'system_label' if has_flexible
167
+
168
+ # Per-attribute deprecation. The accessors are declared in one grouped
169
+ # attr_accessor, so a bare comment would wrongly document every attribute;
170
+ # a YARD @!attribute directive scopes the @deprecated tag to just this one.
171
+ ordered_props.each do |k|
172
+ ps = properties[k]
173
+ next unless ps['deprecated']
174
+
175
+ ruby_name = k.gsub(/([A-Z])/, '_\1').downcase.gsub(/^_/, '')
176
+ puts " # @!attribute [rw] #{ruby_name}"
177
+ puts deprecation_doc_lines(ps['x-deprecated-reason'] || 'deprecated', indent: ' ', tag_prefix: ' ')
178
+ end
179
+
134
180
  puts " attr_accessor #{attr_names.map { |n| ":#{n}" }.join(", ")}"
135
181
 
136
182
  unless required_props.empty?
@@ -182,7 +228,20 @@ if __FILE__ == $PROGRAM_NAME
182
228
  attr_name = prop_name.gsub(/([A-Z])/, '_\1').downcase.gsub(/^_/, '')
183
229
  puts " \"#{prop_name}\" => @#{attr_name},"
184
230
  end
185
- puts ' }.compact'
231
+ # A required-and-nullable field (OpenAPI 3.1 `type: [..., "null"]`) carries an
232
+ # explicit null that must survive to_h; plain .compact would drop it. Keep
233
+ # nil for exactly those keys — required-but-non-nullable fields still get
234
+ # dropped when nil, like any other nil. Everything else keeps .compact.
235
+ required_nullable = required_fields.select do |k|
236
+ t = properties[k] && properties[k]['type']
237
+ t.is_a?(Array) && t.include?('null')
238
+ end
239
+ if required_nullable.any?
240
+ keep_list = required_nullable.map { |k| "\"#{k}\"" }.join(', ')
241
+ puts " }.reject { |k, v| v.nil? && ![#{keep_list}].include?(k) }"
242
+ else
243
+ puts ' }.compact'
244
+ end
186
245
  puts ' end'
187
246
  puts ''
188
247
 
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.8.0
4
+ version: 0.10.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-07-22 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -196,6 +196,7 @@ files:
196
196
  - lib/basecamp/generated/services/comments_service.rb
197
197
  - lib/basecamp/generated/services/documents_service.rb
198
198
  - lib/basecamp/generated/services/events_service.rb
199
+ - lib/basecamp/generated/services/everything_service.rb
199
200
  - lib/basecamp/generated/services/forwards_service.rb
200
201
  - lib/basecamp/generated/services/gauges_service.rb
201
202
  - lib/basecamp/generated/services/hill_charts_service.rb
@@ -223,6 +224,7 @@ files:
223
224
  - lib/basecamp/generated/services/uploads_service.rb
224
225
  - lib/basecamp/generated/services/vaults_service.rb
225
226
  - lib/basecamp/generated/services/webhooks_service.rb
227
+ - lib/basecamp/generated/services/wormholes_service.rb
226
228
  - lib/basecamp/generated/types.rb
227
229
  - lib/basecamp/hooks.rb
228
230
  - lib/basecamp/http.rb
@@ -252,6 +254,7 @@ files:
252
254
  - lib/basecamp/request_result.rb
253
255
  - lib/basecamp/security.rb
254
256
  - lib/basecamp/services/authorization_service.rb
257
+ - lib/basecamp/services/cards_extensions.rb
255
258
  - lib/basecamp/services/todos_extensions.rb
256
259
  - lib/basecamp/static_token_provider.rb
257
260
  - lib/basecamp/token_provider.rb