basecamp-sdk 0.19.0 → 0.21.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.
@@ -17,7 +17,54 @@ require 'set'
17
17
 
18
18
  # Service generator for Ruby SDK
19
19
  class ServiceGenerator
20
- METHODS = %w[get post put patch delete].freeze
20
+ # A Path Item Object is read by EXCLUSION. Its non-operation fields are a
21
+ # closed, spec-defined set and its extensions are `x-` prefixed, so every
22
+ # OTHER field is an operation. Enumerating the verbs instead is the defect
23
+ # this replaces (#925): Smithy's `@http` trait takes the method as a
24
+ # free-form string it "will use literally and will perform no validation
25
+ # on", so a model author writing `method: "HEAD"` produced a valid model, a
26
+ # valid openapi.json, and no method on any service — the verb was not in the
27
+ # list, so the operation was stepped over in silence.
28
+ NON_OPERATION_FIELDS = %w[summary description servers parameters].freeze
29
+
30
+ # Discovery above is total; emission is bounded by the ONE declaration every
31
+ # generator reads (spec/generated-verbs.json), whose header carries the
32
+ # reasoning. Each verb in it has an `Http` helper (`http_get`, `http_post`, …)
33
+ # the emitted method body calls. An operation on any other verb stops this
34
+ # generator by name instead of vanishing from the SDK — a loud refusal is the
35
+ # acceptable outcome, a silent drop never was.
36
+ #
37
+ # The declaration is ORDERED, and that order is emission order. Ordering is a
38
+ # different job from deciding membership and cannot reproduce #925: membership
39
+ # is decided by exclusion above, so a verb absent from the declaration is
40
+ # refused, never skipped.
41
+ # The self-test points this at a crafted declaration to prove the bound is
42
+ # sourced from the shared file rather than a private literal; production runs
43
+ # never set it.
44
+ GENERATED_VERBS_FILE = ENV.fetch(
45
+ 'BASECAMP_GENERATED_VERBS', File.expand_path('../../spec/generated-verbs.json', __dir__)
46
+ )
47
+
48
+ EMITTABLE_METHODS = begin
49
+ verbs = JSON.parse(File.read(GENERATED_VERBS_FILE, encoding: 'UTF-8'))['verbs']
50
+ # An HTTP method is a TOKEN, so the rule is a positive character class rather
51
+ # than a blankness predicate: `[a-z]+`, identical in all six loaders. Two
52
+ # review rounds chased "blank" across languages — Kotlin and Rust rejected a
53
+ # space while the others accepted it, then Ruby's ASCII `strip` accepted a
54
+ # non-breaking space the Unicode-aware ones rejected — and the next
55
+ # disagreement was guaranteed, because every language defines whitespace
56
+ # differently (Java's Character.isWhitespace excludes U+00A0; Rust's
57
+ # char::is_whitespace includes it; Ruby's String#strip is ASCII-only). A
58
+ # closed positive rule has no such seam: "", " ", "\u00a0", "GET" and 1 are
59
+ # all rejected the same way everywhere.
60
+ unless verbs.is_a?(Array) && !verbs.empty? && verbs.all? { |v| v.is_a?(String) && v.match?(/\A[a-z]+\z/) }
61
+ abort "Error: #{GENERATED_VERBS_FILE} must declare a non-empty `verbs` array of lowercase " \
62
+ 'ASCII method names (/\A[a-z]+\z/).'
63
+ end
64
+ verbs.freeze
65
+ rescue Errno::ENOENT, JSON::ParserError => e
66
+ abort "Error: cannot read the generated-verb declaration #{GENERATED_VERBS_FILE}: #{e.message}"
67
+ end
21
68
 
22
69
  # Schema reference cache for resolving $ref
23
70
  attr_reader :schemas
@@ -33,9 +80,9 @@ class ServiceGenerator
33
80
  'Schedule' => 'Schedules',
34
81
  'People' => 'People',
