basecamp-sdk 0.18.0 → 0.19.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/basecamp/api_error.rb +5 -3
  4. data/lib/basecamp/bucket_mismatch_error.rb +41 -0
  5. data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
  6. data/lib/basecamp/campfire_index.rb +398 -0
  7. data/lib/basecamp/client.rb +47 -4
  8. data/lib/basecamp/generated/metadata.json +728 -1
  9. data/lib/basecamp/generated/services/account_service.rb +2 -2
  10. data/lib/basecamp/generated/services/attachments_service.rb +1 -1
  11. data/lib/basecamp/generated/services/automation_service.rb +1 -1
  12. data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
  13. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  14. data/lib/basecamp/generated/services/calendars_service.rb +2 -2
  15. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  16. data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
  17. data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
  18. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  19. data/lib/basecamp/generated/services/cards_service.rb +3 -3
  20. data/lib/basecamp/generated/services/checkins_service.rb +9 -9
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
  23. data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
  25. data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
  26. data/lib/basecamp/generated/services/comments_service.rb +3 -3
  27. data/lib/basecamp/generated/services/documents_service.rb +3 -3
  28. data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
  29. data/lib/basecamp/generated/services/everything_service.rb +2 -2
  30. data/lib/basecamp/generated/services/folders_service.rb +4 -4
  31. data/lib/basecamp/generated/services/forwards_service.rb +3 -3
  32. data/lib/basecamp/generated/services/gauges_service.rb +10 -6
  33. data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
  34. data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
  35. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  36. data/lib/basecamp/generated/services/message_types_service.rb +3 -3
  37. data/lib/basecamp/generated/services/messages_service.rb +3 -3
  38. data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
  39. data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
  40. data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
  41. data/lib/basecamp/generated/services/people_service.rb +11 -11
  42. data/lib/basecamp/generated/services/projects_service.rb +4 -4
  43. data/lib/basecamp/generated/services/recordings_service.rb +1 -1
  44. data/lib/basecamp/generated/services/reports_service.rb +3 -3
  45. data/lib/basecamp/generated/services/schedules_service.rb +6 -6
  46. data/lib/basecamp/generated/services/search_service.rb +1 -1
  47. data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
  48. data/lib/basecamp/generated/services/templates_service.rb +8 -8
  49. data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
  50. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  51. data/lib/basecamp/generated/services/todolists_service.rb +3 -3
  52. data/lib/basecamp/generated/services/todos_service.rb +4 -4
  53. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  54. data/lib/basecamp/generated/services/tools_service.rb +3 -3
  55. data/lib/basecamp/generated/services/uploads_service.rb +4 -4
  56. data/lib/basecamp/generated/services/vaults_service.rb +3 -3
  57. data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
  58. data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
  59. data/lib/basecamp/generated/types.rb +108 -6
  60. data/lib/basecamp/http.rb +383 -60
  61. data/lib/basecamp/ids.rb +277 -0
  62. data/lib/basecamp/mentions.rb +1119 -0
  63. data/lib/basecamp/person_id_sites.rb +184 -0
  64. data/lib/basecamp/recording_routing_error.rb +50 -0
  65. data/lib/basecamp/recording_summary_error.rb +33 -0
  66. data/lib/basecamp/services/comments_extensions.rb +121 -0
  67. data/lib/basecamp/services/merge_safe.rb +37 -13
  68. data/lib/basecamp/services/recordings_extensions.rb +1305 -0
  69. data/lib/basecamp/services/todolists_extensions.rb +4 -3
  70. data/lib/basecamp/text.rb +94 -0
  71. data/lib/basecamp/unresolved_recording_error.rb +68 -0
  72. data/lib/basecamp/version.rb +1 -1
  73. data/lib/basecamp/webhooks/event.rb +4 -1
  74. data/lib/basecamp.rb +22 -4
  75. data/scripts/generate-metadata.rb +75 -2
  76. data/scripts/generate-services.rb +35 -8
  77. data/scripts/generate-types.rb +19 -0
  78. metadata +15 -2
