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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/basecamp/api_error.rb +5 -3
- data/lib/basecamp/bucket_mismatch_error.rb +41 -0
- data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
- data/lib/basecamp/campfire_index.rb +398 -0
- data/lib/basecamp/client.rb +47 -4
- data/lib/basecamp/generated/metadata.json +728 -1
- data/lib/basecamp/generated/services/account_service.rb +2 -2
- data/lib/basecamp/generated/services/attachments_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +1 -1
- data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
- data/lib/basecamp/generated/services/boosts_service.rb +3 -3
- data/lib/basecamp/generated/services/calendars_service.rb +2 -2
- data/lib/basecamp/generated/services/campfires_service.rb +7 -7
- data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
- data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
- data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
- data/lib/basecamp/generated/services/cards_service.rb +3 -3
- data/lib/basecamp/generated/services/checkins_service.rb +9 -9
- data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
- data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
- data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
- data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
- data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
- data/lib/basecamp/generated/services/comments_service.rb +3 -3
- data/lib/basecamp/generated/services/documents_service.rb +3 -3
- data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
- data/lib/basecamp/generated/services/everything_service.rb +2 -2
- data/lib/basecamp/generated/services/folders_service.rb +4 -4
- data/lib/basecamp/generated/services/forwards_service.rb +3 -3
- data/lib/basecamp/generated/services/gauges_service.rb +10 -6
- data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
- data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
- data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
- data/lib/basecamp/generated/services/message_types_service.rb +3 -3
- data/lib/basecamp/generated/services/messages_service.rb +3 -3
- data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
- data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
- data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
- data/lib/basecamp/generated/services/people_service.rb +11 -11
- data/lib/basecamp/generated/services/projects_service.rb +4 -4
- data/lib/basecamp/generated/services/recordings_service.rb +1 -1
- data/lib/basecamp/generated/services/reports_service.rb +3 -3
- data/lib/basecamp/generated/services/schedules_service.rb +6 -6
- data/lib/basecamp/generated/services/search_service.rb +1 -1
- data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
- data/lib/basecamp/generated/services/templates_service.rb +8 -8
- data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
- data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
- data/lib/basecamp/generated/services/todolists_service.rb +3 -3
- data/lib/basecamp/generated/services/todos_service.rb +4 -4
- data/lib/basecamp/generated/services/todosets_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +3 -3
- data/lib/basecamp/generated/services/uploads_service.rb +4 -4
- data/lib/basecamp/generated/services/vaults_service.rb +3 -3
- data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
- data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
- data/lib/basecamp/generated/types.rb +108 -6
- data/lib/basecamp/http.rb +383 -60
- data/lib/basecamp/ids.rb +277 -0
- data/lib/basecamp/mentions.rb +1119 -0
- data/lib/basecamp/person_id_sites.rb +184 -0
- data/lib/basecamp/recording_routing_error.rb +50 -0
- data/lib/basecamp/recording_summary_error.rb +33 -0
- data/lib/basecamp/services/comments_extensions.rb +121 -0
- data/lib/basecamp/services/merge_safe.rb +37 -13
- data/lib/basecamp/services/recordings_extensions.rb +1305 -0
- data/lib/basecamp/services/todolists_extensions.rb +4 -3
- data/lib/basecamp/text.rb +94 -0
- data/lib/basecamp/unresolved_recording_error.rb +68 -0
- data/lib/basecamp/version.rb +1 -1
- data/lib/basecamp/webhooks/event.rb +4 -1
- data/lib/basecamp.rb +22 -4
- data/scripts/generate-metadata.rb +75 -2
- data/scripts/generate-services.rb +35 -8
- data/scripts/generate-types.rb +19 -0
- 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
|
|
184
|
-
#
|
|
185
|
-
# validating layer that would retire this guard
|
|
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
|
data/lib/basecamp/version.rb
CHANGED
|
@@ -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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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})
|
|
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})
|
|
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})
|
|
955
|
+
lines << " http_#{http_method}(#{path_expr}#{operation_arg})#{json_call}"
|
|
929
956
|
end
|
|
930
957
|
|
|
931
958
|
lines
|
data/scripts/generate-types.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|