35
82
  'Projects' => 'Projects',
36
- 'Automation' => 'Automation',
37
83
  'ClientFeatures' => 'ClientFeatures',
38
84
  'Boosts' => 'Boosts',
85
+ 'Subtasks' => 'Subtasks',
39
86
  'Untagged' => 'Miscellaneous'
40
87
  }.freeze
41
88
 
@@ -72,22 +119,11 @@ class ServiceGenerator
72
119
  'CloudFiles' => %w[GetCloudFile CreateCloudFile UpdateCloudFile],
73
120
  'GoogleDocuments' => %w[GetGoogleDocument CreateGoogleDocument UpdateGoogleDocument]
74
121
  },
75
- 'Automation' => {
76
- 'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool],
77
- 'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings SpotlightRecording UnspotlightRecording],
78
- 'Webhooks' => %w[ListWebhooks CreateWebhook GetWebhook UpdateWebhook DeleteWebhook],
79
- 'Events' => %w[ListEvents],
80
- 'Lineup' => %w[CreateLineupMarker UpdateLineupMarker DeleteLineupMarker],
81
- 'Search' => %w[Search GetSearchMetadata],
82
- 'Templates' => %w[
83
- ListTemplates CreateTemplate GetTemplate UpdateTemplate
84
- DeleteTemplate CreateProjectFromTemplate GetProjectConstruction
85
- GetTemplateLibrary CreateTemplateLibraryCopy GetTemplateLibraryCopy
86
- ],
87
- 'Checkins' => %w[
88
- GetQuestionnaire ListQuestions CreateQuestion GetQuestion
89
- UpdateQuestion ListAnswers CreateAnswer GetAnswer UpdateAnswer
90
- ]
122
+ 'Dock' => {
123
+ 'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool]
124
+ },
125
+ 'Recordings' => {
126
+ 'Events' => %w[ListEvents]
91
127
  },
92
128
  'Messages' => {
93
129
  'Messages' => %w[GetMessage UpdateMessage CreateMessage ListMessages PinMessage UnpinMessage],
@@ -179,7 +215,10 @@ class ServiceGenerator
179
215
  'Search' => 'search',
180
216
  'CreateProjectFromTemplate' => 'create_project',
181
217
  'GetProjectConstruction' => 'get_construction',
182
- 'GetTemplateLibrary' => 'get_library',
218
+ 'GetTemplateLibraryTodolists' => 'get_library_todolists',
219
+ 'GetTemplateLibraryCardTables' => 'get_library_card_tables',
220
+ 'CreateTemplateLibraryCardTable' => 'create_library_card_table',
221
+ 'CreateTemplateLibraryTodolist' => 'create_library_todolist',
183
222
  'CreateTemplateLibraryCopy' => 'create_library_copy',
184
223
  'GetTemplateLibraryCopy' => 'get_library_copy',
185
224
  'GetRecordingTimesheet' => 'for_recording',
@@ -323,7 +362,7 @@ class ServiceGenerator
323
362
  lineupmarker clientapproval clientapprovals clientcorrespondence
324
363
  clientcorrespondences clientreply clientreplies forwardreply
325
364
  forwardreplies campfireline campfirelines todolistgroup todolistgroups
326
- todolistorgroup uploadversions hillchart hillcharts
365
+ todolistorgroup uploadversions hillchart hillcharts subtask subtasks
327
366
  wormhole wormholes
328
367
  ].freeze
329
368
 
@@ -366,14 +405,78 @@ class ServiceGenerator
366
405
 
367
406
  private
368
407
 
408
+ # Yields [verb, operation] for every operation in a path item, identifying an
409
+ # operation by what it is NOT (see NON_OPERATION_FIELDS). Anything this
410
+ # generator cannot render aborts the run naming the operation.
411
+ def each_operation(path, path_item)
412
+ unless path_item.is_a?(Hash)
413
+ abort "Error: openapi.json path #{path} is a #{path_item.class}, not a path item object."
414
+ end
415
+
416
+ # A `$ref` path item points at operations this generator cannot see without
417
+ # resolving the reference. Skipping it is the same silent under-count the
418
+ # exclusion walk exists to prevent, so refuse until someone teaches it to
419
+ # follow one.
420
+ if path_item.key?('$ref')
421
+ abort "Error: openapi.json path #{path} is a $ref to #{path_item['$ref'].inspect}. " \
422
+ 'This generator cannot resolve a path-item reference, and skipping it would hide ' \
423
+ 'every operation behind it from the SDK. Inline the path item, or teach this ' \
424
+ 'generator to resolve local references.'
425
+ end
426
+
427
+ # OpenAPI 3.2's `additionalOperations` is a MAP of method to Operation, not an
428
+ # operation. Read as one it carries no operationId, so it would drop every
429
+ # operation inside it without saying so. Refuse by name until the walk learns
430
+ # the map shape.
431
+ if path_item.key?('additionalOperations')
432
+ abort "Error: openapi.json path #{path} declares `additionalOperations`, which OpenAPI 3.2 " \
433
+ 'defines as a map of method to Operation. This walk reads a path-item field as a ' \
434
+ 'single operation, so it would drop every operation inside it. Teach the walk the map ' \
435
+ 'shape, or take the field out of the spec.'
436
+ end
437
+
438
+ fields = path_item.keys.reject { |f| NON_OPERATION_FIELDS.include?(f) || f.start_with?('x-') }
439
+ fields.sort_by! { |f| [ EMITTABLE_METHODS.index(f) || EMITTABLE_METHODS.length, f ] }
440
+
441
+ fields.each do |field|
442
+ operation = path_item[field]
443
+
444
+ unless operation.is_a?(Hash)
445
+ abort "Error: openapi.json path #{path} field #{field.inspect} is a #{operation.class}, " \
446
+ 'which is neither a known non-operation field nor an operation object. If a later ' \
447
+ 'OpenAPI version added it, add it to NON_OPERATION_FIELDS with a reason.'
448
+ end
449
+
450
+ unless EMITTABLE_METHODS.include?(field)
451
+ op_id = operation['operationId'] || '(no operationId)'
452
+ abort "Error: openapi.json declares #{field.upcase} #{path} (#{op_id}), and this generator " \
453
+ "emits only #{EMITTABLE_METHODS.map(&:upcase).join('/')}. Generating the rest of the " \
454
+ 'SDK without it would drop the operation from every Ruby client in silence, which is ' \
455
+ 'the failure #925 closed. Give Basecamp::Http a ' \
456
+ "http_#{field} helper, add #{field.inspect} to spec/generated-verbs.json (read that " \
457
+ 'file first — the other five SDKs need the same helper), or take the operation out ' \
458
+ 'of the Smithy model.'
459
+ end
460
+
461
+ # An operation has to be IDENTIFIABLE. OpenAPI lets operationId be omitted,
462
+ # and every walker here used to step over one that was — a silent drop of a
463
+ # real operation, which is #925 wearing a different field.
464
+ op_id = operation['operationId']
465
+ unless op_id.is_a?(String) && !op_id.empty?
466
+ abort "Error: openapi.json declares #{field.upcase} #{path} with no operationId. " \
467
+ 'Everything downstream is keyed by it, and skipping the operation would drop it ' \
468
+ 'from the SDK in silence.'
469
+ end
470
+
471
+ yield field, operation
472
+ end
473
+ end
474
+
369
475
  def group_operations
370
476
  services = {}
371
477
 
372
478
  @openapi['paths'].each do |path, path_item|
373
- METHODS.each do |method|
374
- operation = path_item[method]
375
- next unless operation
376
-
479
+ each_operation(path, path_item) do |method, operation|
377
480
  tag = operation['tags']&.first || 'Untagged'
378
481
  parsed = parse_operation(path, method, operation)
379
482
 
@@ -692,7 +795,7 @@ class ServiceGenerator
692
795
  op[:path_params].each do |p|
693
796
  ruby_name = to_snake_case(p[:name])
694
797
  type = p[:type] || 'Integer'
695
- desc = p[:description] || "#{ruby_name.gsub('_', ' ')} ID"
798
+ desc = yard_param_description(p[:description] || "#{ruby_name.gsub('_', ' ')} ID")
696
799
  lines << " # @param #{ruby_name} [#{type}] #{desc}"
697
800
  end
698
801
 
@@ -132,6 +132,59 @@ def deprecation_doc_lines(reason, indent:, tag_prefix: '')
132
132
  out
133
133
  end
134
134
 
135
+ # Whether the main loop emits a class for this component. The alias pass checks
136
+ # its targets against exactly this predicate, so an alias can never name a
137
+ # class that was not emitted (a *RequestContent target used to produce
138
+ # `Alias = CreateCardStepRequestContent`, a NameError the moment types.rb loads).
139
+ def emits_class?(name, schema)
140
+ return false if SKIP_PATTERNS.any? { |p| name.match?(p) }
141
+
142
+ schema.is_a?(Hash) && schema['type'] == 'object' && !(schema['properties'] || {}).empty?
143
+ end
144
+
145
+ DEPRECATED_ALIAS_KEYS = %w[$ref deprecated description x-deprecated-reason].freeze
146
+
147
+ # A component that is nothing but a deprecated $ref: a rename kept for
148
+ # compatibility, e.g. CardStep -> Subtask. `deprecated` must be the JSON boolean
149
+ # true, as in every other generator; 1 or "false" is not an alias.
150
+ def deprecated_alias?(schema)
151
+ schema.is_a?(Hash) && schema['deprecated'] == true && schema['$ref'].is_a?(String) &&
152
+ (schema.keys - DEPRECATED_ALIAS_KEYS).empty?
153
+ end
154
+
155
+ # Constants Basecamp::Types already holds besides the generated classes.
156
+ RESERVED_TYPE_CONSTANTS = %w[TypeHelpers].freeze
157
+
158
+ # Every deprecated alias as [alias, target], sorted by alias. Aborts on one this
159
+ # generator cannot place, the same cases every SDK generator refuses: a missing
160
+ # target, an alias of an alias (rejected, not resolved), a target that is not an
161
+ # object model, a target this generator does not emit, and a name collision.
162
+ def deprecated_aliases(schemas)
163
+ schemas.keys.sort.filter_map do |name|
164
+ schema = schemas[name]
165
+ next unless deprecated_alias?(schema)
166
+
167
+ target = schema['$ref'].split('/').last
168
+ target_schema = schemas[target]
169
+ problem =
170
+ if !schemas.key?(target)
171
+ 'target schema does not exist'
172
+ elsif deprecated_alias?(target_schema)
173
+ "target is itself a deprecated alias; point #{name} at the model directly"
174
+ elsif !(target_schema.is_a?(Hash) && target_schema['type'] == 'object' &&
175
+ !(target_schema['properties'] || {}).empty?)
176
+ 'target is not an object model'
177
+ elsif !emits_class?(target, target_schema)
178
+ 'target class was not emitted (SKIP_PATTERNS excludes it)'
179
+ elsif RESERVED_TYPE_CONSTANTS.include?(name)
180
+ "#{name} collides with an existing constant in Basecamp::Types"
181
+ end
182
+ abort "Error: deprecated alias #{name} -> #{target}: #{problem}" if problem
183
+
184
+ [ name, target ]
185
+ end
186
+ end
187
+
135
188
  # Main execution
136
189
  if __FILE__ == $PROGRAM_NAME
137
190
  openapi_path = ARGV[0] || File.expand_path('../../openapi.json', __dir__)
@@ -141,6 +194,13 @@ if __FILE__ == $PROGRAM_NAME
141
194
  exit 1
142
195
  end
143
196
 
197
+ # UTF-8 regardless of process locale — see generate-metadata.rb
198
+ schemas = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))['components']['schemas'] || {}
199
+ sorted = schemas.keys.sort
200
+ # Validated before the first line is printed, so a refusal leaves no partial
201
+ # types.rb on stdout.
202
+ aliases = deprecated_aliases(schemas)
203
+
144
204
  puts header
