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
|
@@ -3,13 +3,23 @@
|
|
|
3
3
|
module Basecamp
|
|
4
4
|
# Raised for validation errors (400, 422).
|
|
5
5
|
class ValidationError < Error
|
|
6
|
-
|
|
6
|
+
# @return [Hash{String => Array<String>}, nil] field-keyed validation
|
|
7
|
+
# messages from a 400/422 body — either {"errors" => {"field" => ["msg"]}},
|
|
8
|
+
# the Rails RecordInvalid rendering, or the same map with no wrapper at all
|
|
9
|
+
# ({"field" => ["msg"]}), which some controllers emit. Nil for every other
|
|
10
|
+
# error shape.
|
|
11
|
+
# The flattened form is also folded into the message; this slot preserves
|
|
12
|
+
# the raw, untruncated per-field messages.
|
|
13
|
+
attr_reader :field_errors
|
|
14
|
+
|
|
15
|
+
def initialize(message, hint: nil, http_status: 400, field_errors: nil)
|
|
7
16
|
super(
|
|
8
17
|
code: ErrorCode::VALIDATION,
|
|
9
18
|
message: message,
|
|
10
19
|
hint: hint,
|
|
11
20
|
http_status: http_status
|
|
12
21
|
)
|
|
22
|
+
@field_errors = field_errors
|
|
13
23
|
end
|
|
14
24
|
end
|
|
15
25
|
end
|
data/lib/basecamp/version.rb
CHANGED
data/lib/basecamp.rb
CHANGED
|
@@ -11,10 +11,26 @@ loader.on_load("Basecamp::Services::TodosService") do |klass, _abspath|
|
|
|
11
11
|
klass.prepend(Basecamp::Services::TodosExtensions)
|
|
12
12
|
end
|
|
13
13
|
# Same shape for cards: the generated class owns the constant and the
|
|
14
|
-
#
|
|
14
|
+
# tri-state `due_on` update is prepended over the generated update_verbatim.
|
|
15
15
|
loader.on_load("Basecamp::Services::CardsService") do |klass, _abspath|
|
|
16
16
|
klass.prepend(Basecamp::Services::CardsExtensions)
|
|
17
17
|
end
|
|
18
|
+
# And for todolists: PUT /todolists/{id} is a full replace, so the generated
|
|
19
|
+
# class owns `replace` and the merge-safe update/edit surface is prepended.
|
|
20
|
+
loader.on_load("Basecamp::Services::TodolistsService") do |klass, _abspath|
|
|
21
|
+
klass.prepend(Basecamp::Services::TodolistsExtensions)
|
|
22
|
+
end
|
|
23
|
+
# And for documents: PUT /documents/{id} is a full replace, so the generated
|
|
24
|
+
# class owns `replace` and the merge-safe update/edit surface is prepended.
|
|
25
|
+
loader.on_load("Basecamp::Services::DocumentsService") do |klass, _abspath|
|
|
26
|
+
klass.prepend(Basecamp::Services::DocumentsExtensions)
|
|
27
|
+
end
|
|
28
|
+
# And for schedule entries: PUT /schedule_entries/{id} is a full replace, so
|
|
29
|
+
# the generated class owns `replace_entry` and the merge-safe
|
|
30
|
+
# `update_entry`/`edit_entry` surface is prepended.
|
|
31
|
+
loader.on_load("Basecamp::Services::SchedulesService") do |klass, _abspath|
|
|
32
|
+
klass.prepend(Basecamp::Services::SchedulesExtensions)
|
|
33
|
+
end
|
|
18
34
|
loader.setup
|
|
19
35
|
|
|
20
36
|
# Load generated types if available
|
|
@@ -105,7 +121,9 @@ module Basecamp
|
|
|
105
121
|
|
|
106
122
|
case status
|
|
107
123
|
when 400, 422
|
|
108
|
-
|
|
124
|
+
field_errors = parse_field_errors(body)
|
|
125
|
+
message = Security.truncate(compose_validation_message(parse_error_message(body), field_errors) || "Request failed")
|
|
126
|
+
ValidationError.new(message, http_status: status, field_errors: field_errors)
|
|
109
127
|
when 401
|
|
110
128
|
AuthError.new(message)
|
|
111
129
|
when 403
|
|
@@ -114,6 +132,10 @@ module Basecamp
|
|
|
114
132
|
NotFoundError.new(message: message)
|
|
115
133
|
when 429
|
|
116
134
|
RateLimitError.new(retry_after: retry_after)
|
|
135
|
+
when 507
|
|
136
|
+
# Decided before the 5xx arms: a 507 is an account limit, not a
|
|
137
|
+
# transient server failure, and no retry can satisfy it.
|
|
138
|
+
LimitExceededError.new(Security.truncate(message))
|
|
117
139
|
when 500
|
|
118
140
|
ApiError.new("Server error (500)", http_status: 500, retryable: true)
|
|
119
141
|
when 502, 503, 504
|
|
@@ -141,7 +163,9 @@ module Basecamp
|
|
|
141
163
|
"download"
|
|
142
164
|
end
|
|
143
165
|
|
|
144
|
-
# Parses error message from response body.
|
|
166
|
+
# Parses error message from response body. A key is used only when its
|
|
167
|
+
# value is a String (SPEC section 6), so a malformed scalar member such as
|
|
168
|
+
# {"error": {}} cannot raise or leak a non-string into the message.
|
|
145
169
|
# @param body [String, nil]
|
|
146
170
|
# @return [String, nil]
|
|
147
171
|
def self.parse_error_message(body)
|
|
@@ -150,9 +174,79 @@ module Basecamp
|
|
|
150
174
|
Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
|
|
151
175
|
|
|
152
176
|
data = JSON.parse(body)
|
|
153
|
-
msg = data["error"]
|
|
177
|
+
msg = data.is_a?(Hash) ? [ data["error"], data["message"] ].find { |value| value.is_a?(String) } : nil
|
|
154
178
|
msg ? Security.truncate(msg) : nil
|
|
155
179
|
rescue JSON::ParserError, ApiError
|
|
156
180
|
nil
|
|
157
181
|
end
|
|
182
|
+
|
|
183
|
+
# Extracts the field-keyed validation errors map from a response body — the
|
|
184
|
+
# Rails RecordInvalid rendering {"errors" => {"field" => ["msg", ...]}}.
|
|
185
|
+
# Entries whose value is not an array are skipped, non-string elements are
|
|
186
|
+
# dropped, and a map with no usable entries is treated as absent (nil).
|
|
187
|
+
# @param body [String, nil]
|
|
188
|
+
# @return [Hash{String => Array<String>}, nil]
|
|
189
|
+
def self.parse_field_errors(body)
|
|
190
|
+
return nil if body.nil? || body.empty?
|
|
191
|
+
|
|
192
|
+
Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
|
|
193
|
+
|
|
194
|
+
data = JSON.parse(body)
|
|
195
|
+
errors = data.is_a?(Hash) ? data["errors"] : nil
|
|
196
|
+
if errors.is_a?(Hash)
|
|
197
|
+
field_errors = errors.each_with_object({}) do |(field, values), result|
|
|
198
|
+
next unless values.is_a?(Array)
|
|
199
|
+
|
|
200
|
+
messages = values.grep(String)
|
|
201
|
+
result[field.to_s] = messages unless messages.empty?
|
|
202
|
+
end
|
|
203
|
+
field_errors.empty? ? nil : field_errors
|
|
204
|
+
else
|
|
205
|
+
parse_bare_field_errors(data)
|
|
206
|
+
end
|
|
207
|
+
rescue JSON::ParserError, ApiError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Extracts an unwrapped field map — the `render json: @webhook.errors`
|
|
212
|
+
# rendering, where the whole body is {"field" => ["msg", ...]}. The gate is
|
|
213
|
+
# all-or-nothing by design (SPEC section 6 step 2): with no "errors" key to
|
|
214
|
+
# declare intent, only shape distinguishes a field map from any other JSON
|
|
215
|
+
# object, so a single non-conforming member means this is not one.
|
|
216
|
+
# @param data [Object] the parsed body
|
|
217
|
+
# @return [Hash{String => Array<String>}, nil]
|
|
218
|
+
def self.parse_bare_field_errors(data)
|
|
219
|
+
return nil unless data.is_a?(Hash) && !data.empty?
|
|
220
|
+
# Only "errors" is structurally reserved (it belongs to the wrapped path).
|
|
221
|
+
# "error" and "message" are not excluded by name: a flat body carries them
|
|
222
|
+
# as strings, which the shape gate below already rejects.
|
|
223
|
+
return nil if data.key?("errors")
|
|
224
|
+
|
|
225
|
+
data.each_with_object({}) do |(field, values), result|
|
|
226
|
+
return nil unless values.is_a?(Array) && !values.empty?
|
|
227
|
+
return nil unless values.all? { |message| message.is_a?(String) && !message.empty? }
|
|
228
|
+
|
|
229
|
+
result[field.to_s] = values
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Merges the top-level error message with the flattened field-keyed errors:
|
|
234
|
+
# appended in parentheses when both are present, standing alone when only the
|
|
235
|
+
# field errors are. The flattened shape — fields sorted lexicographically, a
|
|
236
|
+
# field's messages joined with "; ", fields joined with ", " — is shared by
|
|
237
|
+
# all six SDKs; change it everywhere or nowhere. Callers truncate the
|
|
238
|
+
# composed result so the appended tail is capped too.
|
|
239
|
+
# @param message [String, nil]
|
|
240
|
+
# @param field_errors [Hash{String => Array<String>}, nil]
|
|
241
|
+
# @return [String, nil]
|
|
242
|
+
def self.compose_validation_message(message, field_errors)
|
|
243
|
+
if field_errors.nil?
|
|
244
|
+
message
|
|
245
|
+
else
|
|
246
|
+
flat = field_errors.keys.sort \
|
|
247
|
+
.map { |field| "#{field}: #{field_errors[field].join("; ")}" } \
|
|
248
|
+
.join(", ")
|
|
249
|
+
message ? "#{message} (#{flat})" : flat
|
|
250
|
+
end
|
|
251
|
+
end
|
|
158
252
|
end
|
|
@@ -14,7 +14,9 @@ class MetadataExtractor
|
|
|
14
14
|
METHODS = %w[get post put patch delete].freeze
|
|
15
15
|
|
|
16
16
|
def initialize(openapi_path)
|
|
17
|
-
|
|
17
|
+
# Read as UTF-8 regardless of process locale (LC_ALL=C would otherwise read
|
|
18
|
+
# as US-ASCII and JSON.parse dies on the spec's multibyte characters)
|
|
19
|
+
@openapi = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def extract
|
|
@@ -65,13 +65,15 @@ class ServiceGenerator
|
|
|
65
65
|
},
|
|
66
66
|
'Files' => {
|
|
67
67
|
'Attachments' => %w[CreateAttachment],
|
|
68
|
-
'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions],
|
|
68
|
+
'Uploads' => %w[GetUpload UpdateUpload ListUploads CreateUpload ListUploadVersions CreateUploadVersion],
|
|
69
69
|
'Vaults' => %w[GetVault UpdateVault ListVaults CreateVault],
|
|
70
|
-
'Documents' => %w[GetDocument
|
|
70
|
+
'Documents' => %w[GetDocument ReplaceDocument ListDocuments CreateDocument],
|
|
71
|
+
'CloudFiles' => %w[GetCloudFile CreateCloudFile UpdateCloudFile],
|
|
72
|
+
'GoogleDocuments' => %w[GetGoogleDocument CreateGoogleDocument UpdateGoogleDocument]
|
|
71
73
|
},
|
|
72
74
|
'Automation' => {
|
|
73
75
|
'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool],
|
|
74
|
-
'Recordings' => %w[
|
|
76
|
+
'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings],
|
|
75
77
|
'Webhooks' => %w[ListWebhooks CreateWebhook GetWebhook UpdateWebhook DeleteWebhook],
|
|
76
78
|
'Events' => %w[ListEvents],
|
|
77
79
|
'Lineup' => %w[CreateLineupMarker UpdateLineupMarker DeleteLineupMarker],
|
|
@@ -104,10 +106,10 @@ class ServiceGenerator
|
|
|
104
106
|
'Schedule' => {
|
|
105
107
|
'Schedules' => %w[
|
|
106
108
|
GetSchedule UpdateScheduleSettings ListScheduleEntries
|
|
107
|
-
CreateScheduleEntry GetScheduleEntry
|
|
109
|
+
CreateScheduleEntry GetScheduleEntry ReplaceScheduleEntry
|
|
108
110
|
GetScheduleEntryOccurrence
|
|
109
111
|
],
|
|
110
|
-
'Timesheets' => %w[GetRecordingTimesheet GetProjectTimesheet GetTimesheetReport GetTimesheetEntry CreateTimesheetEntry UpdateTimesheetEntry]
|
|
112
|
+
'Timesheets' => %w[GetRecordingTimesheet GetProjectTimesheet GetTimesheetReport GetTimesheetEntry CreateTimesheetEntry UpdateTimesheetEntry DestroyTimesheetEntry]
|
|
111
113
|
},
|
|
112
114
|
'ClientFeatures' => {
|
|
113
115
|
'ClientApprovals' => %w[ListClientApprovals GetClientApproval],
|
|
@@ -116,7 +118,7 @@ class ServiceGenerator
|
|
|
116
118
|
'ClientVisibility' => %w[SetClientVisibility]
|
|
117
119
|
},
|
|
118
120
|
'Todos' => {
|
|
119
|
-
'Todos' => %w[ListTodos CreateTodo CreateTodosetTodo GetTodo ReplaceTodo CompleteTodo UncompleteTodo
|
|
121
|
+
'Todos' => %w[ListTodos CreateTodo CreateTodosetTodo GetTodo ReplaceTodo CompleteTodo UncompleteTodo],
|
|
120
122
|
'Todolists' => %w[GetTodolistOrGroup UpdateTodolistOrGroup ListTodolists CreateTodolist RepositionTodolist],
|
|
121
123
|
'Todosets' => %w[GetTodoset],
|
|
122
124
|
'HillCharts' => %w[GetHillChart UpdateHillChartSettings],
|
|
@@ -139,15 +141,19 @@ class ServiceGenerator
|
|
|
139
141
|
METHOD_NAME_OVERRIDES = {
|
|
140
142
|
'GetMyProfile' => 'my_profile',
|
|
141
143
|
'GetTodolistOrGroup' => 'get',
|
|
142
|
-
|
|
144
|
+
# The plain `update` name belongs to the merge-safe composite; the raw
|
|
145
|
+
# single-PUT path keeps a name that says what it does. BC3 rebuilds the
|
|
146
|
+
# todolist from the permitted params, so omission clears. See #374.
|
|
147
|
+
'UpdateTodolistOrGroup' => 'replace',
|
|
143
148
|
'SetCardColumnColor' => 'set_color',
|
|
144
149
|
'EnableCardColumnOnHold' => 'enable_on_hold',
|
|
145
150
|
'DisableCardColumnOnHold' => 'disable_on_hold',
|
|
146
151
|
'RepositionCardStep' => 'reposition',
|
|
147
152
|
'CreateCardStep' => 'create',
|
|
148
153
|
'UpdateCardStep' => 'update',
|
|
149
|
-
# The plain `update` name belongs to the
|
|
150
|
-
#
|
|
154
|
+
# The plain `update` name belongs to the tri-state `due_on` wrapper in
|
|
155
|
+
# CardsExtensions; the unnormalised path keeps a name that says what it
|
|
156
|
+
# does. See #467.
|
|
151
157
|
'UpdateCard' => 'update_verbatim',
|
|
152
158
|
'SetCardStepCompletion' => 'set_completion',
|
|
153
159
|
'GetQuestionnaire' => 'get_questionnaire',
|
|
@@ -175,6 +181,7 @@ class ServiceGenerator
|
|
|
175
181
|
'GetTimesheetEntry' => 'get',
|
|
176
182
|
'CreateTimesheetEntry' => 'create',
|
|
177
183
|
'UpdateTimesheetEntry' => 'update',
|
|
184
|
+
'DestroyTimesheetEntry' => 'destroy',
|
|
178
185
|
'GetProgressReport' => 'progress',
|
|
179
186
|
'GetUpcomingSchedule' => 'upcoming',
|
|
180
187
|
'GetAssignedTodos' => 'assigned',
|
|
@@ -203,7 +210,6 @@ class ServiceGenerator
|
|
|
203
210
|
'ListForwards' => 'list',
|
|
204
211
|
'GetForwardReply' => 'get_reply',
|
|
205
212
|
'ListForwardReplies' => 'list_replies',
|
|
206
|
-
'CreateForwardReply' => 'create_reply',
|
|
207
213
|
'GetInbox' => 'get_inbox',
|
|
208
214
|
# Uploads - use specific names to avoid conflicts with versions
|
|
209
215
|
'GetUpload' => 'get',
|
|
@@ -211,6 +217,7 @@ class ServiceGenerator
|
|
|
211
217
|
'ListUploads' => 'list',
|
|
212
218
|
'CreateUpload' => 'create',
|
|
213
219
|
'ListUploadVersions' => 'list_versions',
|
|
220
|
+
'CreateUploadVersion' => 'create_version',
|
|
214
221
|
'GetMessage' => 'get',
|
|
215
222
|
'UpdateMessage' => 'update',
|
|
216
223
|
'CreateMessage' => 'create',
|
|
@@ -233,7 +240,11 @@ class ServiceGenerator
|
|
|
233
240
|
'GetSchedule' => 'get',
|
|
234
241
|
'UpdateScheduleSettings' => 'update_settings',
|
|
235
242
|
'GetScheduleEntry' => 'get_entry',
|
|
236
|
-
|
|
243
|
+
# The plain `update_entry` name belongs to the merge-safe composite; the raw
|
|
244
|
+
# single-PUT path keeps a name that says what it does. Without the override
|
|
245
|
+
# the algorithm yields a bare `replace` (scheduleentry is a SIMPLE_RESOURCE),
|
|
246
|
+
# which reads as "replace the schedule". See #547.
|
|
247
|
+
'ReplaceScheduleEntry' => 'replace_entry',
|
|
237
248
|
'CreateScheduleEntry' => 'create_entry',
|
|
238
249
|
'ListScheduleEntries' => 'list_entries',
|
|
239
250
|
'GetScheduleEntryOccurrence' => 'get_entry_occurrence',
|
|
@@ -310,7 +321,8 @@ class ServiceGenerator
|
|
|
310
321
|
].freeze
|
|
311
322
|
|
|
312
323
|
def initialize(openapi_path)
|
|
313
|
-
|
|
324
|
+
# UTF-8 regardless of process locale — see generate-metadata.rb
|
|
325
|
+
@openapi = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))
|
|
314
326
|
@schemas = @openapi.dig('components', 'schemas') || {}
|
|
315
327
|
end
|
|
316
328
|
|
|
@@ -571,6 +583,34 @@ class ServiceGenerator
|
|
|
571
583
|
.downcase
|
|
572
584
|
end
|
|
573
585
|
|
|
586
|
+
# Folds a multi-line description into the body of a YARD `@param` tag.
|
|
587
|
+
#
|
|
588
|
+
# The first line is returned bare — the caller has already written
|
|
589
|
+
# `# @param name [Type] ` in front of it. Every later line is indented under
|
|
590
|
+
# it, EXCEPT a blank one: a paragraph break must emit a bare `#`, not `#`
|
|
591
|
+
# followed by the continuation padding. Padding an empty line is trailing
|
|
592
|
+
# whitespace, which `git diff --check` fails on, and it stayed fixed only
|
|
593
|
+
# because this lives in the generator — patching the emitted file put it back
|
|
594
|
+
# on the next `make generate`.
|
|
595
|
+
#
|
|
596
|
+
# Any trailing whitespace already in the description is stripped for the same
|
|
597
|
+
# reason: a Smithy doc comment can carry it, and the generator should not
|
|
598
|
+
# launder it into a generated file.
|
|
599
|
+
YARD_CONTINUATION_INDENT = ' # '
|
|
600
|
+
|
|
601
|
+
def yard_param_description(text)
|
|
602
|
+
text.to_s.split("\n", -1).each_with_index.map do |line, index|
|
|
603
|
+
stripped = line.rstrip
|
|
604
|
+
if index.zero?
|
|
605
|
+
stripped
|
|
606
|
+
elsif stripped.empty?
|
|
607
|
+
' #'
|
|
608
|
+
else
|
|
609
|
+
"#{YARD_CONTINUATION_INDENT}#{stripped}"
|
|
610
|
+
end
|
|
611
|
+
end.join("\n")
|
|
612
|
+
end
|
|
613
|
+
|
|
574
614
|
def generate_service(service)
|
|
575
615
|
lines = []
|
|
576
616
|
|
|
@@ -611,8 +651,11 @@ class ServiceGenerator
|
|
|
611
651
|
def generate_method(op, service_name:)
|
|
612
652
|
lines = []
|
|
613
653
|
|
|
614
|
-
|
|
615
|
-
|
|
654
|
+
is_paginated = (op[:returns_array] || op[:has_pagination]) && !op[:pagination_key]
|
|
655
|
+
is_wrapped_paginated = op[:has_pagination] && op[:pagination_key]
|
|
656
|
+
|
|
657
|
+
# Method signature (paginated operations gain a trailing max_items: kwarg)
|
|
658
|
+
params = build_params(op, paginated: is_paginated || is_wrapped_paginated)
|
|
616
659
|
|
|
617
660
|
# YARD documentation
|
|
618
661
|
lines << " # #{op[:description]}"
|
|
@@ -644,7 +687,7 @@ class ServiceGenerator
|
|
|
644
687
|
ruby_name = to_snake_case(b[:name])
|
|
645
688
|
type = b[:type] || 'Object'
|
|
646
689
|
type = "#{type}, nil" unless b[:required]
|
|
647
|
-
desc = (b[:description] || ruby_name.gsub('_', ' '))
|
|
690
|
+
desc = yard_param_description(b[:description] || ruby_name.gsub('_', ' '))
|
|
648
691
|
format_hint = b[:format_hint] ? " (#{b[:format_hint]})" : ''
|
|
649
692
|
lines << " # @param #{ruby_name} [#{type}] #{desc}#{format_hint}"
|
|
650
693
|
end
|
|
@@ -655,20 +698,22 @@ class ServiceGenerator
|
|
|
655
698
|
ruby_name = to_snake_case(q[:name])
|
|
656
699
|
type = q[:type] || 'String'
|
|
657
700
|
type = "#{type}, nil" unless q[:required]
|
|
658
|
-
desc = (q[:description] || ruby_name.gsub('_', ' '))
|
|
701
|
+
desc = yard_param_description(q[:description] || ruby_name.gsub('_', ' '))
|
|
659
702
|
lines << " # @param #{ruby_name} [#{type}] #{desc}"
|
|
660
703
|
end
|
|
661
704
|
|
|
662
|
-
# Add @
|
|
663
|
-
is_paginated
|
|
664
|
-
|
|
705
|
+
# Add @param tag for the pagination cap on paginated operations
|
|
706
|
+
if is_paginated || is_wrapped_paginated
|
|
707
|
+
lines << ' # @param max_items [Integer, nil] cap on items yielded across pages; nil or non-positive means no cap'
|
|
708
|
+
end
|
|
665
709
|
|
|
710
|
+
# Add @return tag
|
|
666
711
|
if op[:returns_void]
|
|
667
712
|
lines << ' # @return [void]'
|
|
668
713
|
elsif is_wrapped_paginated
|
|
669
|
-
lines << ' # @return [Hash]
|
|
714
|
+
lines << ' # @return [Hash] wrapper fields merged with a ListEnumerator of the paginated items'
|
|
670
715
|
elsif is_paginated
|
|
671
|
-
lines << ' # @return [
|
|
716
|
+
lines << ' # @return [ListEnumerator<Hash>] lazily paginated results (#meta carries pagination metadata)'
|
|
672
717
|
elsif op[:returns_bare_array]
|
|
673
718
|
# Unpaginated bare array (single request, no Link-following) — e.g. the
|
|
674
719
|
# overdue todo/card feeds. Returns the parsed JSON array, not a Hash.
|
|
@@ -691,7 +736,8 @@ class ServiceGenerator
|
|
|
691
736
|
body_lines.each { |l| lines << " #{l}" }
|
|
692
737
|
lines << ' end'
|
|
693
738
|
elsif is_paginated
|
|
694
|
-
# wrap_paginated
|
|
739
|
+
# wrap_paginated fires the start hook eagerly (page 1 is fetched inside
|
|
740
|
+
# the block) and the end hook when iteration completes
|
|
695
741
|
lines << " wrap_paginated(#{hook_kwargs}) do"
|
|
696
742
|
body_lines = generate_list_method_body(op, path_expr)
|
|
697
743
|
body_lines.each { |l| lines << " #{l}" }
|
|
@@ -729,7 +775,7 @@ class ServiceGenerator
|
|
|
729
775
|
kwargs.join(', ')
|
|
730
776
|
end
|
|
731
777
|
|
|
732
|
-
def build_params(op)
|
|
778
|
+
def build_params(op, paginated: false)
|
|
733
779
|
params = []
|
|
734
780
|
|
|
735
781
|
# Path parameters as keyword args
|
|
@@ -772,6 +818,9 @@ class ServiceGenerator
|
|
|
772
818
|
params << "#{to_snake_case(q[:name])}: nil"
|
|
773
819
|
end
|
|
774
820
|
|
|
821
|
+
# Client-side pagination cap, threaded into the base paginators
|
|
822
|
+
params << 'max_items: nil' if paginated
|
|
823
|
+
|
|
775
824
|
params.join(', ')
|
|
776
825
|
end
|
|
777
826
|
|
|
@@ -818,9 +867,9 @@ class ServiceGenerator
|
|
|
818
867
|
if op[:query_params].any?
|
|
819
868
|
param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
|
|
820
869
|
lines << " params = compact_query_params(#{param_names.join(', ')})"
|
|
821
|
-
lines << " paginate(#{path_expr}, params: params, operation: \"#{op[:operation_id]}\")"
|
|
870
|
+
lines << " paginate(#{path_expr}, params: params, operation: \"#{op[:operation_id]}\", max_items: max_items)"
|
|
822
871
|
else
|
|
823
|
-
lines << " paginate(#{path_expr}, operation: \"#{op[:operation_id]}\")"
|
|
872
|
+
lines << " paginate(#{path_expr}, operation: \"#{op[:operation_id]}\", max_items: max_items)"
|
|
824
873
|
end
|
|
825
874
|
|
|
826
875
|
lines
|
|
@@ -832,9 +881,11 @@ class ServiceGenerator
|
|
|
832
881
|
if op[:query_params].any?
|
|
833
882
|
param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
|
|
834
883
|
lines << " params = compact_query_params(#{param_names.join(', ')})"
|
|
835
|
-
lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params,
|
|
884
|
+
lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params, " \
|
|
885
|
+
"operation: \"#{op[:operation_id]}\", max_items: max_items)"
|
|
836
886
|
else
|
|
837
|
-
lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\",
|
|
887
|
+
lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", " \
|
|
888
|
+
"operation: \"#{op[:operation_id]}\", max_items: max_items)"
|
|
838
889
|
end
|
|
839
890
|
|
|
840
891
|
lines
|
data/scripts/generate-types.rb
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
require 'json'
|
|
10
10
|
require 'set'
|
|
11
11
|
require 'time'
|
|
12
|
+
require_relative 'go_type_spellings'
|
|
12
13
|
|
|
13
14
|
# Schemas to skip (internal/generated response wrappers)
|
|
14
15
|
SKIP_PATTERNS = [
|
|
@@ -130,7 +131,8 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
130
131
|
puts ' module Types'
|
|
131
132
|
puts ' include TypeHelpers'
|
|
132
133
|
|
|
133
|
-
|
|
134
|
+
# UTF-8 regardless of process locale — see generate-metadata.rb
|
|
135
|
+
schemas = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))['components']['schemas'] || {}
|
|
134
136
|
sorted = schemas.keys.sort
|
|
135
137
|
|
|
136
138
|
sorted.each do |name|
|
|
@@ -207,7 +209,7 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
207
209
|
"parse_float(data[\"#{prop_name}\"])"
|
|
208
210
|
elsif prop_schema['type'] == 'boolean'
|
|
209
211
|
"parse_boolean(data[\"#{prop_name}\"])"
|
|
210
|
-
elsif prop_schema['x-go-type']
|
|
212
|
+
elsif GoTypeSpellings.timestamp_go_type?(prop_schema['x-go-type'])
|
|
211
213
|
"parse_datetime(data[\"#{prop_name}\"])"
|
|
212
214
|
else
|
|
213
215
|
"data[\"#{prop_name}\"]"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Go type spellings the Ruby generator needs to reason about, extracted so the
|
|
4
|
+
# generator and its tests can share one definition — the test used to `load`
|
|
5
|
+
# the whole generator script, which defined every one of its helpers on the
|
|
6
|
+
# test process.
|
|
7
|
+
module GoTypeSpellings
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Spellings that mean "full timestamp" and therefore get Time coercion in
|
|
11
|
+
# Ruby. Matched after stripping a leading `*`: the Go optional-pointer policy
|
|
12
|
+
# (SPEC.md §10) means a schema may carry either `time.Time` or `*time.Time`
|
|
13
|
+
# for the same wire contract, and an exact-string match silently degrades the
|
|
14
|
+
# pointer spelling to a raw String (#537).
|
|
15
|
+
#
|
|
16
|
+
# types.FlexibleTime is deliberately NOT here: it also accepts date-only
|
|
17
|
+
# values, and Ruby has passed those through as strings since it was
|
|
18
|
+
# introduced. Adding it is a behavior change, not a spelling fix.
|
|
19
|
+
TIMESTAMP_GO_TYPES = [ 'time.Time' ].freeze
|
|
20
|
+
|
|
21
|
+
def timestamp_go_type?(go_type)
|
|
22
|
+
return false unless go_type.is_a?(String)
|
|
23
|
+
|
|
24
|
+
TIMESTAMP_GO_TYPES.include?(go_type.delete_prefix('*'))
|
|
25
|
+
end
|
|
26
|
+
end
|
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.14.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-08-
|
|
11
|
+
date: 2026-08-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -195,13 +195,16 @@ files:
|
|
|
195
195
|
- lib/basecamp/generated/services/client_correspondences_service.rb
|
|
196
196
|
- lib/basecamp/generated/services/client_replies_service.rb
|
|
197
197
|
- lib/basecamp/generated/services/client_visibility_service.rb
|
|
198
|
+
- lib/basecamp/generated/services/cloud_files_service.rb
|
|
198
199
|
- lib/basecamp/generated/services/comments_service.rb
|
|
199
200
|
- lib/basecamp/generated/services/documents_service.rb
|
|
200
201
|
- lib/basecamp/generated/services/drafts_service.rb
|
|
201
202
|
- lib/basecamp/generated/services/events_service.rb
|
|
202
203
|
- lib/basecamp/generated/services/everything_service.rb
|
|
204
|
+
- lib/basecamp/generated/services/folders_service.rb
|
|
203
205
|
- lib/basecamp/generated/services/forwards_service.rb
|
|
204
206
|
- lib/basecamp/generated/services/gauges_service.rb
|
|
207
|
+
- lib/basecamp/generated/services/google_documents_service.rb
|
|
205
208
|
- lib/basecamp/generated/services/hill_charts_service.rb
|
|
206
209
|
- lib/basecamp/generated/services/lineup_service.rb
|
|
207
210
|
- lib/basecamp/generated/services/message_boards_service.rb
|
|
@@ -232,6 +235,9 @@ files:
|
|
|
232
235
|
- lib/basecamp/generated/types.rb
|
|
233
236
|
- lib/basecamp/hooks.rb
|
|
234
237
|
- lib/basecamp/http.rb
|
|
238
|
+
- lib/basecamp/limit_exceeded_error.rb
|
|
239
|
+
- lib/basecamp/list_enumerator.rb
|
|
240
|
+
- lib/basecamp/list_meta.rb
|
|
235
241
|
- lib/basecamp/logger_hooks.rb
|
|
236
242
|
- lib/basecamp/network_error.rb
|
|
237
243
|
- lib/basecamp/noop_hooks.rb
|
|
@@ -262,6 +268,10 @@ files:
|
|
|
262
268
|
- lib/basecamp/security.rb
|
|
263
269
|
- lib/basecamp/services/authorization_service.rb
|
|
264
270
|
- lib/basecamp/services/cards_extensions.rb
|
|
271
|
+
- lib/basecamp/services/documents_extensions.rb
|
|
272
|
+
- lib/basecamp/services/merge_safe.rb
|
|
273
|
+
- lib/basecamp/services/schedules_extensions.rb
|
|
274
|
+
- lib/basecamp/services/todolists_extensions.rb
|
|
265
275
|
- lib/basecamp/services/todos_extensions.rb
|
|
266
276
|
- lib/basecamp/static_token_provider.rb
|
|
267
277
|
- lib/basecamp/token_provider.rb
|
|
@@ -276,6 +286,7 @@ files:
|
|
|
276
286
|
- scripts/generate-metadata.rb
|
|
277
287
|
- scripts/generate-services.rb
|
|
278
288
|
- scripts/generate-types.rb
|
|
289
|
+
- scripts/go_type_spellings.rb
|
|
279
290
|
homepage: https://github.com/basecamp/basecamp-sdk
|
|
280
291
|
licenses:
|
|
281
292
|
- MIT
|