@@ -180,9 +180,10 @@ module Basecamp
180
180
  # Ruby has no typed decoder between the GET and this read, unlike the Go,
181
181
  # Swift and Kotlin composites where a wrong-typed field fails at decode,
182
182
  # and flattening the shape did not add one: the generated method still
183
- # returns <tt>http_get(...).json</tt> verbatim. The same shape is live in
184
- # the shipped Todos composite; tracked in #576, with the generated
185
- # validating layer that would retire this guard tracked in #578.
183
+ # returns <tt>http_get(...).json</tt> verbatim. The same shape in the
184
+ # shipped Todos composite is guarded by the MergeSafe checks #576 closed
185
+ # with; the generated validating layer that would retire this guard is
186
+ # tracked in #578.
186
187
  def writable_string(body, key, non_empty: false)
187
188
  raise_missing_field(key) unless body.key?(key)
188
189
 
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Text trimming that matches the reference's, byte for byte.
5
+ #
6
+ # Ruby's <tt>String#strip</tt> is NOT that function, and the difference runs
7
+ # in both directions. Measured over all 256 single bytes and the whole of
8
+ # +unicode.IsSpace+:
9
+ #
10
+ # * <tt>strip</tt> removes a leading or trailing NUL and +strings.TrimSpace+
11
+ # does not — <tt>unicode.IsSpace</tt> has no NUL in it. That is the
12
+ # ACCEPTING direction wherever a trimmed value is compared against a table:
13
+ # <tt>"Comment\\0"</tt> selected a real recording type here and was
14
+ # +unknown_recording_type+ there.
15
+ # * <tt>strip</tt> on a BYTE string removes none of the nineteen multi-byte
16
+ # spaces +IsSpace+ carries — U+0085, U+00A0, U+1680, U+2000..U+200A,
17
+ # U+2028, U+2029, U+202F, U+205F, U+3000 — which the reference trims. That
18
+ # direction merely refuses, but it is still a divergence.
19
+ #
20
+ # One byte of the 256 diverges in the first direction and nineteen characters
21
+ # in the second, so neither <tt>strip</tt> nor a plain ASCII trim is the
22
+ # reference's function; this walk is.
23
+ #
24
+ # It lives here rather than in either caller because both {Basecamp::Mentions}
25
+ # and the recording-summary composite need the same trim, and a second
26
+ # hand-written copy of a byte walk is how the two would come apart.
27
+ module Text
28
+ # Every member of +unicode.IsSpace+, as its UTF-8 bytes. Built from the
29
+ # literals rather than a range so the set is readable, and keyed by byte
30
+ # string so a lookup needs no decoding. No member is a prefix, a suffix or a
31
+ # substring of another, which is what lets the walk below take the first
32
+ # width that matches rather than the longest.
33
+ UTF8_SPACE_BYTES = ([ " ", "\t", "\n", "\v", "\f", "\r", "…", " ", " ",
34
+ "
", "
", " ", " ", " " ] +
35
+ (0x2000..0x200A).map { |codepoint| codepoint.chr(Encoding::UTF_8) })
36
+ .to_h { |character| [ character.b, true ] }.freeze
37
+
38
+ # The widest member, so the walk knows how far to look.
39
+ MAX_SPACE_WIDTH = UTF8_SPACE_BYTES.keys.map(&:bytesize).max
40
+
41
+ module_function
42
+
43
+ # The value with leading and trailing +unicode.IsSpace+ runs removed, as
44
+ # +strings.TrimSpace+ removes them.
45
+ #
46
+ # @param value [String]
47
+ # @return [String] the trimmed bytes
48
+ def trim_space(value)
49
+ bytes = value.b
50
+ first = space_run_end(bytes)
51
+ bytes.byteslice(first, space_run_start(bytes, first) - first)
52
+ end
53
+
54
+ # The offset just past the leading run of spaces.
55
+ def space_run_end(bytes)
56
+ offset = 0
57
+ while (width = space_width_at(bytes, offset, bytes.bytesize))
58
+ offset += width
59
+ end
60
+ offset
61
+ end
62
+
63
+ # The offset where the trailing run of spaces begins, never below +floor+ —
64
+ # so a value that is nothing but spaces trims to empty rather than
65
+ # underflowing past the leading run already consumed.
66
+ def space_run_start(bytes, floor)
67
+ offset = bytes.bytesize
68
+ while (width = space_width_behind(bytes, offset, floor))
69
+ offset -= width
70
+ end
71
+ offset
72
+ end
73
+
74
+ # The width of the space at +offset+, or nil when there is none.
75
+ def space_width_at(bytes, offset, ceiling)
76
+ (1..MAX_SPACE_WIDTH).each do |width|
77
+ next if offset + width > ceiling
78
+ return width if UTF8_SPACE_BYTES.key?(bytes.byteslice(offset, width))
79
+ end
80
+ nil
81
+ end
82
+
83
+ # The width of the space ending at +offset+, or nil when there is none.
84
+ def space_width_behind(bytes, offset, floor)
85
+ (1..MAX_SPACE_WIDTH).each do |width|
86
+ next if offset - width < floor
87
+ return width if UTF8_SPACE_BYTES.key?(bytes.byteslice(offset - width, width))
88
+ end
89
+ nil
90
+ end
91
+
92
+ private_class_method :space_run_end, :space_run_start, :space_width_at, :space_width_behind
93
+ end
94
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when a chat line was found under none of the Campfires the caller can
5
+ # currently see in its bucket.
6
+ #
7
+ # It is distinct from a failed read — any non-404 answer from a candidate is
8
+ # raised as itself and stops the loop — and from
9
+ # {CampfireDiscoveryIncompleteError}, where candidates were left unsearched:
10
+ # here EVERY candidate answered 404.
11
+ #
12
+ # It is NOT distinct from lost visibility. BC3 answers 404 for a Campfire the
13
+ # caller may not see, too, so "unresolved" means "under no Campfire the caller
14
+ # can currently see". {#stale_campfire_ids} is what lets a consumer tell the
15
+ # two apart: candidates the cache held that the refreshed sources no longer
16
+ # list are Campfires the caller could see when the cache filled and cannot
17
+ # now. A consumer marks the record blocked and retries on its own schedule.
18
+ #
19
+ # +code+ is +not_found+: the recording could not be located. The CLASS is what
20
+ # keeps it distinct from a read's {NotFoundError} — this error carries no
21
+ # +http_status+, because no single HTTP answer produced it.
22
+ class UnresolvedRecordingError < RecordingSummaryError
23
+ KIND = "recording_unresolved"
24
+
25
+ # @return [Integer] the bucket the pointer named
26
+ attr_reader :bucket_id
27
+
28
+ # @return [Integer] the chat line's id
29
+ attr_reader :recording_id
30
+
31
+ # @return [Array<Integer>] the candidates tried, in order; empty when the
32
+ # bucket has no visible Campfire at all
33
+ attr_reader :campfire_ids
34
+
35
+ # @return [Array<Integer>] candidates from the cache that the refreshed
36
+ # sources no longer list. Set only when {#refreshed?}.
37
+ attr_reader :stale_campfire_ids
38
+
39
+ # @param bucket_id [Integer]
40
+ # @param recording_id [Integer]
41
+ # @param campfire_ids [Array<Integer>]
42
+ # @param refreshed [Boolean] whether the cached discovery sources were
43
+ # re-read before concluding. False when every source had been read within
44
+ # the last +CampfireIndex::MIN_REFRESH+, so a Campfire created in that
45
+ # window was not seen: the conclusion stands on data up to that old, and a
46
+ # retry after the floor sees the current sources.
47
+ # @param stale_campfire_ids [Array<Integer>]
48
+ def initialize(bucket_id:, recording_id:, campfire_ids:, refreshed:, stale_campfire_ids: [])
49
+ super(
50
+ kind: KIND,
51
+ code: ErrorCode::NOT_FOUND,
52
+ message: "chat line found under no visible campfire: line #{recording_id} in bucket #{bucket_id} " \
53
+ "(tried #{campfire_ids.length} campfires)"
54
+ )
55
+ @bucket_id = bucket_id
56
+ @recording_id = recording_id
57
+ @campfire_ids = campfire_ids
58
+ @refreshed = refreshed
59
+ @stale_campfire_ids = stale_campfire_ids
60
+ end
61
+
62
+ # @return [Boolean] whether the cached discovery sources were re-read before
63
+ # concluding
64
+ def refreshed?
65
+ @refreshed
66
+ end
67
+ end
68
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.18.0"
4
+ VERSION = "0.19.0"
5
5
  API_VERSION = "2026-09-02"
6
6
  end
@@ -5,7 +5,7 @@ module Basecamp
5
5
  # Structured wrapper around webhook event payloads.
6
6
  # Accepts any hash - does not reject unknown fields or event kinds.
7
7
  class Event
8
- attr_reader :id, :kind, :details, :created_at, :recording, :creator, :copy, :raw
8
+ attr_reader :id, :kind, :details, :created_at, :recording, :creator, :performed_by, :copy, :raw
9
9
 
10
10
  def initialize(hash)
11
11
  @raw = hash
@@ -15,6 +15,9 @@ module Basecamp
15
15
  @created_at = hash["created_at"]
16
16
  @recording = hash["recording"] || {}
17
17
  @creator = hash["creator"] || {}
18
+ # The agent that carried out a delegated action (personable_type "Agent",
19
+ # or "Tombstone" once deleted); nil for actions performed directly.
20
+ @performed_by = hash["performed_by"]
18
21
  @copy = hash["copy"]
19
22
  end
20
23
 
data/lib/basecamp.rb CHANGED
@@ -31,6 +31,17 @@ end
31
31
  loader.on_load("Basecamp::Services::SchedulesService") do |klass, _abspath|
32
32
  klass.prepend(Basecamp::Services::SchedulesExtensions)
33
33
  end
34
+ # And for recordings: the generated class owns the status writes, and the
35
+ # hand-written `summarize` projection — one typed read per recording type, plus
36
+ # Campfire discovery for chat lines — is prepended over it.
37
+ loader.on_load("Basecamp::Services::RecordingsService") do |klass, _abspath|
38
+ klass.prepend(Basecamp::Services::RecordingsExtensions)
39
+ end
40
+ # And for comments: the generated class owns `create`, and the mention-expanding
41
+ # `create_with_mentions`/`expand_mentions` surface is prepended over it.
42
+ loader.on_load("Basecamp::Services::CommentsService") do |klass, _abspath|
43
+ klass.prepend(Basecamp::Services::CommentsExtensions)
44
+ end
34
45
  loader.setup
35
46
 
36
47
  # Load generated types if available
@@ -124,7 +135,7 @@ module Basecamp
124
135
  server_message = parse_error_message(body)
125
136
  message = server_message || "Request failed"
126
137
 
127
- case status
138
+ err = case status
128
139
  when 400, 422
129
140
  field_errors = parse_field_errors(body)
130
141
  message = Security.truncate(compose_validation_message(server_message, field_errors) || "Request failed")
@@ -149,12 +160,19 @@ module Basecamp
149
160
  # transient server failure, and no retry can satisfy it.
150
161
  LimitExceededError.new(Security.truncate(message), hint: hint)
151
162
  when 500
152
- ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint)
163
+ ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint, retry_after: retry_after)
153
164
  when 502, 503, 504
