basecamp-sdk 0.17.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 +111 -7
  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
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Basecamp
6
+ # Decodes a person's id at the response paths the reference reads it as
7
+ # +types.FlexibleInt64+, keyed by operation id.
8
+ #
9
+ # Go decodes every generated read through +Parse<Op>Response+, and
10
+ # +generated.Person.Id+ is +types.FlexibleInt64+
11
+ # (go/pkg/types/flexible_int64.go:27): an untagged +{"creator":{"id":"7"}}+
12
+ # is 7 there. Ruby returns the parsed Hash, and the normalizer converts only
13
+ # a person carrying +personable_type+ (plus creator/participants on the gauge
14
+ # and notification surfaces), so that id reached the caller as the string
15
+ # "7" (SPEC §10 "Person Ids Off the Wire").
16
+ #
17
+ # The fix is NOT a wider normalizer walk. The same key names hold plain int64
18
+ # ids in the reference (+UpcomingSchedulePerson+, +MyAssignmentAssignee+,
19
+ # +OutOfOfficePerson+, +TemplateLibraryConfirmationPerson+), where a string
20
+ # is a decode error. So the sites come from the generated table in
21
+ # +generated/metadata.json+ ("personIdSites"), which +generate-metadata.rb+
22
+ # selects on the +x-go-type+ marker of +Person.id+ and never on a key name:
23
+ # an operation not in the table, or a path not under it, is never touched.
24
+ #
25
+ # Runs AFTER {Http.normalize_person_ids} on the same body. Idempotent: a
26
+ # second pass finds only integers and leaves them.
27
+ module PersonIdSites
28
+ HINT = "A person id in this response is neither a 64-bit integer nor a string, " \
29
+ "or is a string out of int64 range; the response cannot be read."
30
+
31
+ # Operations whose FOLLOWED pages the reference does not decode as the
32
+ # generated types. Go runs page 1 through Parse<Op>Response (Person.Id is
33
+ # FlexibleInt64, so a null id fails), but decodes each kept item of a
34
+ # followed page into a hand-written type whose Person.ID is a plain int64
35
+ # after the positional normalizer — gauges.go:236-241 (Gauge),
36
+ # gauges.go:307-312 (GaugeNeedle), my_notifications.go:285-305
37
+ # (Notification) — and encoding/json leaves a plain int64 at zero for null.
38
+ # So there a null id reads rather than fails. Go-SDK behaviour, not schema,
39
+ # hence a hand-written list rather than a generated one.
40
+ NULL_ID_READS_ON_FOLLOWED_PAGES = %w[ListGauges ListGaugeNeedles GetBubbleUps].freeze
41
+
42
+ class << self
43
+ # Decodes the person ids at +operation+'s sites in +body+, in place.
44
+ #
45
+ # +body+ is a whole response: a single read, or the FIRST page of a
46
+ # paginated one (the array for a bare-array list, the wrapper object for
47
+ # a wrapped one), which the reference decodes whole through
48
+ # Parse<Op>Response before any cap applies. A followed page goes through
49
+ # {decode_item!} instead.
50
+ #
51
+ # @param body [Object] parsed, normalized JSON
52
+ # @param operation [String, nil] canonical operation id
53
+ # @return [Object] +body+
54
+ # @raise [ApiError] non-retryable, when a site's id cannot be decoded
55
+ def decode!(body, operation)
56
+ paths = operation && table[operation]
57
+ return body unless paths
58
+
59
+ paths.each { |path, array_site| visit(body, path, 0, array_site, operation) }
60
+ body
61
+ end
62
+
63
+ # Decodes ONE item of a FOLLOWED page, at the sites under that item only.
64
+ #
65
+ # The reference never decodes a followed page whole. A bare-array list
66
+ # collects the page as raw items, trims them to the cap and decodes only
67
+ # what it kept (client.go:604-631, followPagination), so the caller
68
+ # decodes each item as it is kept. A wrapped listing decodes only the
69
+ # items under its key — GetPersonProgress unmarshals struct{ Events }
70
+ # and never reads the page's person (timeline.go:444-457) — so the
71
+ # wrapper's other keys are not touched here either.
72
+ #
73
+ # @param item [Object] one parsed, normalized item
74
+ # @param operation [String, nil] canonical operation id
75
+ # @param key [String, nil] the wrapped listing's key; nil for a bare array
76
+ # @return [Object] +item+
77
+ # @raise [ApiError] non-retryable, when a site's id cannot be decoded
78
+ def decode_item!(item, operation, key: nil)
79
+ paths = operation && item_table(operation, key)
80
+ return item unless paths
81
+
82
+ null_reads = NULL_ID_READS_ON_FOLLOWED_PAGES.include?(operation)
83
+ paths.each { |path, array_site| visit(item, path, 0, array_site, operation, null_reads: null_reads) }
84
+ item
85
+ end
86
+
87
+ # {operationId => [[components, array_site], ...]} relative to the whole
88
+ # body, memoized. Benign race: concurrent first loads compute identical
89
+ # values.
90
+ def table
91
+ @table ||= raw_table.transform_values do |paths|
92
+ paths.map { |components| split_site(components) }.freeze
93
+ end.freeze
94
+ end
95
+
96
+ private
97
+
98
+ # {operationId => [components, ...]}, each the full "."-split path.
99
+ def raw_table
100
+ @raw_table ||= JSON.parse(
101
+ File.read(File.join(__dir__, "generated", "metadata.json"), encoding: "UTF-8")
102
+ ).fetch("personIdSites").transform_values do |paths|
103
+ paths.map { |path| (path == "$" ? [] : path.split(".")).freeze }.freeze
104
+ end.freeze
105
+ end
106
+
107
+ # The operation's sites relative to one item: those under "[]" for a
108
+ # bare array, or under "<key>.[]" for a wrapped listing, prefix removed.
109
+ # An item that is itself the person ("[]", "<key>.[]") is the empty path.
110
+ def item_table(operation, key)
111
+ (@item_tables ||= {})[[ operation, key ]] ||= begin
112
+ prefix = key ? [ key, "[]" ] : [ "[]" ]
113
+ (raw_table[operation] || []).filter_map do |components|
114
+ next unless components.first(prefix.length) == prefix
115
+
116
+ split_site(components.drop(prefix.length))
117
+ end.freeze
118
+ end
119
+ end
120
+
121
+ # A path ending in "[]" names an array of people.
122
+ def split_site(components)
123
+ array_site = components.last == "[]"
124
+ [ (array_site ? components[0...-1] : components).freeze, array_site ].freeze
125
+ end
126
+
127
+ # Follows the components; a value of another shape on the way ends the
128
+ # walk with nothing done. Go zero-fills or refuses those as part of its
129
+ # whole-body decode, which this lenient tier does for no field — that
130
+ # residual is not the person id's to close.
131
+ def visit(value, components, index, array_site, operation, null_reads: false)
132
+ if index == components.length
133
+ if array_site
134
+ value.each { |person| decode_person(person, components, operation, null_reads) } if value.is_a?(Array)
135
+ else
136
+ decode_person(value, components, operation, null_reads)
137
+ end
138
+ return
139
+ end
140
+
141
+ component = components[index]
142
+ if component == "[]"
143
+ value.each { |element| visit(element, components, index + 1, array_site, operation, null_reads: null_reads) } if value.is_a?(Array)
144
+ elsif value.is_a?(Hash) && value.key?(component)
145
+ visit(value[component], components, index + 1, array_site, operation, null_reads: null_reads)
146
+ end
147
+ end
148
+
149
+ # FlexibleInt64.UnmarshalJSON on one person's "id", present only: an
150
+ # absent key is the zero value with no call in Go, so it stays absent.
151
+ def decode_person(person, components, operation, null_reads)
152
+ return unless person.is_a?(Hash) && person.key?("id")
153
+
154
+ raw = person["id"]
155
+ # A plain int64 reads null as zero (see NULL_ID_READS_ON_FOLLOWED_PAGES);
156
+ # left as null, the same representation residual as an absent id.
157
+ return if null_reads && raw.nil?
158
+
159
+ # One rule for a person id off the wire: {Ids.person_from_wire} is the
160
+ # flexible decoder — an int64 passes, a string goes through ParseInt
161
+ # (ErrSyntax reads 0, flexible_int64.go:46; ErrRange fails,
162
+ # flexible_int64.go:43), and a float, null, boolean, array, object or
163
+ # out-of-range number fails (json.Number.Int64, flexible_int64.go:57).
164
+ # No system_label: the decoder writes none.
165
+ id = Ids.person_from_wire(raw)
166
+ raise refusal(raw, components, operation) if id.nil?
167
+
168
+ person["id"] = id unless raw.is_a?(Integer)
169
+ end
170
+
171
+ # Names the operation, the site and the value's kind — not the value,
172
+ # which is the response's and travels into logs from here.
173
+ def refusal(raw, components, operation)
174
+ site = components.empty? ? "the response body" : components.join(".")
175
+ shape = case raw
176
+ when String, Integer then "an out-of-range #{raw.class}"
177
+ when nil then "null"
178
+ else "a #{raw.class}"
179
+ end
180
+ ApiError.new("#{operation} returned a person id at #{site} that is #{shape}", hint: HINT, retryable: false)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised for a recording pointer +summarize+ cannot route, before any request
5
+ # is made. Two kinds:
6
+ #
7
+ # * +no_recording_type+ — an event type that names no recording type, which is
8
+ # +boost.*+: the row points at the boost's target and does not carry that
9
+ # target's type. A consumer resolves those from its own record of what it
10
+ # posted, not through +summarize+.
11
+ # * +unknown_recording_type+ — neither the event type nor the recording type
12
+ # names a type in the routing table. The table is a DELIBERATE set, not an
13
+ # exhaustive one (see +RecordingsExtensions::RECORDING_TYPES+), so a type
14
+ # outside it is this error by design, whether or not the SDK has an id-only
15
+ # read for it.
16
+ #
17
+ # +code+ is +usage+: nothing was asked of the API, and the caller's own
18
+ # pointer is what cannot be routed.
19
+ class RecordingRoutingError < RecordingSummaryError
20
+ NO_RECORDING_TYPE = "no_recording_type"
21
+ UNKNOWN_RECORDING_TYPE = "unknown_recording_type"
22
+
23
+ # @return [String] the recording type or event type that could not be routed
24
+ attr_reader :routing_key
25
+
26
+ # @param kind [String] {NO_RECORDING_TYPE} or {UNKNOWN_RECORDING_TYPE}
27
+ # @param routing_key [String, nil] the type string that could not be routed
28
+ def initialize(kind:, routing_key:)
29
+ reason = kind == NO_RECORDING_TYPE ? "event type names no recording type" : "no typed read for recording type"
30
+ super(
31
+ kind: kind,
32
+ code: ErrorCode::USAGE,
33
+ message: "#{reason}: #{routing_key.to_s.inspect}"
34
+ )
35
+ @routing_key = routing_key.to_s
36
+ end
37
+
38
+ # @param routing_key [String, nil]
39
+ # @return [RecordingRoutingError]
40
+ def self.no_recording_type(routing_key)
41
+ new(kind: NO_RECORDING_TYPE, routing_key: routing_key)
42
+ end
43
+
44
+ # @param routing_key [String, nil]
45
+ # @return [RecordingRoutingError]
46
+ def self.unknown_recording_type(routing_key)
47
+ new(kind: UNKNOWN_RECORDING_TYPE, routing_key: routing_key)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Base class for the identities +RecordingsExtensions#summarize+ raises.
5
+ #
6
+ # These are the composite's OWN outcomes, not HTTP answers: "this pointer
7
+ # names no recording type", "this chat line is under no Campfire you can
8
+ # currently see", "discovery could not be carried to a conclusion", "the read
9
+ # came back from another bucket". A consumer matches the CLASS — or {#kind},
10
+ # the same vocabulary the conformance fixture pins — rather than parsing the
11
+ # message or reading a status off it.
12
+ #
13
+ # +code+ stays inside SPEC section 6's closed taxonomy, which is the
14
+ # HTTP-status mapping and has no room for composite identities. Each subclass
15
+ # documents the canonical code it chose and why; the code is the coarse
16
+ # category a CLI exits on, the class is the identity.
17
+ class RecordingSummaryError < Error
18
+ # The composite identity, in the conformance fixture's vocabulary
19
+ # (+no_recording_type+, +unknown_recording_type+, +recording_unresolved+,
20
+ # +campfire_discovery_incomplete+, +bucket_mismatch+).
21
+ # @return [String]
22
+ attr_reader :kind
23
+
24
+ # @param kind [String] the composite identity
25
+ # @param message [String]
26
+ # @param code [String] a canonical {ErrorCode}
27
+ # @param hint [String, nil]
28
+ def initialize(kind:, message:, code:, hint: nil)
29
+ super(code: code, message: message, hint: hint)
30
+ @kind = kind
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Mention-expanding comment writes, prepended onto the generated
6
+ # {CommentsService} (see the +on_load+ hook in +basecamp.rb+).
7
+ #
8
+ # Both methods compose public generated operations — +people.get+ and
9
+ # +comments.create+ — so hooks observe those two wire operations under their
10
+ # own identities, never a synthetic composite (SPEC.md section 18 rule 3).
11
+ module CommentsExtensions
12
+ # Returns content that mentions each of the given people, for posting as a
13
+ # comment — or, since the markup is the same, as a rich-text Campfire line.
14
+ #
15
+ # Every requested id is read through +people.get+ for its
16
+ # +attachable_sgid+ — one read per distinct id, ALWAYS: an sgid already in
17
+ # the content is unsigned and cannot prove the person is mentioned, so it
18
+ # never stands in for the read — and the mentions are placed as
19
+ # {Basecamp::Mentions.with_mentions} places them, which adds nothing for a
20
+ # person whose exact +attachable_sgid+ the content already carries.
21
+ #
22
+ # A person read that fails — an id that is not a person in this account, a
23
+ # 403 — fails the expansion; nothing is posted on a partial mention list.
24
+ # That error is raised unchanged rather than wrapped: in Ruby the error's
25
+ # CLASS and +code+ are the identity a caller matches on, and re-raising a
26
+ # wrapper to carry "which person" in the message would cost both.
27
+ #
28
+ # The rendered mentions round-trip:
29
+ # {Basecamp::Mentions.mentioned_person_ids} on the returned content reports
30
+ # every id passed here, and +recordings.summarize+ reports them on the
31
+ # comment once posted.
32
+ #
33
+ # @param content [String] the comment's rich text
34
+ # @param person_ids [Array<Integer>, nil] people to mention
35
+ # @return [String] the content with the mentions placed
36
+ # @raise [Basecamp::UsageError] on a non-positive person id
37
+ def expand_mentions(content:, person_ids: nil)
38
+ content = require_content(content)
39
+ person_ids = Array(person_ids)
40
+ return content if person_ids.empty?
41
+
42
+ seen = {}
43
+ people = []
44
+ person_ids.each do |person_id|
45
+ id = Ids.integer(person_id, "mention person id")
46
+ raise UsageError.new("invalid mention person id #{person_id.inspect}") unless id.positive?
47
+ next if seen.key?(id)
48
+
49
+ seen[id] = true
50
+ # Collected with an explicit push rather than by filter_map. That
51
+ # dropped every FALSY return as well as the duplicates it was meant
52
+ # to skip, so a people read answering JSON null or false removed the
53
+ # mention and let the comment post without it — the exact opposite of
54
+ # what this method's own doc promises, and a defect a comment saying
55
+ # "nothing is posted on a partial mention list" made harder to see.
56
+ people << read_person(id, @client.people.get(person_id: id))
57
+ end
58
+
59
+ Mentions.with_mentions(content, people)
60
+ end
61
+
62
+ # Creates a comment on a recording whose content mentions the given people:
63
+ # {#expand_mentions}, then the generated +create+. The mention reads happen
64
+ # before the write, so a failed lookup posts nothing.
65
+ #
66
+ # @param recording_id [Integer] the recording to comment on
67
+ # @param content [String] the comment's rich text
68
+ # @param person_ids [Array<Integer>, nil] people to mention
69
+ # @return [Hash] the created comment
70
+ # @raise [Basecamp::UsageError] when the content is empty, or on a
71
+ # non-positive person id
72
+ def create_with_mentions(recording_id:, content:, person_ids: nil)
73
+ # Checked as a STRING, not as `content.to_s.empty?`. That validated a
74
+ # coerced copy and then handed the ORIGINAL to create, so a Hash was
75
+ # posted as a JSON object when no mentions were requested and was
76
+ # rendered into Ruby text by with_mentions when they were — two
77
+ # different wire bodies for one argument, neither of them the documented
78
+ # String. The reference takes `content string`, so a non-string cannot
79
+ # reach it at all; this is the equivalent refusal in a tier that has to
80
+ # make it explicitly.
81
+ content = require_content(content)
82
+ raise UsageError.new("comment content is required") if content.empty?
83
+
84
+ # Typed, not bounded. Ruby has no int64 to receive this in, so a value
85
+ # that is not an id at all is refused here — but the reference validates
86
+ # the CONTENT only and sends whatever id it is given, so a zero or a
87
+ # negative goes to the wire and comes back a 404, as it does there.
88
+ recording_id = Ids.integer(recording_id, "recording id")
89
+
90
+ create(recording_id: recording_id, content: expand_mentions(content: content, person_ids: person_ids))
91
+ end
92
+
93
+ private
94
+
95
+ # The comment's rich text, which has to be a String before anything is
96
+ # measured, written or posted.
97
+ def require_content(content)
98
+ return content if content.is_a?(String)
99
+
100
+ raise UsageError.new("comment content must be a string, got #{content.class}")
101
+ end
102
+
103
+ # The people read's body, which has to be an object before a mention is
104
+ # built from it.
105
+ #
106
+ # The reference decodes into a typed Person, so a scalar, an array or a
107
+ # null body fails the read there and never reaches the write. Here they
108
+ # reached {Basecamp::Mentions.with_mentions}, which would have gone on to
109
+ # index them.
110
+ def read_person(id, person)
111
+ return person if person.is_a?(Hash)
112
+
113
+ raise MergeSafe.malformed(
114
+ "the read for person #{id} returned #{MergeSafe.describe(person)}, not a person object",
115
+ "Every requested mention is resolved before the comment is written, so a person that " \
116
+ "cannot be read fails the whole write rather than posting without that mention."
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
@@ -202,12 +202,25 @@ module Basecamp
202
202
  # replaces (<tt>(body[key] || []).map { |p| p["id"] }</tt>) has three ways
203
203
  # to go wrong on malformed data: a non-Array has no +map+ (or, for a Hash,
204
204
  # maps over its pairs), a non-Hash element raises TypeError on +[]+, and a
205
- # non-Integer +id+ rides through verbatim into the full-replace PUT — the
205
+ # wrong-typed +id+ rides through verbatim into the full-replace PUT — the
206
206
  # same corruption as a wrong-typed string, one level down.
207
207
  #
208
- # +true+/+false+ are refused explicitly: they are not Integers in Ruby, so
209
- # +is_a?(Integer)+ already rejects them, but the message names them as ids
210
- # rather than as an unexplained type error.
208
+ # What it must NOT do is refuse an id the reference ACCEPTS, which is the
209
+ # other half of the same defect and the one this guard had (#913). A
210
+ # person's id is the single field in the generated model the reference
211
+ # decodes flexibly, and +fieldsFromTodo+ appends what that produced with
212
+ # no filter of any kind — 0 included.
213
+ #
214
+ # It must not lean on {Basecamp::Http}'s pre-decode normalizer having
215
+ # reached the person first. Which people that walk finds is the walk's
216
+ # rule — the reference's positional pass covers +creator+ and
217
+ # +participants+ and nothing else — and a person it did not find arrives
218
+ # here exactly as BC3 sent it. How often that is a String the fixtures
219
+ # cannot say (none of the person objects in +spec/fixtures+ carries one);
220
+ # what they show is that the marker the +personable_type+ pass keys on is
221
+ # often missing, on 3 of 7 +assignees+ people. The rule itself lives in
222
+ # {Basecamp::Ids.person_from_wire}, where the read path already reads one,
223
+ # so the two cannot drift.
211
224
  def writable_id_list(body, key, record:, escape:)
212
225
  value = body[key]
213
226
  return [] if value.nil?
@@ -225,7 +238,23 @@ module Basecamp
225
238
  end
226
239
 
227
240
  # Validates one element of an id-list field and returns its id.
241
+ #
242
+ # Three element shapes are accepted that a strict reading would refuse,
243
+ # each because the reference accepts it:
244
+ #
245
+ # * a +nil+ element is 0 — a JSON null in the array decodes to the
246
+ # reference's zero Person, whose id is 0 and is appended like any other;
247
+ # * an element with NO "id" is 0, the zero value, with no error;
248
+ # * an element whose "id" is a String is whatever ParseInt makes of it,
249
+ # and a String that is not a number at all is the system actor 0.
250
+ #
251
+ # An explicit <tt>"id" => nil</tt> is NOT the same as an absent one and
252
+ # still fails the read, which is why the absent case is tested with
253
+ # +key?+ rather than by asking for the value: see
254
+ # {Basecamp::Ids.person_from_wire} for the decoder line that splits them.
228
255
  def person_id(element, index, key, record:, escape:)
256
+ return 0 if element.nil?
257
+
229
258
  unless element.is_a?(Hash)
230
259
  raise malformed(
231
260
  "#{record} field #{key.inspect}[#{index}] is not an object: #{describe(element)}",
@@ -233,17 +262,12 @@ module Basecamp
233
262
  )
234
263
  end
235
264
 
236
- id = element["id"]
237
- if id.nil?
238
- raise malformed(
239
- "#{record} field #{key.inspect}[#{index}] has no \"id\"",
240
- format(RESEND_HINT, escape: escape)
241
- )
242
- end
265
+ return 0 unless element.key?("id")
243
266
 
244
- unless id.is_a?(Integer)
267
+ id = Ids.person_from_wire(element["id"])
268
+ if id.nil?
245
269
  raise malformed(
246
- "#{record} field #{key.inspect}[#{index}].id is not an integer: #{describe(id)}",
270
+ "#{record} field #{key.inspect}[#{index}].id is not a person id: #{describe(element["id"])}",
247
271
  format(RESEND_HINT, escape: escape)
248
272
  )
249
273
  end