basecamp-sdk 0.9.0 → 0.11.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 +106 -10
  3. data/lib/basecamp/client.rb +13 -8
  4. data/lib/basecamp/config.rb +4 -3
  5. data/lib/basecamp/generated/metadata.json +280 -12
  6. data/lib/basecamp/generated/services/account_service.rb +1 -1
  7. data/lib/basecamp/generated/services/automation_service.rb +2 -2
  8. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  9. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  10. data/lib/basecamp/generated/services/card_columns_service.rb +1 -1
  11. data/lib/basecamp/generated/services/card_steps_service.rb +1 -1
  12. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  13. data/lib/basecamp/generated/services/cards_service.rb +4 -4
  14. data/lib/basecamp/generated/services/checkins_service.rb +8 -8
  15. data/lib/basecamp/generated/services/client_approvals_service.rb +2 -2
  16. data/lib/basecamp/generated/services/client_correspondences_service.rb +2 -2
  17. data/lib/basecamp/generated/services/client_replies_service.rb +2 -2
  18. data/lib/basecamp/generated/services/comments_service.rb +2 -2
  19. data/lib/basecamp/generated/services/documents_service.rb +2 -2
  20. data/lib/basecamp/generated/services/events_service.rb +1 -1
  21. data/lib/basecamp/generated/services/everything_service.rb +169 -0
  22. data/lib/basecamp/generated/services/forwards_service.rb +5 -5
  23. data/lib/basecamp/generated/services/gauges_service.rb +3 -3
  24. data/lib/basecamp/generated/services/hill_charts_service.rb +1 -1
  25. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  26. data/lib/basecamp/generated/services/message_types_service.rb +20 -15
  27. data/lib/basecamp/generated/services/messages_service.rb +2 -2
  28. data/lib/basecamp/generated/services/my_assignments_service.rb +5 -5
  29. data/lib/basecamp/generated/services/my_notifications_service.rb +16 -2
  30. data/lib/basecamp/generated/services/people_service.rb +9 -9
  31. data/lib/basecamp/generated/services/projects_service.rb +2 -2
  32. data/lib/basecamp/generated/services/recordings_service.rb +3 -3
  33. data/lib/basecamp/generated/services/reports_service.rb +5 -5
  34. data/lib/basecamp/generated/services/schedules_service.rb +13 -5
  35. data/lib/basecamp/generated/services/search_service.rb +2 -2
  36. data/lib/basecamp/generated/services/subscriptions_service.rb +1 -1
  37. data/lib/basecamp/generated/services/templates_service.rb +3 -3
  38. data/lib/basecamp/generated/services/timeline_service.rb +1 -1
  39. data/lib/basecamp/generated/services/timesheets_service.rb +5 -5
  40. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  41. data/lib/basecamp/generated/services/todolists_service.rb +2 -2
  42. data/lib/basecamp/generated/services/todos_service.rb +2 -2
  43. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  44. data/lib/basecamp/generated/services/tools_service.rb +4 -3
  45. data/lib/basecamp/generated/services/uploads_service.rb +3 -3
  46. data/lib/basecamp/generated/services/vaults_service.rb +2 -2
  47. data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
  48. data/lib/basecamp/generated/types.rb +374 -18
  49. data/lib/basecamp/http.rb +47 -18
  50. data/lib/basecamp/oauth/device_authorization.rb +37 -0
  51. data/lib/basecamp/oauth/device_flow.rb +873 -0
  52. data/lib/basecamp/oauth/device_flow_error.rb +46 -0
  53. data/lib/basecamp/oauth/discovery.rb +13 -11
  54. data/lib/basecamp/oauth/exchange.rb +44 -5
  55. data/lib/basecamp/oauth/fetcher.rb +626 -56
  56. data/lib/basecamp/oauth/oauth_error.rb +3 -1
  57. data/lib/basecamp/oauth/refresh_request.rb +8 -2
  58. data/lib/basecamp/oauth/resource.rb +13 -10
  59. data/lib/basecamp/oauth/token.rb +10 -3
  60. data/lib/basecamp/oauth.rb +47 -2
  61. data/lib/basecamp/services/cards_extensions.rb +67 -0
  62. data/lib/basecamp/version.rb +2 -2
  63. data/lib/basecamp.rb +5 -0
  64. data/scripts/generate-services.rb +24 -6
  65. data/scripts/generate-types.rb +29 -2
  66. metadata +7 -2