154
- ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint)
165
+ ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint, retry_after: retry_after)
155
166
  else
156
- ApiError.from_status(status, server_message, hint: hint)
167
+ ApiError.from_status(status, server_message, hint: hint, retry_after: retry_after)
157
168
  end
169
+
170
+ # Every status carries the parsed Retry-After (SPEC §6 "HTTP Status Mapping
171
+ # Algorithm"), including the arms whose error classes take no such
172
+ # argument — the same back-fill Http#handle_error applies, so this public
173
+ # mapper and the private one answer alike.
174
+ err.instance_variable_set(:@retry_after, retry_after) if retry_after && err.retry_after.nil?
175
+ err
158
176
  end
159
177
 
160
178
  # Extracts a filename from the last path segment of a URL.
@@ -3,6 +3,8 @@
3
3
 
4
4
  # Extracts x-basecamp-* extensions from OpenAPI spec into a runtime-accessible metadata file.
5
5
  # This allows the Ruby SDK to read operation metadata at runtime for retry, pagination, etc.
6
+ # It also emits +personIdSites+, the per-operation table of response paths the
7
+ # generated read path decodes a person id at (see {Basecamp::PersonIdSites}).
6
8
  #
7
9
  # Usage: ruby scripts/ruby/generate-metadata.rb > lib/basecamp/generated/metadata.json
