basecamp-sdk 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c53198686c7bf2b7f9fc25f60c14dec77dada2f3bbf7b6070f9a3e0639f7b529
4
- data.tar.gz: f25233709c5a99476ed38728cf9ed005eb55e39457cc6dd4ea3250ac72583e60
3
+ metadata.gz: a1df4a43587230e1bfbf0403175de3fdf93b50635c42a91a71d3c2e29bb835c8
4
+ data.tar.gz: c2a9eb831ecd48967fdb706a808fe13a923570f97edcf7b6ed96b7e2372dfcd2
5
5
  SHA512:
6
- metadata.gz: 3d17039b8cddadbbc8ee270f46f0f3e4665651091c0cd0371728693398002dfdc9def64bc081316c496ca477e43a1e2b4e55b65bc53a2d47124694989d5a0437
7
- data.tar.gz: 9bf77edeae08a13c60d1f25f3e9d16ad9d39298dffa7cc430fa319aee4ecfa208d62c1c3ccec629473058ac9b48569faea8190b4373313923caa48556a1ab83a
6
+ metadata.gz: 25fb53f7d538f519dfd7f7091e687dec7a0f9ce59f65df4c612f44fcea5cf2f3d65d8b8257dcccb8a0bc2b535e340efe911b9f435ef54f022ac0f9752953f6d5
7
+ data.tar.gz: 6a7f120b233a4d4dc07f5a233c97ee4c8fb3861181143bdd492a7e48998903eb8e0a465b6cfc8816511ef03ecfa0657c0af951b05f3d5a761019130332401a27
@@ -516,11 +516,6 @@ module Basecamp
516
516
  service(:lineup) { Services::LineupService.new(self) }
517
517
  end
518
518
 
519
- # @return [Services::AutomationService]
520
- def automation
521
- service(:automation) { Services::AutomationService.new(self) }
522
- end
523
-
524
519
  # @return [Services::MessageTypesService]
525
520
  def message_types
526
521
  service(:message_types) { Services::MessageTypesService.new(self) }
@@ -7,6 +7,14 @@ module Basecamp
7
7
  # @generated from OpenAPI spec
8
8
  class LineupService < BaseService
9
9
 
10
+ # List all lineup markers for the account
11
+ # @return [Array<Hash>] response data
12
+ def list_lineup_markers()
13
+ with_operation(service: "lineup", operation: "list_lineup_markers", is_mutation: false) do
14
+ http_get("/lineup/markers.json", operation: "ListLineupMarkers").json(operation: "ListLineupMarkers")
15
+ end
16
+ end
17
+
10
18
  # Create a new lineup marker
11
19
  # @param name [String] name
12
20
  # @param date [String] date
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.19.0"
4
+ VERSION = "0.20.0"
5
5
  API_VERSION = "2026-09-02"
6
6
  end
@@ -13,7 +13,46 @@ require 'time'
13
13
 
14
14
  # Extract metadata from OpenAPI spec
15
15
  class MetadataExtractor