@@ -4,7 +4,9 @@ module Basecamp
4
4
  module Oauth
5
5
  # OAuth-specific error class.
6
6
  #
7
- # @attr type [String] Error type ("validation", "auth", "network", "api_error")
7
+ # @attr type [String] Error type ("validation", "auth", "network",
8
+ # "api_error", or "usage" — the parent type {DeviceFlowError} derives for
9
+ # a cancelled device flow)
8
10
  # @attr http_status [Integer, nil] HTTP status code if applicable
9
11
  # @attr hint [String, nil] Helpful hint for resolving the error
10
12
  # @attr retryable [Boolean] Whether the request can be retried
@@ -9,19 +9,25 @@ module Basecamp
9
9
  # @attr client_id [String, nil] The client identifier (optional)
10
10
  # @attr client_secret [String, nil] The client secret (optional)
11
11
  # @attr use_legacy_format [Boolean] Use Launchpad's non-standard token format
12
+ # @attr resource [String, nil] RFC 8707 resource indicator to bind the
13
+ # refreshed token to, sent only when set. Echo the stored token's
14
+ # resource: BC5 multi-account refresh tokens hard-require it (SPEC §16).
15
+ # Appended last so earlier members keep their positions.
12
16
  RefreshRequest = Data.define(
13
17
  :token_endpoint,
14
18
  :refresh_token,
15
19
  :client_id,
16
20
  :client_secret,
17
- :use_legacy_format
21
+ :use_legacy_format,
22
+ :resource
18
23
  ) do
19
24
  def initialize(
20
25
  token_endpoint:,
21
26
  refresh_token:,
22
27
  client_id: nil,
23
28
  client_secret: nil,
24
- use_legacy_format: false
29
+ use_legacy_format: false,
30
+ resource: nil
25
31
  )
26
32
  super
27
33
  end
@@ -5,25 +5,28 @@ module Basecamp
5
5
  # Fetches RFC 9728 protected-resource metadata (hop 1 of resource-first
6
6
  # discovery) and binds the returned +resource+ to the requested origin.
7
7
  class Resource
8
- # @param http_client [Faraday::Connection, nil] HTTP client (SSRF-hardened default if nil)
9
- # @param timeout [Integer] request timeout in seconds (default: 10)
8
+ # @param http_client [Faraday::Connection, nil] injected Faraday client
9
+ # (kept, verified redirect-free); nil selects the default headers-first
10
+ # Net::HTTP transport ({Fetcher.stream_http})
11
+ # @param timeout [Numeric] request timeout in seconds (default: 10); an
12
+ # invalid value falls back via {Fetcher.normalize_timeout}
10
13
  # @param max_body_bytes [Integer] bounded read cap in bytes
11
14
  def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES)
12
15
  Fetcher.ensure_redirects_suppressed!(http_client) if http_client
13
- # Normalize before building the client and before the fetch computes its
16
+ # Normalize before storing (nil selects the headers-first Net::HTTP
17
+ # transport; an injected client is kept) and before the fetch computes its
14
18
  # wall-clock deadline: a non-finite/non-positive timeout must not disable
15
19
  # either bound (see Fetcher.normalize_timeout).
16
20
  @timeout = Fetcher.normalize_timeout(timeout)
17
- @http_client = http_client || Fetcher.build_client(@timeout)
21
+ # nil selects the headers-first {Fetcher.stream_http} transport (total
22
+ # wall-clock bound incl. the header phase); an injected connection keeps
23
+ # the Faraday path, verified redirect-free above.
24
+ @http_client = http_client
18
25
  # Normalize the public cap to a finite non-negative Integer: a nil, float,
19
26
  # or Float::INFINITY would otherwise disable the streaming memory bound
20
27
  # (an infinite/undefined cap never trips), reintroducing an SSRF/OOM risk.
21
- @max_body_bytes =
22
- if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0
23
- max_body_bytes
24
- else
25
- Fetcher::DEFAULT_MAX_BODY_BYTES
26
- end
28
+ # Shared with Discovery and the device flow.
29
+ @max_body_bytes = Fetcher.normalize_body_cap(max_body_bytes)
27
30
  end
28
31
 
29
32
  # Discovers protected-resource metadata from