8
10
 
@@ -39,10 +41,82 @@ class MetadataExtractor
39
41
  '$schema' => 'https://basecamp.com/schemas/sdk-metadata.json',
40
42
  'version' => '1.0.0',
41
43
  'generated' => Time.now.utc.iso8601,
42
- 'operations' => operations
44
+ 'operations' => operations,
45
+ 'personIdSites' => person_id_sites
43
46
  }
44
47
  end
45
48
 
49
+ # The Go-type marker that makes a schema's +id+ a flexible person id. Go
50
+ # generates +Person.Id+ as +types.FlexibleInt64+ from exactly this extension,
51
+ # and it is the only field that carries it, so the table is selected on the
52
+ # marker and never on a key name: +UpcomingSchedulePerson+,
53
+ # +MyAssignmentAssignee+, +OutOfOfficePerson+ and
54
+ # +TemplateLibraryConfirmationPerson+ sit under the same key names with a
55
+ # plain int64 id, where a string is a decode error in the reference.
56
+ FLEXIBLE_PERSON_ID = 'types.FlexibleInt64'
57
+
58
+ # {operationId => [path, ...]} for every operation whose 2xx JSON response
59
+ # reaches a schema whose +id+ is {FLEXIBLE_PERSON_ID}. A path is its
60
+ # components joined by "."; "[]" is an array element, and a path that ENDS
61
+ # in "[]" names an array of people. "$" is the body itself.
62
+ def person_id_sites
63
+ sites = {}
64
+ (@openapi['paths'] || {}).each_value do |path_item|
65
+ METHODS.each do |method|
66
+ operation = path_item[method]
67
+ next unless operation && operation['operationId']
68
+
69
+ paths = (operation['responses'] || {}).flat_map do |code, response|
70
+ next [] unless code.to_s.start_with?('2')
71
+
72
+ response = resolve_response(response)
73
+ (response['content'] || {}).values.flat_map do |media|
74
+ found = []
75
+ walk_person_sites(media['schema'] || {}, [], [], found)
76
+ found
77
+ end
78
+ end.uniq.sort
79
+ sites[operation['operationId']] = paths if paths.any?
80
+ end
81
+ end
82
+ sites.sort.to_h
83
+ end
84
+
85
+ def resolve_response(response)
86
+ return response unless response['$ref']
87
+
88
+ @openapi.dig('components', 'responses', response['$ref'].split('/').last)
89
+ end
90
+
91
+ # Walks $ref, allOf/oneOf/anyOf, items and properties. A schema already on
92
+ # the $ref stack is a cycle and is not re-entered. additionalProperties has
93
+ # no runtime path syntax, so a person reached through one fails generation
94
+ # rather than being silently left out of the table.
95
+ def walk_person_sites(schema, path, stack, found)
96
+ if (ref = schema['$ref'])
97
+ name = ref.split('/').last
98
+ return if stack.include?(name)
99
+
100
+ target = @openapi.dig('components', 'schemas', name)
101
+ found << (path.empty? ? '$' : path.join('.')) if target.dig('properties', 'id', 'x-go-type') == FLEXIBLE_PERSON_ID
102
+ walk_person_sites(target, path, stack + [ name ], found)
103
+ return
104
+ end
105
+
106
+ %w[allOf oneOf anyOf].each do |key|
107
+ (schema[key] || []).each { |sub| walk_person_sites(sub, path, stack, found) }
108
+ end
109
+ walk_person_sites(schema['items'] || {}, path + [ '[]' ], stack, found) if schema['type'] == 'array' || schema['items']
110
+ (schema['properties'] || {}).each { |key, sub| walk_person_sites(sub, path + [ key ], stack, found) }
111
+
112
+ extra = schema['additionalProperties']
113
+ return unless extra.is_a?(Hash)
114
+
115
+ nested = []
116
+ walk_person_sites(extra, path + [ '{}' ], stack, nested)
117
+ raise "person id site under additionalProperties at #{nested.first} has no runtime path syntax" if nested.any?
118
+ end
119
+
46
120
  private