16
- METHODS = %w[get post put patch delete].freeze
16
+ # A Path Item Object is read by EXCLUSION: its non-operation fields are a
17
+ # closed, spec-defined set, its extensions are `x-` prefixed, and every other
18
+ # field is an operation. A verb list here dropped an operation's metadata in
19
+ # silence for any method outside the five it knew (#925). Nothing in this
20
+ # file is verb-specific — metadata is keyed by operationId — so the walk is
21
+ # total and no verb bound belongs here.
22
+ NON_OPERATION_FIELDS = %w[summary description servers parameters].freeze
23
+
24
+ # Membership is decided above, by exclusion. The shared declaration every
25
+ # generator reads (spec/generated-verbs.json) is ORDERED, and that order is
26
+ # reused here to keep metadata.json byte-stable. Order is not membership: a
27
+ # verb absent from the declaration sorts deterministically to the end by name
28
+ # and is still extracted, because nothing in this file is verb-specific.
29
+ # The self-test points this at a crafted declaration to prove the bound is
30
+ # sourced from the shared file rather than a private literal; production runs
31
+ # never set it.
32
+ GENERATED_VERBS_FILE = ENV.fetch(
33
+ 'BASECAMP_GENERATED_VERBS', File.expand_path('../../spec/generated-verbs.json', __dir__)
34
+ )
35
+
36
+ METHOD_ORDER = begin
37
+ verbs = JSON.parse(File.read(GENERATED_VERBS_FILE, encoding: 'UTF-8'))['verbs']
38
+ # An HTTP method is a TOKEN, so the rule is a positive character class rather
39
+ # than a blankness predicate: `[a-z]+`, identical in all six loaders. Two
40
+ # review rounds chased "blank" across languages — Kotlin and Rust rejected a
41
+ # space while the others accepted it, then Ruby's ASCII `strip` accepted a
42
+ # non-breaking space the Unicode-aware ones rejected — and the next
43
+ # disagreement was guaranteed, because every language defines whitespace
44
+ # differently (Java's Character.isWhitespace excludes U+00A0; Rust's
45
+ # char::is_whitespace includes it; Ruby's String#strip is ASCII-only). A
46
+ # closed positive rule has no such seam: "", " ", "\u00a0", "GET" and 1 are
47
+ # all rejected the same way everywhere.
48
+ unless verbs.is_a?(Array) && !verbs.empty? && verbs.all? { |v| v.is_a?(String) && v.match?(/\A[a-z]+\z/) }
49
+ abort "Error: #{GENERATED_VERBS_FILE} must declare a non-empty `verbs` array of lowercase " \
50
+ 'ASCII method names (/\A[a-z]+\z/).'
51
+ end
52
+ verbs.freeze
53
+ rescue Errno::ENOENT, JSON::ParserError => e
54
+ abort "Error: cannot read the generated-verb declaration #{GENERATED_VERBS_FILE}: #{e.message}"
55
+ end
17
56
 
18
57
  def initialize(openapi_path)
19
58
  # Read as UTF-8 regardless of process locale (LC_ALL=C would otherwise read
@@ -24,13 +63,9 @@ class MetadataExtractor
24
63
  def extract
25
64
  operations = {}
26
65
 
27
- (@openapi['paths'] || {}).each_value do |path_item|
28
- METHODS.each do |method|
29
- operation = path_item[method]
30
- next unless operation
31
-
66
+ (@openapi['paths'] || {}).each do |path, path_item|
67
+ each_operation(path, path_item) do |_method, operation|
32
68
  operation_id = operation['operationId']
33
- next unless operation_id
34
69
 
35
70
  metadata = extract_operation_metadata(operation)
36
71
  operations[operation_id] = metadata if metadata.any?
@@ -61,11 +96,8 @@ class MetadataExtractor
61
96
  # in "[]" names an array of people. "$" is the body itself.
62
97
  def person_id_sites
63
98
  sites = {}
64
- (@openapi['paths'] || {}).each_value do |path_item|
65
- METHODS.each do |method|
66
- operation = path_item[method]
67
- next unless operation && operation['operationId']
68
-
99
+ (@openapi['paths'] || {}).each do |path, path_item|
100
+ each_operation(path, path_item) do |_method, operation|
69
101
  paths = (operation['responses'] || {}).flat_map do |code, response|
70
102
  next [] unless code.to_s.start_with?('2')
71
103
 
@@ -117,6 +149,57 @@ class MetadataExtractor
117
149
  raise "person id site under additionalProperties at #{nested.first} has no runtime path syntax" if nested.any?
118
150
  end
119
151
 
