basecamp-sdk 0.12.0 → 0.14.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 +128 -8
- data/lib/basecamp/client.rb +35 -11
- data/lib/basecamp/config.rb +69 -0
- data/lib/basecamp/error.rb +1 -0
- data/lib/basecamp/error_code.rb +1 -0
- data/lib/basecamp/exit_code.rb +1 -0
- data/lib/basecamp/generated/metadata.json +291 -141
- data/lib/basecamp/generated/services/base_service.rb +37 -16
- data/lib/basecamp/generated/services/bookmarks_service.rb +5 -4
- data/lib/basecamp/generated/services/boosts_service.rb +12 -6
- data/lib/basecamp/generated/services/campfires_service.rb +54 -41
- data/lib/basecamp/generated/services/cards_service.rb +6 -3
- data/lib/basecamp/generated/services/checkins_service.rb +28 -15
- data/lib/basecamp/generated/services/client_approvals_service.rb +8 -5
- data/lib/basecamp/generated/services/client_correspondences_service.rb +8 -5
- data/lib/basecamp/generated/services/client_replies_service.rb +12 -7
- data/lib/basecamp/generated/services/cloud_files_service.rb +57 -0
- data/lib/basecamp/generated/services/comments_service.rb +6 -3
- data/lib/basecamp/generated/services/documents_service.rb +9 -6
- data/lib/basecamp/generated/services/drafts_service.rb +5 -4
- data/lib/basecamp/generated/services/events_service.rb +6 -3
- data/lib/basecamp/generated/services/everything_service.rb +70 -56
- data/lib/basecamp/generated/services/folders_service.rb +62 -0
- data/lib/basecamp/generated/services/forwards_service.rb +12 -17
- data/lib/basecamp/generated/services/gauges_service.rb +12 -7
- data/lib/basecamp/generated/services/google_documents_service.rb +61 -0
- data/lib/basecamp/generated/services/message_types_service.rb +4 -3
- data/lib/basecamp/generated/services/messages_service.rb +6 -4
- data/lib/basecamp/generated/services/my_notes_service.rb +1 -1
- data/lib/basecamp/generated/services/my_notifications_service.rb +8 -5
- data/lib/basecamp/generated/services/people_service.rb +17 -10
- data/lib/basecamp/generated/services/projects_service.rb +26 -4
- data/lib/basecamp/generated/services/recordings_service.rb +6 -13
- data/lib/basecamp/generated/services/reports_service.rb +15 -9
- data/lib/basecamp/generated/services/schedules_service.rb +90 -16
- data/lib/basecamp/generated/services/search_service.rb +6 -4
- data/lib/basecamp/generated/services/templates_service.rb +6 -4
- data/lib/basecamp/generated/services/timeline_service.rb +6 -3
- data/lib/basecamp/generated/services/timesheets_service.rb +22 -8
- data/lib/basecamp/generated/services/todolist_groups_service.rb +7 -4
- data/lib/basecamp/generated/services/todolists_service.rb +11 -9
- data/lib/basecamp/generated/services/todos_service.rb +6 -14
- data/lib/basecamp/generated/services/uploads_service.rb +28 -6
- data/lib/basecamp/generated/services/vaults_service.rb +6 -3
- data/lib/basecamp/generated/services/webhooks_service.rb +4 -3
- data/lib/basecamp/generated/types.rb +603 -139
- data/lib/basecamp/http.rb +352 -163
- data/lib/basecamp/limit_exceeded_error.rb +22 -0
- data/lib/basecamp/list_enumerator.rb +29 -0
- data/lib/basecamp/list_meta.rb +44 -0
- data/lib/basecamp/services/authorization_service.rb +11 -2
- data/lib/basecamp/services/cards_extensions.rb +35 -27
- data/lib/basecamp/services/documents_extensions.rb +136 -0
- data/lib/basecamp/services/merge_safe.rb +255 -0
- data/lib/basecamp/services/schedules_extensions.rb +354 -0
- data/lib/basecamp/services/todolists_extensions.rb +274 -0
- data/lib/basecamp/services/todos_extensions.rb +22 -6
- data/lib/basecamp/validation_error.rb +11 -1
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +98 -4
- data/scripts/generate-metadata.rb +3 -1
- data/scripts/generate-services.rb +78 -27
- data/scripts/generate-types.rb +4 -2
- data/scripts/go_type_spellings.rb +26 -0
- metadata +13 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Raised when an account limit blocks the request (HTTP 507) — file storage
|
|
5
|
+
# exhausted, or a webhook ceiling reached.
|
|
6
|
+
#
|
|
7
|
+
# Never retryable: no amount of backoff frees storage or raises a plan limit.
|
|
8
|
+
# That is the whole reason this is not an ApiError, which a 507 would
|
|
9
|
+
# otherwise become through the 5xx catch-all.
|
|
10
|
+
class LimitExceededError < Error
|
|
11
|
+
def initialize(message = "Account limit reached", hint: nil, cause: nil)
|
|
12
|
+
super(
|
|
13
|
+
code: ErrorCode::LIMIT_EXCEEDED,
|
|
14
|
+
message: message,
|
|
15
|
+
hint: hint,
|
|
16
|
+
http_status: 507,
|
|
17
|
+
retryable: false,
|
|
18
|
+
cause: cause
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# A lazy Enumerator over paginated items that carries pagination metadata.
|
|
5
|
+
#
|
|
6
|
+
# Behaves exactly like the plain Enumerator it replaces — each/next/peek/
|
|
7
|
+
# take/first/lazy all work, and pages beyond the first are fetched only as
|
|
8
|
+
# iteration demands them — while additionally exposing {#meta}.
|
|
9
|
+
#
|
|
10
|
+
# The metadata object is shared with the paginator: meta.total_count is
|
|
11
|
+
# populated from the eagerly fetched first page, and meta.truncated is
|
|
12
|
+
# finalized by consuming the enumeration (see {ListMeta}).
|
|
13
|
+
#
|
|
14
|
+
# Re-enumerating restarts pagination: the eagerly fetched first page is
|
|
15
|
+
# served from memory on the first pass only, and later passes refetch
|
|
16
|
+
# every page for a consistent snapshot, refreshing meta from their own
|
|
17
|
+
# first-page response — metadata always describes the most recent
|
|
18
|
+
# traversal.
|
|
19
|
+
class ListEnumerator < Enumerator
|
|
20
|
+
# @return [ListMeta] pagination metadata
|
|
21
|
+
attr_reader :meta
|
|
22
|
+
|
|
23
|
+
# @param meta [ListMeta] metadata shared with the producing paginator
|
|
24
|
+
def initialize(meta, &block)
|
|
25
|
+
@meta = meta
|
|
26
|
+
super(&block)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Pagination metadata carried by {ListEnumerator}.
|
|
5
|
+
#
|
|
6
|
+
# total_count comes from the X-Total-Count header on the first page (0 when
|
|
7
|
+
# absent) and is available as soon as the list call returns, because the
|
|
8
|
+
# first page is fetched eagerly.
|
|
9
|
+
#
|
|
10
|
+
# truncated starts false and flips to true when enumeration discovers that
|
|
11
|
+
# items beyond those yielded were available: items dropped by a max_items
|
|
12
|
+
# cap, or a next Link left unfetched when the max_items or max_pages cap
|
|
13
|
+
# stopped pagination. It is final only once enumeration completes; landing
|
|
14
|
+
# exactly on the final item is not truncation.
|
|
15
|
+
class ListMeta
|
|
16
|
+
# @return [Integer] total item count from X-Total-Count (0 if absent)
|
|
17
|
+
attr_reader :total_count
|
|
18
|
+
|
|
19
|
+
# @return [Boolean] whether items beyond those yielded were available
|
|
20
|
+
attr_reader :truncated
|
|
21
|
+
|
|
22
|
+
alias truncated? truncated
|
|
23
|
+
|
|
24
|
+
# @param total_count [Integer] value of the X-Total-Count header
|
|
25
|
+
def initialize(total_count: 0)
|
|
26
|
+
@total_count = total_count
|
|
27
|
+
@truncated = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Records that truncation was discovered during enumeration.
|
|
31
|
+
# @api private
|
|
32
|
+
def mark_truncated!
|
|
33
|
+
@truncated = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Re-initializes the metadata when a traversal restarts, so it describes
|
|
37
|
+
# the restarted pass's own snapshot rather than a previous traversal's.
|
|
38
|
+
# @api private
|
|
39
|
+
def restart!(total_count:)
|
|
40
|
+
@total_count = total_count
|
|
41
|
+
@truncated = false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -5,11 +5,20 @@ module Basecamp
|
|
|
5
5
|
# Service for authorization operations.
|
|
6
6
|
# This is the only service that doesn't require an account context.
|
|
7
7
|
#
|
|
8
|
+
# The document's shape depends on which issuer served it. Discovery selects a
|
|
9
|
+
# BC5 issuer whenever one is advertised, and a BC5 issuer serves its *own*
|
|
10
|
+
# document (+app/views/api/authorizations/show.json.jbuilder+), which is not
|
|
11
|
+
# Launchpad's: it carries +identity.id+ and nothing else of the identity, no
|
|
12
|
+
# +product+ or +app_href+ on accounts, an RFC 8707 +resource+ indicator
|
|
13
|
+
# instead, a top-level +scope+ for BC3-issued tokens, and +expires_at+ as
|
|
14
|
+
# integer epoch seconds rather than ISO-8601. Only +identity.id+,
|
|
15
|
+
# +accounts[].id+, +accounts[].name+ and +accounts[].href+ are common to both.
|
|
16
|
+
#
|
|
8
17
|
# @example Get authorization info
|
|
9
18
|
# auth = client.authorization.get
|
|
10
|
-
# puts "Identity: #{auth["identity"]["
|
|
19
|
+
# puts "Identity: #{auth["identity"]["id"]}"
|
|
11
20
|
# auth["accounts"].each do |account|
|
|
12
|
-
# puts "Account: #{account["name"]} (#{account["
|
|
21
|
+
# puts "Account: #{account["name"]} (#{account["href"]})"
|
|
13
22
|
# end
|
|
14
23
|
class AuthorizationService < BaseService
|
|
15
24
|
# Gets authorization information for the current user.
|
|
@@ -2,37 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
module Basecamp
|
|
4
4
|
module Services
|
|
5
|
-
#
|
|
5
|
+
# Tri-state +due_on+ for card updates, prepended onto the generated
|
|
6
6
|
# {CardsService} (see the +on_load+ hook in +basecamp.rb+).
|
|
7
7
|
#
|
|
8
|
-
# BC3
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# {
|
|
8
|
+
# BC3's card controller is presence-aware on the JSON representation
|
|
9
|
+
# (+kanban/cards_controller.rb+, basecamp/bc3#12521): +card_update_params+
|
|
10
|
+
# is plain +card_params+, so an update writes exactly the keys the body
|
|
11
|
+
# carries. An omitted +due_on+ leaves the card's due date UNCHANGED; an
|
|
12
|
+
# explicit <tt>""</tt> (or +null+) clears it. The
|
|
13
|
+
# <tt>{ due_on: nil }.merge(card_params)</tt> default survives only for the
|
|
14
|
+
# HTML/turbo_stream web forms, which post every field on every submit.
|
|
14
15
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
16
|
+
# A clear therefore has to be *stated*, never encoded as an omission —
|
|
17
|
+
# omitting +due_on+ to clear it is a silent no-op. <tt>""</tt> is the
|
|
18
|
+
# spelling that travels: JSON +null+ cannot reach the wire from here at
|
|
19
|
+
# all, because +compact_params+ is +kwargs.compact+ and drops nils (SPEC
|
|
20
|
+
# section 18 body compaction). Rails casts the blank string to nil on the
|
|
21
|
+
# date column, so <tt>""</tt> is what a clear looks like end to end.
|
|
17
22
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
23
|
+
# There is no read-before-write. An earlier version GET the card and resent
|
|
24
|
+
# its due date, because the server then nil'd an unmentioned one and a
|
|
25
|
+
# sparse PUT was destructive. Presence-awareness removed the hazard the
|
|
26
|
+
# extra round-trip covered, and with it the race the round-trip opened
|
|
27
|
+
# between the read and the write. Every case is a single PUT.
|
|
21
28
|
module CardsExtensions
|
|
22
|
-
# Updates a card
|
|
29
|
+
# Updates a card, addressing only what the caller named.
|
|
23
30
|
#
|
|
24
|
-
# +due_on+ is tri-state
|
|
31
|
+
# +due_on+ is tri-state:
|
|
25
32
|
#
|
|
26
|
-
# * +nil+ (
|
|
27
|
-
#
|
|
33
|
+
# * +nil+ (unaddressed) — no +due_on+ key is sent; BC3 leaves the current
|
|
34
|
+
# due date alone
|
|
35
|
+
# * <tt>""</tt> — a stated clear, sent as <tt>""</tt>
|
|
28
36
|
# * a date — the due date is set
|
|
29
37
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
38
|
+
# Every other argument is plain send-when-set: +nil+ leaves the field off
|
|
39
|
+
# the body, and BC3 leaves the stored value untouched.
|
|
32
40
|
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
41
|
+
# This is the same single PUT as {#update_verbatim}, which stays as the
|
|
42
|
+
# unnormalised path; +update+ differs only in mapping an empty +due_on+ to
|
|
43
|
+
# the <tt>""</tt> the server reads as a clear.
|
|
36
44
|
#
|
|
37
45
|
# @param card_id [Integer] card id
|
|
38
46
|
# @param title [String, nil] new title (nil = keep current)
|
|
@@ -43,13 +51,13 @@ module Basecamp
|
|
|
43
51
|
def update(card_id:, title: nil, content: nil, due_on: nil, assignee_ids: nil)
|
|
44
52
|
resolved_due_on =
|
|
45
53
|
if due_on.nil?
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
# Clearing is encoded by OMITTING due_on — compact_params strips the
|
|
49
|
-
# nil below, and BC3 nils an omitted due date. Sending an explicit
|
|
50
|
-
# null would violate body compaction (SPEC §18), and sending ""
|
|
51
|
-
# risks a date-format error.
|
|
54
|
+
# Unaddressed. +compact_params+ drops the nil, so no key is sent and
|
|
55
|
+
# the presence-aware update never touches the stored date.
|
|
52
56
|
nil
|
|
57
|
+
elsif due_on.to_s.empty?
|
|
58
|
+
# A stated clear. "" survives +compact_params+ (it removes only
|
|
59
|
+
# nils) and reaches the wire as {"due_on": ""}.
|
|
60
|
+
""
|
|
53
61
|
else
|
|
54
62
|
due_on
|
|
55
63
|
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Merge-safe +update+ and read-modify-write +edit+ for documents,
|
|
6
|
+
# prepended onto the generated {DocumentsService} (see the +on_load+ hook
|
|
7
|
+
# in +basecamp.rb+).
|
|
8
|
+
#
|
|
9
|
+
# BC3's +DocumentsController#update+ builds a brand-new +Document+ from
|
|
10
|
+
# only the permitted params and swaps the recordable wholesale, so
|
|
11
|
+
# <tt>PUT /documents/{id}</tt> is a full replace: a body that omits
|
|
12
|
+
# +content+ ERASES it, and one that omits +title+ erases that too — the
|
|
13
|
+
# document then reads back as <tt>"Untitled"</tt>, because +Document#title+
|
|
14
|
+
# falls back when blank. Neither attribute is presence-validated, so
|
|
15
|
+
# *neither omission is a 422*; both are a 200 that quietly clears. What BC3
|
|
16
|
+
# does require is the wrapping +document+ object, so a body naming neither
|
|
17
|
+
# field is a 400. The sparse PUT — the natural thing to write — is
|
|
18
|
+
# therefore destructive on the raw endpoint, which stays available as
|
|
19
|
+
# {#replace}.
|
|
20
|
+
#
|
|
21
|
+
# Both compose the public +get+ and +replace+ methods, so hooks observe
|
|
22
|
+
# the two wire operations (+get+ then +replace+), not a synthetic
|
|
23
|
+
# composite.
|
|
24
|
+
#
|
|
25
|
+
# Neither is atomic: there is no conditional-update signal on this
|
|
26
|
+
# endpoint, so a concurrent write between the GET and PUT is
|
|
27
|
+
# overwritten — last write wins for the whole representation. The
|
|
28
|
+
# window is one round-trip. Use +replace+ to overwrite deliberately.
|
|
29
|
+
module DocumentsExtensions
|
|
30
|
+
# A document's full writable state, yielded to the +edit+ block. The
|
|
31
|
+
# whole struct is PUT back to the server, so clearing a field means
|
|
32
|
+
# setting it empty (<tt>""</tt>) — there is no third state. The writable
|
|
33
|
+
# set is exactly what BC3 permits: +title+ and +content+.
|
|
34
|
+
DocumentFields = Struct.new(:title, :content, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
# The deliberate-overwrite escape hatch named in every malformed-response
|
|
37
|
+
# hint raised out of this composite.
|
|
38
|
+
ESCAPE_HATCH = "replace"
|
|
39
|
+
|
|
40
|
+
# Sets the given fields on a document and preserves everything else:
|
|
41
|
+
# GETs the current document, overlays the explicitly-passed keyword
|
|
42
|
+
# arguments, and PUTs the full representation back. An omitted (+nil+)
|
|
43
|
+
# field is untouched, guaranteed; an explicitly-passed <tt>""</tt>
|
|
44
|
+
# clears.
|
|
45
|
+
#
|
|
46
|
+
# Not atomic — see the module docs for the GET→PUT race. Use {#replace}
|
|
47
|
+
# to overwrite deliberately.
|
|
48
|
+
#
|
|
49
|
+
# @param document_id [Integer] document id
|
|
50
|
+
# @param title [String, nil] new title (nil = keep current, "" clears)
|
|
51
|
+
# @param content [String, nil] new content (nil = keep current, "" clears)
|
|
52
|
+
# @return [Hash] the updated document
|
|
53
|
+
def update(document_id:, title: nil, content: nil)
|
|
54
|
+
fields = fields_from_document(get(document_id: document_id))
|
|
55
|
+
fields.title = title unless title.nil?
|
|
56
|
+
fields.content = content unless content.nil?
|
|
57
|
+
put_fields(document_id, fields)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Applies a read-modify-write block to a document: GETs the current
|
|
61
|
+
# document, yields its full writable state ({DocumentFields}), and PUTs
|
|
62
|
+
# the whole thing back. Clearing a field means setting it empty
|
|
63
|
+
# (<tt>""</tt>) — an untouched field keeps its current value. If the
|
|
64
|
+
# block raises, the edit aborts and nothing is written.
|
|
65
|
+
#
|
|
66
|
+
# Not atomic — see the module docs for the GET→PUT race.
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# account.documents.edit(document_id: 123) do |doc|
|
|
70
|
+
# doc.title = "🚨 #{doc.title}"
|
|
71
|
+
# doc.content = "" # clearing = setting empty on a full object
|
|
72
|
+
# end
|
|
73
|
+
#
|
|
74
|
+
# @param document_id [Integer] document id
|
|
75
|
+
# @yieldparam fields [DocumentFields] the document's writable state, to mutate in place
|
|
76
|
+
# @return [Hash] the updated document
|
|
77
|
+
# @raise [ArgumentError] if no block is given
|
|
78
|
+
def edit(document_id:)
|
|
79
|
+
raise ArgumentError, "edit requires a block" unless block_given?
|
|
80
|
+
|
|
81
|
+
fields = fields_from_document(get(document_id: document_id))
|
|
82
|
+
yield fields
|
|
83
|
+
put_fields(document_id, fields)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Derives the full writable state from a GET response.
|
|
89
|
+
#
|
|
90
|
+
# Every value here is resent in the full-replace PUT, so every value is
|
|
91
|
+
# validated before it is read. A plain <tt>|| ""</tt> would turn +false+
|
|
92
|
+
# into <tt>""</tt> — erasing the field on a call that never mentioned
|
|
93
|
+
# it — and pass arrays, hashes, numbers and +true+ straight through to be
|
|
94
|
+
# written verbatim. Ruby has no typed decoder between the GET and this
|
|
95
|
+
# read (+get+ returns a raw Hash), so the check is explicit work here
|
|
96
|
+
# rather than something the layer below already did. See {MergeSafe} and
|
|
97
|
+
# #576.
|
|
98
|
+
#
|
|
99
|
+
# The two writable fields read differently because the spec models them
|
|
100
|
+
# differently: +title+ is <tt>@required</tt>, so absent or nil is
|
|
101
|
+
# malformed; +content+ is optional, so absent or nil is a genuinely empty
|
|
102
|
+
# body.
|
|
103
|
+
def fields_from_document(document)
|
|
104
|
+
body = MergeSafe.require_hash(
|
|
105
|
+
document, record: "Document", operation: "GetDocument", escape: ESCAPE_HATCH
|
|
106
|
+
)
|
|
107
|
+
DocumentFields.new(
|
|
108
|
+
title: MergeSafe.required_writable_string(body, "title", record: "Document", escape: ESCAPE_HATCH),
|
|
109
|
+
content: MergeSafe.writable_string(body, "content", record: "Document", escape: ESCAPE_HATCH)
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# PUTs the full writable state via +replace+. Both fields are always
|
|
114
|
+
# sent, empties included: the generated layer's +compact_params+ strips
|
|
115
|
+
# nils, so a cleared field travels as <tt>""</tt> rather than JSON null
|
|
116
|
+
# (SPEC section 18 body compaction) — and omitting it would hand the
|
|
117
|
+
# clear back to the server's rebuild instead of stating it.
|
|
118
|
+
#
|
|
119
|
+
# +nil+ is normalised to <tt>""</tt> rather than coerced with +to_s+: the
|
|
120
|
+
# struct starts nil-valued and clearing by assigning nil is idiomatic,
|
|
121
|
+
# but +to_s+ would silently turn a block's +42+ into <tt>"42"</tt> and
|
|
122
|
+
# write it — the same corruption {MergeSafe} refuses on the read side.
|
|
123
|
+
# Validating what the *caller* assigns is the mirror of that rule and is
|
|
124
|
+
# deliberately out of scope here, exactly as in #576: a value the caller
|
|
125
|
+
# chose is not a value silently substituted for one they asked to
|
|
126
|
+
# preserve.
|
|
127
|
+
def put_fields(document_id, fields)
|
|
128
|
+
replace(
|
|
129
|
+
document_id: document_id,
|
|
130
|
+
title: fields.title.nil? ? "" : fields.title,
|
|
131
|
+
content: fields.content.nil? ? "" : fields.content
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Response guards shared by the merge-safe composites.
|
|
6
|
+
#
|
|
7
|
+
# A merge-safe +update+/+edit+ GETs a record, reads each writable field,
|
|
8
|
+
# and PUTs the *full* representation back. The endpoint is full-replace, so
|
|
9
|
+
# every value read here is written — including one the caller never
|
|
10
|
+
# mentioned. If the read step coerces or forwards a malformed value instead
|
|
11
|
+
# of refusing it, that value lands on the record.
|
|
12
|
+
#
|
|
13
|
+
# Two failure modes, the same defect wearing different clothes:
|
|
14
|
+
#
|
|
15
|
+
# * *erasure* — <tt>|| ""</tt> turns +false+ into <tt>""</tt>, wiping the
|
|
16
|
+
# field;
|
|
17
|
+
# * *corruption* — everything else falsey-in-other-languages (+0+, +[]+,
|
|
18
|
+
# <tt>{}</tt>) and every truthy non-string (+42+, +true+,
|
|
19
|
+
# <tt>["x"]</tt>) is forwarded verbatim, writing a number, boolean, array
|
|
20
|
+
# or hash where a String belongs.
|
|
21
|
+
#
|
|
22
|
+
# Ruby's +||+ treats only +nil+ and +false+ as falsy, so it erases in one
|
|
23
|
+
# case and corrupts in the rest. Testing only for erasure is what let this
|
|
24
|
+
# class survive five review passes, so both are refused here.
|
|
25
|
+
#
|
|
26
|
+
# *The rule: a composite is safe exactly when a typed decoder sits between
|
|
27
|
+
# the GET and the field read.* Go (+json.Unmarshal+), Swift (+Codable+) and
|
|
28
|
+
# Kotlin (kotlinx.serialization) get one for free from their models. Ruby
|
|
29
|
+
# does not — the generated services return a raw Hash, so nothing rejects a
|
|
30
|
+
# wrong-typed field and the check has to be explicit. That is why these
|
|
31
|
+
# guards exist in Ruby, Python and TypeScript and nowhere else (#576).
|
|
32
|
+
#
|
|
33
|
+
# Todolists carries its own copy of these guards (#574). #544 flattened the
|
|
34
|
+
# shape those guards read — dropping the envelope-arm rung, not the guards —
|
|
35
|
+
# but did not unify them here. A generated validating layer (#578) is the
|
|
36
|
+
# intended end state for all of them.
|
|
37
|
+
module MergeSafe
|
|
38
|
+
RESEND_HINT = "The merge-safe update/edit resend this field verbatim, so a coerced or " \
|
|
39
|
+
"empty value would overwrite the current one. Use %<escape>s to write the record " \
|
|
40
|
+
"deliberately."
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Renders a value for an error message without ever throwing.
|
|
45
|
+
#
|
|
46
|
+
# The guard's own error path must not fail while explaining a failure:
|
|
47
|
+
# +inspect+ is arbitrary user code and can raise. The class name is always
|
|
48
|
+
# available; the rendering is a bonus, capped per SPEC section 9 and
|
|
49
|
+
# dropped if it fails.
|
|
50
|
+
def describe(value)
|
|
51
|
+
kind = value.class.to_s
|
|
52
|
+
begin
|
|
53
|
+
Security.truncate("#{kind} #{value.inspect}")
|
|
54
|
+
rescue StandardError
|
|
55
|
+
kind
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Builds the malformed-response error.
|
|
60
|
+
#
|
|
61
|
+
# ApiError, not UsageError: the value arrived in a successful API
|
|
62
|
+
# response, so nothing the caller passed is at fault. Non-retryable,
|
|
63
|
+
# because re-requesting cannot repair a malformed body.
|
|
64
|
+
def malformed(message, hint)
|
|
65
|
+
ApiError.new(Security.truncate(message), hint: hint, retryable: false)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The response must be a Hash before any field is read.
|
|
69
|
+
#
|
|
70
|
+
# One level up from the malformed-field guards: a successful GET can
|
|
71
|
+
# return a scalar, an Array, or nil. <tt>body["due_on"]</tt> raises
|
|
72
|
+
# TypeError on an Integer or Array and returns a silent nil substring
|
|
73
|
+
# match on a String, so a malformed envelope would surface as a native
|
|
74
|
+
# TypeError instead of the documented statusless +api_error+.
|
|
75
|
+
def require_hash(body, record:, operation:, escape:)
|
|
76
|
+
return body if body.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
raise malformed(
|
|
79
|
+
"#{operation} returned #{describe(body)} where a #{record.downcase} object was expected",
|
|
80
|
+
"The merge-safe update/edit read this record's fields before rewriting them, so a " \
|
|
81
|
+
"non-object body cannot be used. Use #{escape} to write the record deliberately."
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reads a writable string field, refusing to coerce a malformed one.
|
|
86
|
+
#
|
|
87
|
+
# A missing key or an explicit +nil+ is genuinely empty — there is nothing
|
|
88
|
+
# to preserve and <tt>""</tt> is what the server already holds. An actual
|
|
89
|
+
# String passes verbatim. Anything else is a malformed response and is
|
|
90
|
+
# refused *before* the PUT, naming the offending field.
|
|
91
|
+
def writable_string(body, key, record:, escape:)
|
|
92
|
+
value = body[key]
|
|
93
|
+
|
|
94
|
+
if value.nil?
|
|
95
|
+
""
|
|
96
|
+
elsif value.is_a?(String)
|
|
97
|
+
value
|
|
98
|
+
else
|
|
99
|
+
raise malformed(
|
|
100
|
+
"#{record} field #{key.inspect} is not a string: #{describe(value)}",
|
|
101
|
+
format(RESEND_HINT, escape: escape)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Reads a writable string the record is *required* to carry.
|
|
107
|
+
#
|
|
108
|
+
# {writable_string} treats an absent key or an explicit +nil+ as genuinely
|
|
109
|
+
# empty, which is right for an optional field — <tt>""</tt> is what the
|
|
110
|
+
# server already holds. It is wrong for a required one. Where the spec
|
|
111
|
+
# marks a response member <tt>@required</tt> and BC3 can never render it
|
|
112
|
+
# blank, an absent, nil or blank value in a 2xx body is a *malformed
|
|
113
|
+
# response*, not an empty field. Coalescing it to <tt>""</tt> and sending
|
|
114
|
+
# that in the full-replace PUT would blank the real value on a call that
|
|
115
|
+
# never mentioned it — #576's defect exactly.
|
|
116
|
+
#
|
|
117
|
+
# Two records rely on this today and for the same reason: +Document#title+
|
|
118
|
+
# is <tt>super.presence || "Untitled"</tt> and +Schedule::Entry#summary+ is
|
|
119
|
+
# <tt>super.presence || "Untitled"</tt>, so neither can come back blank
|
|
120
|
+
# from a healthy server.
|
|
121
|
+
#
|
|
122
|
+
# The wrong-type branch is delegated to {writable_string}, so a required
|
|
123
|
+
# field and an optional one report a non-string identically.
|
|
124
|
+
def required_writable_string(body, key, record:, escape:)
|
|
125
|
+
value = body[key]
|
|
126
|
+
if value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
127
|
+
raise malformed(
|
|
128
|
+
%(#{record} field "#{key}" is required but the response carried #{describe(value)}),
|
|
129
|
+
"The merge-safe update/edit resend this field verbatim, so a missing or blank " \
|
|
130
|
+
"value would blank the current one. Use #{escape} to write the record deliberately."
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
writable_string(body, key, record: record, escape: escape)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Reads a writable boolean the record is *required* to carry.
|
|
138
|
+
#
|
|
139
|
+
# The boolean analogue of {required_writable_string}, and it cannot be
|
|
140
|
+
# expressed with a truthiness test: the value this guard most needs to
|
|
141
|
+
# admit is +false+, which every <tt>||</tt> idiom would treat as missing
|
|
142
|
+
# and replace with a default. +Schedule::Entry#all_day+ is NOT NULL with a
|
|
143
|
+
# +false+ default in BC3 and every partial emits it, so absent or nil is a
|
|
144
|
+
# malformed response — and defaulting it to +false+ would silently convert
|
|
145
|
+
# an all-day event into a midnight-to-midnight timed one on a call that
|
|
146
|
+
# only changed the summary.
|
|
147
|
+
#
|
|
148
|
+
# +0+/+1+ are refused rather than coerced, for the same reason
|
|
149
|
+
# {writable_string} refuses +42+: JSON has a boolean type and the server
|
|
150
|
+
# uses it.
|
|
151
|
+
def required_writable_boolean(body, key, record:, escape:)
|
|
152
|
+
value = body[key]
|
|
153
|
+
|
|
154
|
+
if value.nil?
|
|
155
|
+
raise malformed(
|
|
156
|
+
%(#{record} field "#{key}" is required but the response carried #{describe(value)}),
|
|
157
|
+
"The merge-safe update/edit resend this field verbatim, so a missing value would " \
|
|
158
|
+
"replace the current one with a default. Use #{escape} to write the record deliberately."
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
unless [ true, false ].include?(value)
|
|
163
|
+
raise malformed(
|
|
164
|
+
"#{record} field #{key.inspect} is not a boolean: #{describe(value)}",
|
|
165
|
+
format(RESEND_HINT, escape: escape)
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
value
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Reads an *optional* writable boolean, refusing to coerce a malformed one.
|
|
173
|
+
#
|
|
174
|
+
# {writable_string}'s boolean sibling, standing in the same relation to
|
|
175
|
+
# {required_writable_boolean} that +writable_string+ does to
|
|
176
|
+
# +required_writable_string+: a missing key or an explicit +nil+ is
|
|
177
|
+
# genuinely "not set" and returns +false+, because that is what the server
|
|
178
|
+
# already holds.
|
|
179
|
+
#
|
|
180
|
+
# +ScheduleEntry#highlighted+ is the case it exists for. The entry partial
|
|
181
|
+
# emits it unconditionally, but the reduced calendar partial behind
|
|
182
|
+
# GetUpcomingSchedule does not, and both render through the same schema —
|
|
183
|
+
# so the member is optional and absence is legitimate rather than
|
|
184
|
+
# malformed.
|
|
185
|
+
#
|
|
186
|
+
# What still cannot be tolerated is the *wrong type*: a <tt>"yes"</tt> or a
|
|
187
|
+
# +1+ must be refused, not coerced, because a caller who assigns the seeded
|
|
188
|
+
# value straight back sends whatever it was seeded with. That branch is
|
|
189
|
+
# delegated to {required_writable_boolean}, so an optional boolean and a
|
|
190
|
+
# required one report a non-boolean identically.
|
|
191
|
+
def writable_boolean(body, key, record:, escape:)
|
|
192
|
+
if body[key].nil?
|
|
193
|
+
false
|
|
194
|
+
else
|
|
195
|
+
required_writable_boolean(body, key, record: record, escape: escape)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Reads a list of person records and projects it to their Integer ids.
|
|
200
|
+
#
|
|
201
|
+
# The analogue of {writable_string} for the id-list fields. The +map+ it
|
|
202
|
+
# replaces (<tt>(body[key] || []).map { |p| p["id"] }</tt>) has three ways
|
|
203
|
+
# to go wrong on malformed data: a non-Array has no +map+ (or, for a Hash,
|
|
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
|
|
206
|
+
# same corruption as a wrong-typed string, one level down.
|
|
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.
|
|
211
|
+
def writable_id_list(body, key, record:, escape:)
|
|
212
|
+
value = body[key]
|
|
213
|
+
return [] if value.nil?
|
|
214
|
+
|
|
215
|
+
unless value.is_a?(Array)
|
|
216
|
+
raise malformed(
|
|
217
|
+
"#{record} field #{key.inspect} is not an array: #{describe(value)}",
|
|
218
|
+
format(RESEND_HINT, escape: escape)
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
value.each_with_index.map do |element, index|
|
|
223
|
+
person_id(element, index, key, record: record, escape: escape)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Validates one element of an id-list field and returns its id.
|
|
228
|
+
def person_id(element, index, key, record:, escape:)
|
|
229
|
+
unless element.is_a?(Hash)
|
|
230
|
+
raise malformed(
|
|
231
|
+
"#{record} field #{key.inspect}[#{index}] is not an object: #{describe(element)}",
|
|
232
|
+
format(RESEND_HINT, escape: escape)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
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
|
|
243
|
+
|
|
244
|
+
unless id.is_a?(Integer)
|
|
245
|
+
raise malformed(
|
|
246
|
+
"#{record} field #{key.inspect}[#{index}].id is not an integer: #{describe(id)}",
|
|
247
|
+
format(RESEND_HINT, escape: escape)
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
id
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|