47
121
 
48
122
  def extract_operation_metadata(operation)
@@ -62,7 +136,6 @@ class MetadataExtractor
62
136
  if (pagination = operation['x-basecamp-pagination'])
63
137
  metadata['pagination'] = {
64
138
  'style' => pagination['style'],
65
- 'pageParam' => pagination['pageParam'],
66
139
  'totalCountHeader' => pagination['totalCountHeader'],
67
140
  'maxPageSize' => pagination['maxPageSize']
68
141
  }.compact
@@ -56,6 +56,7 @@ class ServiceGenerator
56
56
  GetCardColumn UpdateCardColumn SetCardColumnColor
57
57
  EnableCardColumnOnHold DisableCardColumnOnHold
58
58
  CreateCardColumn MoveCardColumn
59
+ SubscribeToCardColumn UnsubscribeFromCardColumn
59
60
  ],
60
61
  'CardSteps' => %w[
61
62
  GetCardStep CreateCardStep UpdateCardStep SetCardStepCompletion
@@ -440,6 +441,14 @@ class ServiceGenerator
440
441
  # Extract body parameters from schema
441
442
  body_params = extract_body_params(body_schema_ref)
442
443
 
444
+ # Only "link" and "cursor" are implemented. Anything else -- a typo, or the
445
+ # "page" style the trait used to advertise -- must fail loudly: read as "not
446
+ # paginated" it would silently ship a method that never walks.
447
+ pagination_style = operation.dig('x-basecamp-pagination', 'style')
448
+ if operation['x-basecamp-pagination'] && !%w[ link cursor ].include?(pagination_style)
449
+ raise "#{operation_id}: unsupported pagination style #{pagination_style.inspect} (expected \"link\" or \"cursor\")"
450
+ end
451
+
443
452
  # Check response
444
453
  success_response = operation.dig('responses', '200') || operation.dig('responses', '201')
445
454
  response_schema = success_response&.dig('content', 'application/json', 'schema')
@@ -470,8 +479,18 @@ class ServiceGenerator
470
479
  returns_array: returns_array,
471
480
  returns_bare_array: returns_bare_array,
472
481
  is_mutation: http_method != 'GET',
473
- has_pagination: !!operation['x-basecamp-pagination'],
474
- pagination_key: operation.dig('x-basecamp-pagination', 'key')
482
+ # Auto-pagination is the "link" style alone: the generated method follows
483
+ # Link: rel="next" and flattens the walk into one array. The "cursor"
484
+ # style is declared for the catalogue and generates none -- each call
485
+ # returns one page carrying its own opaque position, and flattening would
486
+ # swallow every intermediate one.
487
+ has_pagination: pagination_style == 'link',
488
+ # Carried separately because has_pagination is false for cursor, and the
489
+ # bare-array gate below would otherwise walk it anyway.
490
+ cursor_pagination: pagination_style == 'cursor',
491
+ # Link-style only: the key drives envelope unwrapping, and a cursor
492
+ # operation must be typed as its envelope rather than the item under it.
493
+ pagination_key: (pagination_style == 'link' ? operation.dig('x-basecamp-pagination', 'key') : nil)
475
494
  }
