basecamp-sdk 0.9.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.
@@ -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.9.0"
5
- API_VERSION = "2026-07-24"
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
@@ -146,6 +146,9 @@ class ServiceGenerator
146
146
  'RepositionCardStep' => 'reposition',
147
147
  'CreateCardStep' => 'create',
148
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',
149
152
  'SetCardStepCompletion' => 'set_completion',
150
153
  'GetQuestionnaire' => 'get_questionnaire',
151
154
  'GetQuestion' => 'get_question',
@@ -424,6 +427,13 @@ class ServiceGenerator
424
427
  response_schema = success_response&.dig('content', 'application/json', 'schema')
425
428
  returns_void = response_schema.nil?
426
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'
427
437
 
428
438
  {
429
439
  operation_id: operation_id,
@@ -440,6 +450,7 @@ class ServiceGenerator
440
450
  multipart_field: multipart_field,
441
451
  returns_void: returns_void,
442
452
  returns_array: returns_array,
453
+ returns_bare_array: returns_bare_array,
443
454
  is_mutation: http_method != 'GET',
444
455
  has_pagination: !!operation['x-basecamp-pagination'],
445
456
  pagination_key: operation.dig('x-basecamp-pagination', 'key')
@@ -658,6 +669,10 @@ class ServiceGenerator
658
669
  lines << ' # @return [Hash] response data'
659
670
  elsif is_paginated
660
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'
661
676
  else
662
677
  lines << ' # @return [Hash] response data'
663
678
  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__)
@@ -128,7 +155,7 @@ if __FILE__ == $PROGRAM_NAME
128
155
  # not individual params, so a class-level @deprecated tag documents a wholly
129
156
  # deprecated type.
130
157
  if schema['deprecated']
131
- puts " # @deprecated #{schema['x-deprecated-reason'] || 'deprecated'}"
158
+ puts deprecation_doc_lines(schema['x-deprecated-reason'] || 'deprecated', indent: ' ')
132
159
  end
133
160
  puts " class #{name}"
134
161
  puts ' include TypeHelpers'
@@ -147,7 +174,7 @@ if __FILE__ == $PROGRAM_NAME
147
174
 
148
175
  ruby_name = k.gsub(/([A-Z])/, '_\1').downcase.gsub(/^_/, '')
149
176
  puts " # @!attribute [rw] #{ruby_name}"
150
- puts " # @deprecated #{ps['x-deprecated-reason'] || 'deprecated'}"
177
+ puts deprecation_doc_lines(ps['x-deprecated-reason'] || 'deprecated', indent: ' ', tag_prefix: ' ')
151
178
  end
152
179
 
153
180
  puts " attr_accessor #{attr_names.map { |n| ":#{n}" }.join(", ")}"
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.9.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-26 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
@@ -253,6 +254,7 @@ files:
253
254
  - lib/basecamp/request_result.rb
254
255
  - lib/basecamp/security.rb
255
256
  - lib/basecamp/services/authorization_service.rb
257
+ - lib/basecamp/services/cards_extensions.rb
256
258
  - lib/basecamp/services/todos_extensions.rb
257
259
  - lib/basecamp/static_token_provider.rb
258
260
  - lib/basecamp/token_provider.rb