@@ -10,13 +10,18 @@ module Basecamp
10
10
  # @attr expires_in [Integer, nil] Lifetime of the access token in seconds
11
11
  # @attr expires_at [Time, nil] Calculated expiration time
12
12
  # @attr scope [String, nil] OAuth scope granted
13
+ # @attr resource [String, nil] RFC 8707 resource indicator the token is
14
+ # bound to (BC5: +urn:bc:account:<id>+). Echo it when refreshing —
15
+ # BC5 multi-account refresh tokens reject a refresh without it
16
+ # (SPEC §16). Appended last so earlier members keep their positions.
13
17
  Token = Data.define(
14
18
  :access_token,
15
19
  :token_type,
16
20
  :refresh_token,
17
21
  :expires_in,
18
22
  :expires_at,
19
- :scope
23
+ :scope,
24
+ :resource
20
25
  ) do
21
26
  def initialize(
22
27
  access_token:,
@@ -24,7 +29,8 @@ module Basecamp
24
29
  refresh_token: nil,
25
30
  expires_in: nil,
26
31
  expires_at: nil,
27
- scope: nil
32
+ scope: nil,
33
+ resource: nil
28
34
  )
29
35
  # Calculate expires_at from expires_in if not provided
30
36
  calculated_expires_at = expires_at || (expires_in ? Time.now + expires_in : nil)
@@ -34,7 +40,8 @@ module Basecamp
34
40
  refresh_token: refresh_token,
35
41
  expires_in: expires_in,
36
42
  expires_at: calculated_expires_at,
37
- scope: scope
43
+ scope: scope,
44
+ resource: resource
38
45
  )
39
46
  end
40
47
 
@@ -133,12 +133,12 @@ module Basecamp
133
133
  def self.refresh_token(
134
134
  token_endpoint:, refresh_token:,
135
135
  client_id: nil, client_secret: nil,
136
- use_legacy_format: false, timeout: 30
136
+ use_legacy_format: false, timeout: 30, resource: nil
137
137
  )
138
138
  request = RefreshRequest.new(
139
139
  token_endpoint: token_endpoint, refresh_token: refresh_token,
140
140
  client_id: client_id, client_secret: client_secret,
141
- use_legacy_format: use_legacy_format
141
+ use_legacy_format: use_legacy_format, resource: resource
142
142
  )
143
143
  Exchange.new(timeout: timeout).refresh(request)
144
144
  end
@@ -147,6 +147,51 @@ module Basecamp
147
147
  token.expired?(buffer_seconds)
148
148
  end
149
149
 
150
+ # Requests an RFC 8628 device/user code pair. Thin delegator to
151
+ # {DeviceFlow.request_device_authorization}.
152
+ #
153
+ # @return [DeviceAuthorization]
154
+ def self.request_device_authorization(device_authorization_endpoint:, client_id:, scope: nil, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT)
155
+ DeviceFlow.request_device_authorization(
156
+ device_authorization_endpoint: device_authorization_endpoint,
157
+ client_id: client_id, scope: scope, timeout: timeout
158
+ )
159
+ end
160
+
161
+ # Polls the token endpoint for a device grant (RFC 8628 §3.5). Thin delegator
162
+ # to {DeviceFlow.poll_device_token}; +clock+, +sleeper+, and +cancelled+ are
163
+ # injectable for tests.
164
+ #
165
+ # @return [Token]
166
+ def self.poll_device_token(
167
+ token_endpoint:, client_id:, device_code:, interval:, expires_in:,
168
+ clock: DeviceFlow::DEFAULT_CLOCK, sleeper: DeviceFlow::DEFAULT_SLEEPER,
169
+ cancelled: DeviceFlow::DEFAULT_CANCELLED, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT,
170
+ deadline_at: nil
171
+ )
172
+ DeviceFlow.poll_device_token(
173
+ token_endpoint: token_endpoint, client_id: client_id,
174
+ device_code: device_code, interval: interval, expires_in: expires_in,
175
+ clock: clock, sleeper: sleeper, cancelled: cancelled, timeout: timeout,
176
+ deadline_at: deadline_at
177
+ )
178
+ end
179
+
180
+ # Runs the full device authorization grant against an already-selected
181
+ # {Config} (RFC 8628). Thin delegator to {DeviceFlow.perform_device_login}.
182
+ #
183
+ # @return [Token]
184
+ def self.perform_device_login(
185
+ config:, client_id:, display:, scope: nil,
186
+ clock: DeviceFlow::DEFAULT_CLOCK, sleeper: DeviceFlow::DEFAULT_SLEEPER,
187
+ cancelled: DeviceFlow::DEFAULT_CANCELLED, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT
188
+ )
189
+ DeviceFlow.perform_device_login(
190
+ config: config, client_id: client_id, display: display, scope: scope,
191
+ clock: clock, sleeper: sleeper, cancelled: cancelled, timeout: timeout
192
+ )
193
+ end
194
+
150
195
  # Selects an issuer from the advertised set and — once a BC5 issuer is