145
205
  puts generate_helpers
146
206
  puts ''
@@ -148,18 +208,11 @@ if __FILE__ == $PROGRAM_NAME
148
208
  puts ' module Types'
149
209
  puts ' include TypeHelpers'
150
210
 
151
- # UTF-8 regardless of process locale — see generate-metadata.rb
152
- schemas = JSON.parse(File.read(openapi_path, encoding: 'UTF-8'))['components']['schemas'] || {}
153
- sorted = schemas.keys.sort
154
-
155
211
  sorted.each do |name|
156
- next if SKIP_PATTERNS.any? { |p| name.match?(p) }
157
-
158
212
  schema = schemas[name]
159
- next unless schema['type'] == 'object'
213
+ next unless emits_class?(name, schema)
160
214
 
161
- properties = schema['properties'] || {}
162
- next if properties.empty?
215
+ properties = schema['properties']
163
216
 
164
217
  required_fields = schema['required'] || []
165
218
  required_set = required_fields.to_set
@@ -273,6 +326,17 @@ if __FILE__ == $PROGRAM_NAME
273
326
  puts ' end'
274
327
  end
275
328
 
329
+ # Deprecated former names (see deprecated_aliases), emitted after every class
330
+ # because the alias must reference an already-defined constant. The alias
331
+ # also keeps Basecamp::Types.const_get("CardStep") resolving.
332
+ aliases.each do |name, target|
333
+ schema = schemas[name]
334
+ puts ''
335
+ puts " # #{name}"
336
+ puts deprecation_doc_lines(schema['x-deprecated-reason'] || 'deprecated', indent: ' ')
337
+ puts " #{name} = #{target}"
338
+ end
339
+
276
340
  puts ' end'
