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.
- 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 -9
- 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/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/lineup_service.rb +8 -0
- 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 +165 -9
- data/scripts/generate-services.rb +156 -30
- data/scripts/generate-types.rb +19 -0
- metadata +15 -3
- 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
|
|
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
|
|
|
@@ -11,7 +13,46 @@ require 'time'
|
|
|
11
13
|
|
|
12
14
|
# Extract metadata from OpenAPI spec
|
|
13
15
|
class MetadataExtractor
|
|
14
|
-
|
|
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'] || {}).
|
|
26
|
-
|
|
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
|