476
495
  end
477
496
 
@@ -657,7 +676,10 @@ class ServiceGenerator
657
676
  def generate_method(op, service_name:)
658
677
  lines = []
659
678
 
660
- is_paginated = (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
679
+ # A cursor operation never walks, whatever its response shape: has_pagination
680
+ # is already false for it, but a bare-array response would otherwise send it
681
+ # down the Link-following path through returns_array.
682
+ is_paginated = !op[:cursor_pagination] && (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
661
683
  is_wrapped_paginated = op[:has_pagination] && op[:pagination_key]
662
684
 
663
685
  # Method signature (paginated operations gain a trailing max_items: kwarg)
@@ -903,6 +925,11 @@ class ServiceGenerator
903
925
  # Only GETs are retry-governed in Ruby, and only Http#get accepts the
904
926
  # operation keyword — mutations go through single_request with no retry.
905
927
  operation_arg = http_method == 'get' ? ", operation: \"#{op[:operation_id]}\"" : ''
928
+ # Every verb names its operation to the body decode, which is a separate
929
+ # channel from the retry keyword above: the typed person-id decode
930
+ # (Basecamp::PersonIdSites) is keyed by operation id, and a mutation's
931
+ # response carries people exactly as a read's does.
932
+ json_call = ".json(operation: \"#{op[:operation_id]}\")"
906
933
 
907
934
  if op[:has_binary_body]
908
935
  # Binary upload - use raw body and set Content-Type header
@@ -914,18 +941,18 @@ class ServiceGenerator
914
941
  query_string = query_parts.join('&')
915
942
  # Modify path_expr to include query string
916
943
  path_expr_with_query = path_expr.sub(/"$/, "?#{query_string}\"")
917
- lines << " http_#{http_method}_raw(#{path_expr_with_query}, body: data, content_type: content_type).json"
944
+ lines << " http_#{http_method}_raw(#{path_expr_with_query}, body: data, content_type: content_type)#{json_call}"
918
945
  else
919
- lines << " http_#{http_method}_raw(#{path_expr}, body: data, content_type: content_type).json"
946
+ lines << " http_#{http_method}_raw(#{path_expr}, body: data, content_type: content_type)#{json_call}"
920
947
  end
921
948
  elsif op[:has_body]
922
949
  body_expr = build_body_expression(op)
923
- lines << " http_#{http_method}(#{path_expr}, body: #{body_expr}).json"
950
+ lines << " http_#{http_method}(#{path_expr}, body: #{body_expr})#{json_call}"
924
951
  elsif op[:query_params].any?
925
952
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
926
- lines << " http_#{http_method}(#{path_expr}, params: compact_query_params(#{param_names.join(', ')})#{operation_arg}).json"
953
+ lines << " http_#{http_method}(#{path_expr}, params: compact_query_params(#{param_names.join(', ')})#{operation_arg})#{json_call}"
927
954
  else
928
- lines << " http_#{http_method}(#{path_expr}#{operation_arg}).json"
955
+ lines << " http_#{http_method}(#{path_expr}#{operation_arg})#{json_call}"
929
956
  end
930
957
 
931
958
  lines
@@ -47,6 +47,23 @@ def generate_helpers
47
47
  value.to_i
48
48
  end
49
49
 
50
+ # A PERSON's id: the one field in this model the reference decodes
51
+ # flexibly (x-go-type: types.FlexibleInt64), and the only one that may
52
+ # arrive as a JSON string.
53
+ #
54
+ # Read with Basecamp::Ids.person_from_wire, which is Go's
55
+ # strconv.ParseInt(s, 10, 64) and is already the rule at the other two
56
+ # person-id sites (the pre-decode normalizer in Basecamp::Http and the
57
+ # flexible reader itself). NOT parse_integer, whose to_i answers 0 for
58
+ # "basecamp" without recording the label, 8 for "010", and — since the
59
+ # normalizer now leaves an out-of-range id as a String for the reader to
60
+ # refuse — a bignum for "18446744073709551616x", which is not an id any
61
+ # API can hold. A refused id is nil here, the same nil a malformed value
62
+ # of any other type gets.
63
+ def parse_flexible_person_id(value)
64
+ Basecamp::Ids.person_from_wire(value)
65
+ end
66
+
50
67
  def parse_float(value)
51
68
  return nil if value.nil?
52
69
  value.to_f
@@ -203,6 +220,8 @@ if __FILE__ == $PROGRAM_NAME
203
220
  elsif prop_schema['type'] == 'array' && prop_schema.dig('items', '$ref')
204
221
  ref_name = prop_schema['items']['$ref'].split('/').last
205
222
  "parse_array(data[\"#{prop_name}\"], \"#{ref_name}\")"
223
+ elsif prop_schema['x-go-type']&.include?('FlexibleInt64')
224
+ "parse_flexible_person_id(data[\"#{prop_name}\"])"
206
225
  elsif prop_schema['type'] == 'integer'
207
226
  "parse_integer(data[\"#{prop_name}\"])"
208
227
  elsif prop_schema['type'] == 'number'
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.18.0
4
+ version: 0.19.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-09-10 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -169,6 +169,9 @@ files:
169
169
  - lib/basecamp/auth_error.rb
170
170
  - lib/basecamp/auth_strategy.rb
171
171
  - lib/basecamp/bearer_auth.rb
172
+ - lib/basecamp/bucket_mismatch_error.rb
173
+ - lib/basecamp/campfire_discovery_incomplete_error.rb
174
+ - lib/basecamp/campfire_index.rb
172
175
  - lib/basecamp/chain_hooks.rb
173
176
  - lib/basecamp/client.rb
174
177
  - lib/basecamp/config.rb
@@ -200,6 +203,7 @@ files:
200
203
  - lib/basecamp/generated/services/comments_service.rb
201
204
  - lib/basecamp/generated/services/documents_service.rb
202
205
  - lib/basecamp/generated/services/drafts_service.rb
206
+ - lib/basecamp/generated/services/event_feed_service.rb
203
207
  - lib/basecamp/generated/services/events_service.rb
204
208
  - lib/basecamp/generated/services/everything_service.rb
205
209
  - lib/basecamp/generated/services/folders_service.rb
@@ -236,10 +240,12 @@ files:
236
240
  - lib/basecamp/generated/types.rb
237
241
  - lib/basecamp/hooks.rb
238
242
  - lib/basecamp/http.rb
243
+ - lib/basecamp/ids.rb
239
244
  - lib/basecamp/limit_exceeded_error.rb
240
245
  - lib/basecamp/list_enumerator.rb
241
246
  - lib/basecamp/list_meta.rb
242
247
  - lib/basecamp/logger_hooks.rb
248
+ - lib/basecamp/mentions.rb
243
249
  - lib/basecamp/network_error.rb
244
250
  - lib/basecamp/noop_hooks.rb
245
251
  - lib/basecamp/not_found_error.rb
@@ -264,19 +270,26 @@ files:
264
270
  - lib/basecamp/operation_info.rb
265
271
  - lib/basecamp/operation_result.rb
266
272
  - lib/basecamp/people_confirmation_required_error.rb
273
+ - lib/basecamp/person_id_sites.rb
267
274
  - lib/basecamp/rate_limit_error.rb
275
+ - lib/basecamp/recording_routing_error.rb
276
+ - lib/basecamp/recording_summary_error.rb
268
277
  - lib/basecamp/request_info.rb
269
278
  - lib/basecamp/request_result.rb
270
279
  - lib/basecamp/security.rb
271
280
  - lib/basecamp/services/authorization_service.rb
272
281
  - lib/basecamp/services/cards_extensions.rb
282
+ - lib/basecamp/services/comments_extensions.rb
273
283
  - lib/basecamp/services/documents_extensions.rb
274
284
  - lib/basecamp/services/merge_safe.rb
285
+ - lib/basecamp/services/recordings_extensions.rb
275
286
  - lib/basecamp/services/schedules_extensions.rb
276
287
  - lib/basecamp/services/todolists_extensions.rb
277
288
  - lib/basecamp/services/todos_extensions.rb
278
289
  - lib/basecamp/static_token_provider.rb
290
+ - lib/basecamp/text.rb
279
291
  - lib/basecamp/token_provider.rb
292
+ - lib/basecamp/unresolved_recording_error.rb
280
293
  - lib/basecamp/usage_error.rb
281
294
  - lib/basecamp/validation_error.rb
282
295
  - lib/basecamp/version.rb