152
+ # Yields [verb, operation] for every operation in a path item. A field that is
153
+ # neither a known non-operation field nor a readable operation object stops
154
+ # the run by name rather than being stepped over.
155
+ def each_operation(path, path_item)
156
+ unless path_item.is_a?(Hash)
157
+ abort "Error: openapi.json path #{path} is a #{path_item.class}, not a path item object."
158
+ end
159
+
160
+ if path_item.key?('$ref')
161
+ abort "Error: openapi.json path #{path} is a $ref to #{path_item['$ref'].inspect}. " \
162
+ 'This extractor cannot resolve a path-item reference, and skipping it would leave ' \
163
+ 'every operation behind it without runtime metadata.'
164
+ end
165
+
166
+ # OpenAPI 3.2's `additionalOperations` is a MAP of method to Operation, not an
167
+ # operation. Read as one it carries no operationId, so it would drop every
168
+ # operation inside it without saying so. Refuse by name until the walk learns
169
+ # the map shape.
170
+ if path_item.key?('additionalOperations')
171
+ abort "Error: openapi.json path #{path} declares `additionalOperations`, which OpenAPI 3.2 " \
172
+ 'defines as a map of method to Operation. This walk reads a path-item field as a ' \
173
+ 'single operation, so it would drop every operation inside it. Teach the walk the map ' \
174
+ 'shape, or take the field out of the spec.'
175
+ end
176
+
177
+ fields = path_item.keys.reject { |f| NON_OPERATION_FIELDS.include?(f) || f.start_with?('x-') }
178
+ fields.sort_by! { |f| [ METHOD_ORDER.index(f) || METHOD_ORDER.length, f ] }
179
+
180
+ fields.each do |field|
181
+ operation = path_item[field]
182
+
183
+ unless operation.is_a?(Hash)
184
+ abort "Error: openapi.json path #{path} field #{field.inspect} is a #{operation.class}, " \
185
+ 'which is neither a known non-operation field nor an operation object. If a later ' \
186
+ 'OpenAPI version added it, add it to NON_OPERATION_FIELDS with a reason.'
187
+ end
188
+
189
+ # An operation has to be IDENTIFIABLE. OpenAPI lets operationId be omitted,
190
+ # and every walker here used to step over one that was — a silent drop of a
191
+ # real operation, which is #925 wearing a different field.
192
+ op_id = operation['operationId']
193
+ unless op_id.is_a?(String) && !op_id.empty?
194
+ abort "Error: openapi.json declares #{field.upcase} #{path} with no operationId. " \
195
+ 'Everything downstream is keyed by it, and skipping the operation would drop it ' \
196
+ 'from the SDK in silence.'
197
+ end
198
+
199
+ yield field, operation
200
+ end
201
+ end
202
+
120
203
  private
121
204
 
122
205
  def extract_operation_metadata(operation)
@@ -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,7 +80,6 @@ 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',
39
85
  'Untagged' => 'Miscellaneous'
@@ -72,22 +118,11 @@ class ServiceGenerator
72
118
  'CloudFiles' => %w[GetCloudFile CreateCloudFile UpdateCloudFile],
73
119
  'GoogleDocuments' => %w[GetGoogleDocument CreateGoogleDocument UpdateGoogleDocument]
74
120
  },
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
- ]
121
+ 'Dock' => {
122
+ 'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool]
123
+ },
124
+ 'Recordings' => {
125
+ 'Events' => %w[ListEvents]
91
126
  },