277
341
  puts 'end'
278
342
  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.19.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Basecamp
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-16 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -183,7 +183,6 @@ files:
183
183
  - lib/basecamp/generated/metadata.json
184
184
  - lib/basecamp/generated/services/account_service.rb
185
185
  - lib/basecamp/generated/services/attachments_service.rb
186
- - lib/basecamp/generated/services/automation_service.rb
187
186
  - lib/basecamp/generated/services/base_service.rb
188
187
  - lib/basecamp/generated/services/bookmarks_service.rb
189
188
  - lib/basecamp/generated/services/boosts_service.rb
@@ -225,6 +224,7 @@ files:
225
224
  - lib/basecamp/generated/services/schedules_service.rb
226
225
  - lib/basecamp/generated/services/search_service.rb
227
226
  - lib/basecamp/generated/services/subscriptions_service.rb
227
+ - lib/basecamp/generated/services/subtasks_service.rb
228
228
  - lib/basecamp/generated/services/templates_service.rb
229
229
  - lib/basecamp/generated/services/timeline_service.rb
230
230
  - lib/basecamp/generated/services/timesheets_service.rb
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Basecamp
4
- module Services
5
- # Service for Automation operations
6
- #
7
- # @generated from OpenAPI spec
8
- class AutomationService < BaseService
9
-
10
- # List all lineup markers for the account
11
- # @return [Array<Hash>] response data
12
- def list_lineup_markers()
13
- with_operation(service: "automation", operation: "list_lineup_markers", is_mutation: false) do
14
- http_get("/lineup/markers.json", operation: "ListLineupMarkers").json(operation: "ListLineupMarkers")
15
- end
16
- end
17
- end
18
- end
19
- end