151
196
  # committed — binds its AS metadata. From this point on, every failure is
152
197
  # fatal: no Launchpad request may be issued.
@@ -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.11.0"
5
+ API_VERSION = "2026-07-30"
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
@@ -803,9 +818,9 @@ class ServiceGenerator
803
818
  if op[:query_params].any?
804
819
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
805
820
  lines << " params = compact_query_params(#{param_names.join(', ')})"
806
- lines << " paginate(#{path_expr}, params: params)"
821
+ lines << " paginate(#{path_expr}, params: params, operation: \"#{op[:operation_id]}\")"
807
822
  else
808
- lines << " paginate(#{path_expr})"
823
+ lines << " paginate(#{path_expr}, operation: \"#{op[:operation_id]}\")"
809
824
  end
810
825
 
811
826
  lines
@@ -817,9 +832,9 @@ class ServiceGenerator
817
832
  if op[:query_params].any?
818
833
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
819
834
  lines << " params = compact_query_params(#{param_names.join(', ')})"
820
- lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params)"
835
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params, operation: \"#{op[:operation_id]}\")"
821
836
  else
822
- lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\")"
837
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", operation: \"#{op[:operation_id]}\")"
823
838
  end
824
839
 
825
840
  lines
@@ -828,6 +843,9 @@ class ServiceGenerator
828
843
  def generate_get_method_body(op, path_expr)
829
844
  lines = []
830
845
  http_method = op[:http_method].downcase
846
+ # Only GETs are retry-governed in Ruby, and only Http#get accepts the
847
+ # operation keyword — mutations go through single_request with no retry.
848
+ operation_arg = http_method == 'get' ? ", operation: \"#{op[:operation_id]}\"" : ''
831
849
 
832
850
  if op[:has_binary_body]
833
851
  # Binary upload - use raw body and set Content-Type header
@@ -848,9 +866,9 @@ class ServiceGenerator
848
866
  lines << " http_#{http_method}(#{path_expr}, body: #{body_expr}).json"
849
867
  elsif op[:query_params].any?
850
868
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
851
- lines << " http_#{http_method}(#{path_expr}, params: compact_query_params(#{param_names.join(', ')})).json"
869
+ lines << " http_#{http_method}(#{path_expr}, params: compact_query_params(#{param_names.join(', ')})#{operation_arg}).json"
852
870
  else
853
- lines << " http_#{http_method}(#{path_expr}).json"
871
+ lines << " http_#{http_method}(#{path_expr}#{operation_arg}).json"
854
872
  end
855
873
 
856
874
  lines
@@ -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.11.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-31 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
@@ -233,6 +234,9 @@ files:
233
234
  - lib/basecamp/not_found_error.rb
234
235
  - lib/basecamp/oauth.rb
235
236
  - lib/basecamp/oauth/config.rb
237
+ - lib/basecamp/oauth/device_authorization.rb
238
+ - lib/basecamp/oauth/device_flow.rb
239
+ - lib/basecamp/oauth/device_flow_error.rb
236
240
  - lib/basecamp/oauth/discovery.rb
237
241
  - lib/basecamp/oauth/discovery_result.rb
238
242
  - lib/basecamp/oauth/discovery_selection_error.rb
@@ -253,6 +257,7 @@ files:
253
257
  - lib/basecamp/request_result.rb
254
258
  - lib/basecamp/security.rb
255
259
  - lib/basecamp/services/authorization_service.rb
260
+ - lib/basecamp/services/cards_extensions.rb
256
261
  - lib/basecamp/services/todos_extensions.rb
257
262
  - lib/basecamp/static_token_provider.rb
258
263
  - lib/basecamp/token_provider.rb