92
127
  'Messages' => {
93
128
  'Messages' => %w[GetMessage UpdateMessage CreateMessage ListMessages PinMessage UnpinMessage],
@@ -366,14 +401,78 @@ class ServiceGenerator
366
401
 
367
402
  private
368
403
 
404
+ # Yields [verb, operation] for every operation in a path item, identifying an
405
+ # operation by what it is NOT (see NON_OPERATION_FIELDS). Anything this
406
+ # generator cannot render aborts the run naming the operation.
407
+ def each_operation(path, path_item)
408
+ unless path_item.is_a?(Hash)
409
+ abort "Error: openapi.json path #{path} is a #{path_item.class}, not a path item object."
410
+ end
411
+
412
+ # A `$ref` path item points at operations this generator cannot see without
413
+ # resolving the reference. Skipping it is the same silent under-count the
414
+ # exclusion walk exists to prevent, so refuse until someone teaches it to
415
+ # follow one.
416
+ if path_item.key?('$ref')
417
+ abort "Error: openapi.json path #{path} is a $ref to #{path_item['$ref'].inspect}. " \
418
+ 'This generator cannot resolve a path-item reference, and skipping it would hide ' \
419
+ 'every operation behind it from the SDK. Inline the path item, or teach this ' \
420
+ 'generator to resolve local references.'
421
+ end
422
+
423
+ # OpenAPI 3.2's `additionalOperations` is a MAP of method to Operation, not an
424
+ # operation. Read as one it carries no operationId, so it would drop every
425
+ # operation inside it without saying so. Refuse by name until the walk learns
426
+ # the map shape.
427
+ if path_item.key?('additionalOperations')
428
+ abort "Error: openapi.json path #{path} declares `additionalOperations`, which OpenAPI 3.2 " \
429
+ 'defines as a map of method to Operation. This walk reads a path-item field as a ' \
430
+ 'single operation, so it would drop every operation inside it. Teach the walk the map ' \
431
+ 'shape, or take the field out of the spec.'
432
+ end
433
+
434
+ fields = path_item.keys.reject { |f| NON_OPERATION_FIELDS.include?(f) || f.start_with?('x-') }
435
+ fields.sort_by! { |f| [ EMITTABLE_METHODS.index(f) || EMITTABLE_METHODS.length, f ] }
436
+
437
+ fields.each do |field|
438
+ operation = path_item[field]
439
+
440
+ unless operation.is_a?(Hash)
441
+ abort "Error: openapi.json path #{path} field #{field.inspect} is a #{operation.class}, " \
442
+ 'which is neither a known non-operation field nor an operation object. If a later ' \
443
+ 'OpenAPI version added it, add it to NON_OPERATION_FIELDS with a reason.'
444
+ end
445
+
446
+ unless EMITTABLE_METHODS.include?(field)
447
+ op_id = operation['operationId'] || '(no operationId)'
448
+ abort "Error: openapi.json declares #{field.upcase} #{path} (#{op_id}), and this generator " \
449
+ "emits only #{EMITTABLE_METHODS.map(&:upcase).join('/')}. Generating the rest of the " \
450
+ 'SDK without it would drop the operation from every Ruby client in silence, which is ' \
451
+ 'the failure #925 closed. Give Basecamp::Http a ' \
452
+ "http_#{field} helper, add #{field.inspect} to spec/generated-verbs.json (read that " \
453
+ 'file first — the other five SDKs need the same helper), or take the operation out ' \
454
+ 'of the Smithy model.'
455
+ end
456
+
457
+ # An operation has to be IDENTIFIABLE. OpenAPI lets operationId be omitted,
458
+ # and every walker here used to step over one that was — a silent drop of a
459
+ # real operation, which is #925 wearing a different field.
460
+ op_id = operation['operationId']
461
+ unless op_id.is_a?(String) && !op_id.empty?
462
+ abort "Error: openapi.json declares #{field.upcase} #{path} with no operationId. " \
463
+ 'Everything downstream is keyed by it, and skipping the operation would drop it ' \
464
+ 'from the SDK in silence.'
465
+ end
466
+
467
+ yield field, operation
468
+ end
469
+ end
470
+
369
471
  def group_operations
370
472
  services = {}
371
473
 
372
474
  @openapi['paths'].each do |path, path_item|
373
- METHODS.each do |method|
374
- operation = path_item[method]
375
- next unless operation
376
-
475
+ each_operation(path, path_item) do |method, operation|
377
476
  tag = operation['tags']&.first || 'Untagged'
378
477
  parsed = parse_operation(path, method, operation)
379
478
 
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.20.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-20 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
@@ -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