basecamp-sdk 0.18.0 → 0.20.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 (79) 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 -9
  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/bookmarks_service.rb +2 -2
  12. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  13. data/lib/basecamp/generated/services/calendars_service.rb +2 -2
  14. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  15. data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
  16. data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
  17. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  18. data/lib/basecamp/generated/services/cards_service.rb +3 -3
  19. data/lib/basecamp/generated/services/checkins_service.rb +9 -9
  20. data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
  21. data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
  22. data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
  23. data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
  24. data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
  25. data/lib/basecamp/generated/services/comments_service.rb +3 -3
  26. data/lib/basecamp/generated/services/documents_service.rb +3 -3
  27. data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
  28. data/lib/basecamp/generated/services/everything_service.rb +2 -2
  29. data/lib/basecamp/generated/services/folders_service.rb +4 -4
  30. data/lib/basecamp/generated/services/forwards_service.rb +3 -3
  31. data/lib/basecamp/generated/services/gauges_service.rb +10 -6
  32. data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
  33. data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
  34. data/lib/basecamp/generated/services/lineup_service.rb +8 -0
  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 +165 -9
  76. data/scripts/generate-services.rb +156 -30
  77. data/scripts/generate-types.rb +19 -0
  78. metadata +15 -3
  79. data/lib/basecamp/generated/services/automation_service.rb +0 -19
@@ -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.20.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
 
@@ -11,7 +13,46 @@ require 'time'
11
13
 
12
14
  # Extract metadata from OpenAPI spec
13
15
  class MetadataExtractor
14
- METHODS = %w[get post put patch delete].freeze
16
+ # A Path Item Object is read by EXCLUSION: its non-operation fields are a
17
+ # closed, spec-defined set, its extensions are `x-` prefixed, and every other
18
+ # field is an operation. A verb list here dropped an operation's metadata in
19
+ # silence for any method outside the five it knew (#925). Nothing in this
20
+ # file is verb-specific — metadata is keyed by operationId — so the walk is
21
+ # total and no verb bound belongs here.
22
+ NON_OPERATION_FIELDS = %w[summary description servers parameters].freeze
23
+
24
+ # Membership is decided above, by exclusion. The shared declaration every
25
+ # generator reads (spec/generated-verbs.json) is ORDERED, and that order is
26
+ # reused here to keep metadata.json byte-stable. Order is not membership: a
27
+ # verb absent from the declaration sorts deterministically to the end by name
28
+ # and is still extracted, because nothing in this file is verb-specific.
29
+ # The self-test points this at a crafted declaration to prove the bound is
30
+ # sourced from the shared file rather than a private literal; production runs
31
+ # never set it.
32
+ GENERATED_VERBS_FILE = ENV.fetch(
33
+ 'BASECAMP_GENERATED_VERBS', File.expand_path('../../spec/generated-verbs.json', __dir__)
34
+ )
35
+
36
+ METHOD_ORDER = begin
37
+ verbs = JSON.parse(File.read(GENERATED_VERBS_FILE, encoding: 'UTF-8'))['verbs']
38
+ # An HTTP method is a TOKEN, so the rule is a positive character class rather
39
+ # than a blankness predicate: `[a-z]+`, identical in all six loaders. Two
40
+ # review rounds chased "blank" across languages — Kotlin and Rust rejected a
41
+ # space while the others accepted it, then Ruby's ASCII `strip` accepted a
42
+ # non-breaking space the Unicode-aware ones rejected — and the next
43
+ # disagreement was guaranteed, because every language defines whitespace
44
+ # differently (Java's Character.isWhitespace excludes U+00A0; Rust's
45
+ # char::is_whitespace includes it; Ruby's String#strip is ASCII-only). A
46
+ # closed positive rule has no such seam: "", " ", "\u00a0", "GET" and 1 are
47
+ # all rejected the same way everywhere.
48
+ unless verbs.is_a?(Array) && !verbs.empty? && verbs.all? { |v| v.is_a?(String) && v.match?(/\A[a-z]+\z/) }
49
+ abort "Error: #{GENERATED_VERBS_FILE} must declare a non-empty `verbs` array of lowercase " \
50
+ 'ASCII method names (/\A[a-z]+\z/).'
51
+ end
52
+ verbs.freeze
53
+ rescue Errno::ENOENT, JSON::ParserError => e
54
+ abort "Error: cannot read the generated-verb declaration #{GENERATED_VERBS_FILE}: #{e.message}"
55
+ end
15
56
 
16
57
  def initialize(openapi_path)
17
58
  # Read as UTF-8 regardless of process locale (LC_ALL=C would otherwise read
@@ -22,13 +63,9 @@ class MetadataExtractor
22
63
  def extract
23
64
  operations = {}
24
65
 
25
- (@openapi['paths'] || {}).each_value do |path_item|
26
- METHODS.each do |method|
27
- operation = path_item[method]
28
- next unless operation
29
-
66
+ (@openapi['paths'] || {}).each do |path, path_item|
67
+ each_operation(path, path_item) do |_method, operation|
30
68
  operation_id = operation['operationId']
31
- next unless operation_id
32
69
 
33
70
  metadata = extract_operation_metadata(operation)
34
71
  operations[operation_id] = metadata if metadata.any?
@@ -39,10 +76,130 @@ class MetadataExtractor
39
76
  '$schema' => 'https://basecamp.com/schemas/sdk-metadata.json',
40
77
  'version' => '1.0.0',
41
78
  'generated' => Time.now.utc.iso8601,
42
- 'operations' => operations
79
+ 'operations' => operations,
80
+ 'personIdSites' => person_id_sites
43
81
  }
44
82
  end
45
83
 
84
+ # The Go-type marker that makes a schema's +id+ a flexible person id. Go
85
+ # generates +Person.Id+ as +types.FlexibleInt64+ from exactly this extension,
86
+ # and it is the only field that carries it, so the table is selected on the
87
+ # marker and never on a key name: +UpcomingSchedulePerson+,
88
+ # +MyAssignmentAssignee+, +OutOfOfficePerson+ and
89
+ # +TemplateLibraryConfirmationPerson+ sit under the same key names with a
90
+ # plain int64 id, where a string is a decode error in the reference.
91
+ FLEXIBLE_PERSON_ID = 'types.FlexibleInt64'
92
+
93
+ # {operationId => [path, ...]} for every operation whose 2xx JSON response
94
+ # reaches a schema whose +id+ is {FLEXIBLE_PERSON_ID}. A path is its
95
+ # components joined by "."; "[]" is an array element, and a path that ENDS
96
+ # in "[]" names an array of people. "$" is the body itself.
97
+ def person_id_sites
98
+ sites = {}
99
+ (@openapi['paths'] || {}).each do |path, path_item|
100
+ each_operation(path, path_item) do |_method, operation|
101
+ paths = (operation['responses'] || {}).flat_map do |code, response|
102
+ next [] unless code.to_s.start_with?('2')
103
+
104
+ response = resolve_response(response)
105
+ (response['content'] || {}).values.flat_map do |media|
106
+ found = []
107
+ walk_person_sites(media['schema'] || {}, [], [], found)
108
+ found
109
+ end
110
+ end.uniq.sort
111
+ sites[operation['operationId']] = paths if paths.any?
112
+ end
113
+ end
114
+ sites.sort.to_h
115
+ end
116
+
117
+ def resolve_response(response)
118
+ return response unless response['$ref']
119
+
120
+ @openapi.dig('components', 'responses', response['$ref'].split('/').last)
121
+ end
122
+
123
+ # Walks $ref, allOf/oneOf/anyOf, items and properties. A schema already on
124
+ # the $ref stack is a cycle and is not re-entered. additionalProperties has
125
+ # no runtime path syntax, so a person reached through one fails generation
126
+ # rather than being silently left out of the table.
127
+ def walk_person_sites(schema, path, stack, found)
128
+ if (ref = schema['$ref'])
129
+ name = ref.split('/').last
130
+ return if stack.include?(name)
131
+
132
+ target = @openapi.dig('components', 'schemas', name)
133
+ found << (path.empty? ? '$' : path.join('.')) if target.dig('properties', 'id', 'x-go-type') == FLEXIBLE_PERSON_ID
134
+ walk_person_sites(target, path, stack + [ name ], found)
135
+ return
136
+ end
137
+
138
+ %w[allOf oneOf anyOf].each do |key|
139
+ (schema[key] || []).each { |sub| walk_person_sites(sub, path, stack, found) }
140
+ end
141
+ walk_person_sites(schema['items'] || {}, path + [ '[]' ], stack, found) if schema['type'] == 'array' || schema['items']
142
+ (schema['properties'] || {}).each { |key, sub| walk_person_sites(sub, path + [ key ], stack, found) }
143
+
144
+ extra = schema['additionalProperties']
145
+ return unless extra.is_a?(Hash)
146
+
147
+ nested = []
148
+ walk_person_sites(extra, path + [ '{}' ], stack, nested)
149
+ raise "person id site under additionalProperties at #{nested.first} has no runtime path syntax" if nested.any?
150
+ end
151
+
152
+ # Yields [verb, operation] for every operation in a path item. A field that is
153
+ # neither a known non-operation field nor a readable operation object stops
154
+ # the run by name rather than being stepped over.
155
+ def each_operation(path, path_item)
156
+ unless path_item.is_a?(Hash)
157
+ abort "Error: openapi.json path #{path} is a #{path_item.class}, not a path item object."
158
+ end
159
+
160
+ if path_item.key?('$ref')
161
+ abort "Error: openapi.json path #{path} is a $ref to #{path_item['$ref'].inspect}. " \
162
+ 'This extractor cannot resolve a path-item reference, and skipping it would leave ' \
163
+ 'every operation behind it without runtime metadata.'
164
+ end
165
+
166
+ # OpenAPI 3.2's `additionalOperations` is a MAP of method to Operation, not an
167
+ # operation. Read as one it carries no operationId, so it would drop every
168
+ # operation inside it without saying so. Refuse by name until the walk learns
169
+ # the map shape.
170
+ if path_item.key?('additionalOperations')
171
+ abort "Error: openapi.json path #{path} declares `additionalOperations`, which OpenAPI 3.2 " \
172
+ 'defines as a map of method to Operation. This walk reads a path-item field as a ' \
173
+ 'single operation, so it would drop every operation inside it. Teach the walk the map ' \
174
+ 'shape, or take the field out of the spec.'
175
+ end
176
+
177
+ fields = path_item.keys.reject { |f| NON_OPERATION_FIELDS.include?(f) || f.start_with?('x-') }
178
+ fields.sort_by! { |f| [ METHOD_ORDER.index(f) || METHOD_ORDER.length, f ] }
179
+
180
+ fields.each do |field|
181
+ operation = path_item[field]
182
+
183
+ unless operation.is_a?(Hash)
184
+ abort "Error: openapi.json path #{path} field #{field.inspect} is a #{operation.class}, " \
185
+ 'which is neither a known non-operation field nor an operation object. If a later ' \
186
+ 'OpenAPI version added it, add it to NON_OPERATION_FIELDS with a reason.'
187
+ end
188
+
189
+ # An operation has to be IDENTIFIABLE. OpenAPI lets operationId be omitted,
190
+ # and every walker here used to step over one that was — a silent drop of a
191
+ # real operation, which is #925 wearing a different field.
192
+ op_id = operation['operationId']
193
+ unless op_id.is_a?(String) && !op_id.empty?
194
+ abort "Error: openapi.json declares #{field.upcase} #{path} with no operationId. " \
195
+ 'Everything downstream is keyed by it, and skipping the operation would drop it ' \
196
+ 'from the SDK in silence.'
197
+ end
198
+
199
+ yield field, operation
200
+ end
201
+ end
202
+
46
203
  private
47
204
 
48
205
  def extract_operation_metadata(operation)
@@ -62,7 +219,6 @@ class MetadataExtractor
62
219
  if (pagination = operation['x-basecamp-pagination'])
63
220
  metadata['pagination'] = {
64
221
  'style' => pagination['style'],
65
- 'pageParam' => pagination['pageParam'],
66
222
  'totalCountHeader' => pagination['totalCountHeader'],
67
223
  'maxPageSize' => pagination['maxPageSize']
68
224
  }.compact