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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Auto-generated from OpenAPI spec. Do not edit manually.
4
- # Generated: 2026-09-16T11:13:05Z
4
+ # Generated: 2026-09-30T18:52:50Z
5
5
 
6
6
  require "json"
7
7
  require "time"
@@ -594,7 +594,7 @@ module Basecamp
594
594
  # Card
595
595
  class Card
596
596
  include TypeHelpers
597
- attr_accessor :app_url, :bucket, :created_at, :creator, :description_attachments, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :assignees, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completed_at, :completer, :completion_subscribers, :completion_url, :content, :description, :due_on, :position, :steps, :subscription_url
597
+ attr_accessor :app_url, :bucket, :created_at, :creator, :description_attachments, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :assignees, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completed_at, :completer, :completion_subscribers, :completion_url, :content, :description, :due_on, :position, :steps, :subscription_url, :subtasks_completed_count, :subtasks_count, :subtasks_url
598
598
 
599
599
  # @return [Array<Symbol>]
600
600
  def self.required_fields
@@ -631,8 +631,11 @@ module Basecamp
631
631
  @description = data["description"]
632
632
  @due_on = data["due_on"]
633
633
  @position = parse_integer(data["position"])
634
- @steps = parse_array(data["steps"], "CardStep")
634
+ @steps = parse_array(data["steps"], "Subtask")
635
635
  @subscription_url = data["subscription_url"]
636
+ @subtasks_completed_count = parse_integer(data["subtasks_completed_count"])
637
+ @subtasks_count = parse_integer(data["subtasks_count"])
638
+ @subtasks_url = data["subtasks_url"]
636
639
  end
637
640
 
638
641
  def to_h
@@ -668,6 +671,9 @@ module Basecamp
668
671
  "position" => @position,
669
672
  "steps" => @steps,
670
673
  "subscription_url" => @subscription_url,
674
+ "subtasks_completed_count" => @subtasks_completed_count,
675
+ "subtasks_count" => @subtasks_count,
676
+ "subtasks_url" => @subtasks_url,
671
677
  }.compact
672
678
  end
673
679
 
@@ -782,75 +788,10 @@ module Basecamp
782
788
  end
783
789
  end
784
790
 
785
- # CardStep
786
- class CardStep
787
- include TypeHelpers
788
- attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :assignees, :bookmark_url, :completed, :completed_at, :completer, :completion_url, :due_on, :position
789
-
790
- # @return [Array<Symbol>]
791
- def self.required_fields
792
- %i[app_url bucket created_at creator id inherits_status parent status title type updated_at url visible_to_clients].freeze
793
- end
794
-
795
- def initialize(data = {})
796
- @app_url = data["app_url"]
797
- @bucket = parse_type(data["bucket"], "TodoBucket")
798
- @created_at = parse_datetime(data["created_at"])
799
- @creator = parse_type(data["creator"], "Person")
800
- @id = parse_integer(data["id"])
801
- @inherits_status = parse_boolean(data["inherits_status"])
802
- @parent = parse_type(data["parent"], "RecordingParent")
803
- @status = data["status"]
804
- @title = data["title"]
805
- @type = data["type"]
806
- @updated_at = parse_datetime(data["updated_at"])
807
- @url = data["url"]
808
- @visible_to_clients = parse_boolean(data["visible_to_clients"])
809
- @assignees = parse_array(data["assignees"], "Person")
810
- @bookmark_url = data["bookmark_url"]
811
- @completed = parse_boolean(data["completed"])
812
- @completed_at = parse_datetime(data["completed_at"])
813
- @completer = parse_type(data["completer"], "Person")
814
- @completion_url = data["completion_url"]
815
- @due_on = data["due_on"]
816
- @position = parse_integer(data["position"])
817
- end
818
-
819
- def to_h
820
- {
821
- "app_url" => @app_url,
822
- "bucket" => @bucket,
823
- "created_at" => @created_at,
824
- "creator" => @creator,
825
- "id" => @id,
826
- "inherits_status" => @inherits_status,
827
- "parent" => @parent,
828
- "status" => @status,
829
- "title" => @title,
830
- "type" => @type,
831
- "updated_at" => @updated_at,
832
- "url" => @url,
833
- "visible_to_clients" => @visible_to_clients,
834
- "assignees" => @assignees,
835
- "bookmark_url" => @bookmark_url,
836
- "completed" => @completed,
837
- "completed_at" => @completed_at,
838
- "completer" => @completer,
839
- "completion_url" => @completion_url,
840
- "due_on" => @due_on,
841
- "position" => @position,
842
- }.compact
843
- end
844
-
845
- def to_json(*args)
846
- to_h.to_json(*args)
847
- end
848
- end
849
-
850
791
  # CardTable
851
792
  class CardTable
852
793
  include TypeHelpers
853
- attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :lists, :subscribers, :subscription_url, :wormholes
794
+ attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :lists, :parent, :position, :public_link_url, :subscribers, :subscription_url, :wormholes
854
795
 
855
796
  # @return [Array<Symbol>]
856
797
  def self.required_fields
@@ -872,6 +813,9 @@ module Basecamp
872
813
  @visible_to_clients = parse_boolean(data["visible_to_clients"])
873
814
  @bookmark_url = data["bookmark_url"]
874
815
  @lists = parse_array(data["lists"], "CardColumn")
816
+ @parent = parse_type(data["parent"], "RecordingParent")
817
+ @position = parse_integer(data["position"])
818
+ @public_link_url = data["public_link_url"]
875
819
  @subscribers = parse_array(data["subscribers"], "Person")
876
820
  @subscription_url = data["subscription_url"]
877
821
  @wormholes = parse_array(data["wormholes"], "Wormhole")
@@ -893,6 +837,9 @@ module Basecamp
893
837
  "visible_to_clients" => @visible_to_clients,
894
838
  "bookmark_url" => @bookmark_url,
895
839
  "lists" => @lists,
840
+ "parent" => @parent,
841
+ "position" => @position,
842
+ "public_link_url" => @public_link_url,
896
843
  "subscribers" => @subscribers,
897
844
  "subscription_url" => @subscription_url,
898
845
  "wormholes" => @wormholes,
@@ -3819,7 +3766,7 @@ module Basecamp
3819
3766
  # Recording
3820
3767
  class Recording
3821
3768
  include TypeHelpers
3822
- attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :boosts_count, :boosts_url, :bubble_up_url, :category, :comments_count, :comments_url, :content, :content_attachments, :description, :description_attachments, :from, :group_on, :parent, :position, :replies_count, :replies_url, :service, :subject, :subscription_url
3769
+ attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :boosts_count, :boosts_url, :bubble_up_url, :category, :comments_count, :comments_url, :content, :content_attachments, :description, :description_attachments, :from, :group_on, :parent, :position, :replies_count, :replies_url, :service, :subject, :subscription_url, :subtasks_completed_count, :subtasks_count, :subtasks_url
3823
3770
 
3824
3771
  # @return [Array<Symbol>]
3825
3772
  def self.required_fields
@@ -3859,6 +3806,9 @@ module Basecamp
3859
3806
  @service = parse_type(data["service"], "DoorService")
3860
3807
  @subject = data["subject"]
3861
3808
  @subscription_url = data["subscription_url"]
3809
+ @subtasks_completed_count = parse_integer(data["subtasks_completed_count"])
3810
+ @subtasks_count = parse_integer(data["subtasks_count"])
3811
+ @subtasks_url = data["subtasks_url"]
3862
3812
  end
3863
3813
 
3864
3814
  def to_h
@@ -3895,6 +3845,9 @@ module Basecamp
3895
3845
  "service" => @service,
3896
3846
  "subject" => @subject,
3897
3847
  "subscription_url" => @subscription_url,
3848
+ "subtasks_completed_count" => @subtasks_completed_count,
3849
+ "subtasks_count" => @subtasks_count,
3850
+ "subtasks_url" => @subtasks_url,
3898
3851
  }.compact
3899
3852
  end
3900
3853
 
@@ -4462,6 +4415,71 @@ module Basecamp
4462
4415
  end
4463
4416
  end
4464
4417
 
4418
+ # Subtask
4419
+ class Subtask
4420
+ include TypeHelpers
4421
+ attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :assignees, :bookmark_url, :completed, :completed_at, :completer, :completion_url, :due_on, :position
4422
+
4423
+ # @return [Array<Symbol>]
4424
+ def self.required_fields
4425
+ %i[app_url bucket created_at creator id inherits_status parent status title type updated_at url visible_to_clients].freeze
4426
+ end
4427
+
4428
+ def initialize(data = {})
4429
+ @app_url = data["app_url"]
4430
+ @bucket = parse_type(data["bucket"], "TodoBucket")
4431
+ @created_at = parse_datetime(data["created_at"])
4432
+ @creator = parse_type(data["creator"], "Person")
4433
+ @id = parse_integer(data["id"])
4434
+ @inherits_status = parse_boolean(data["inherits_status"])
4435
+ @parent = parse_type(data["parent"], "RecordingParent")
4436
+ @status = data["status"]
4437
+ @title = data["title"]
4438
+ @type = data["type"]
4439
+ @updated_at = parse_datetime(data["updated_at"])
4440
+ @url = data["url"]
4441
+ @visible_to_clients = parse_boolean(data["visible_to_clients"])
4442
+ @assignees = parse_array(data["assignees"], "Person")
4443
+ @bookmark_url = data["bookmark_url"]
4444
+ @completed = parse_boolean(data["completed"])
4445
+ @completed_at = parse_datetime(data["completed_at"])
4446
+ @completer = parse_type(data["completer"], "Person")
4447
+ @completion_url = data["completion_url"]
4448
+ @due_on = data["due_on"]
4449
+ @position = parse_integer(data["position"])
4450
+ end
4451
+
4452
+ def to_h
4453
+ {
4454
+ "app_url" => @app_url,
4455
+ "bucket" => @bucket,
4456
+ "created_at" => @created_at,
4457
+ "creator" => @creator,
4458
+ "id" => @id,
4459
+ "inherits_status" => @inherits_status,
4460
+ "parent" => @parent,
4461
+ "status" => @status,
4462
+ "title" => @title,
4463
+ "type" => @type,
4464
+ "updated_at" => @updated_at,
4465
+ "url" => @url,
4466
+ "visible_to_clients" => @visible_to_clients,
4467
+ "assignees" => @assignees,
4468
+ "bookmark_url" => @bookmark_url,
4469
+ "completed" => @completed,
4470
+ "completed_at" => @completed_at,
4471
+ "completer" => @completer,
4472
+ "completion_url" => @completion_url,
4473
+ "due_on" => @due_on,
4474
+ "position" => @position,
4475
+ }.compact
4476
+ end
4477
+
4478
+ def to_json(*args)
4479
+ to_h.to_json(*args)
4480
+ end
4481
+ end
4482
+
4465
4483
  # Template
4466
4484
  class Template
4467
4485
  include TypeHelpers
@@ -4503,27 +4521,27 @@ module Basecamp
4503
4521
  end
4504
4522
  end
4505
4523
 
4506
- # TemplateLibrary
4507
- class TemplateLibrary
4524
+ # TemplateLibraryCardTables
4525
+ class TemplateLibraryCardTables
4508
4526
  include TypeHelpers
4509
- attr_accessor :bucket, :todolists, :todoset
4527
+ attr_accessor :bucket, :card_tables, :kanban_boardset
4510
4528
 
4511
4529
  # @return [Array<Symbol>]
4512
4530
  def self.required_fields
4513
- %i[bucket todolists todoset].freeze
4531
+ %i[bucket card_tables kanban_boardset].freeze
4514
4532
  end
4515
4533
 
4516
4534
  def initialize(data = {})
4517
4535
  @bucket = parse_type(data["bucket"], "RecordingBucket")
4518
- @todolists = parse_array(data["todolists"], "Todolist")
4519
- @todoset = parse_type(data["todoset"], "RecordingParent")
4536
+ @card_tables = parse_array(data["card_tables"], "Recording")
4537
+ @kanban_boardset = data["kanban_boardset"]
4520
4538
  end
4521
4539
 
4522
4540
  def to_h
4523
4541
  {
4524
4542
  "bucket" => @bucket,
4525
- "todolists" => @todolists,
4526
- "todoset" => @todoset,
4543
+ "card_tables" => @card_tables,
4544
+ "kanban_boardset" => @kanban_boardset,
4527
4545
  }.compact
4528
4546
  end
4529
4547
 
@@ -4564,7 +4582,7 @@ module Basecamp
4564
4582
  # TemplateLibraryCopy
4565
4583
  class TemplateLibraryCopy
4566
4584
  include TypeHelpers
4567
- attr_accessor :destination_parent_id, :id, :source_recording_id, :status, :url, :destination_todolist
4585
+ attr_accessor :destination_parent_id, :id, :source_recording_id, :status, :url, :destination_card_table, :destination_todolist
4568
4586
 
4569
4587
  # @return [Array<Symbol>]
4570
4588
  def self.required_fields
@@ -4577,6 +4595,7 @@ module Basecamp
4577
4595
  @source_recording_id = parse_integer(data["source_recording_id"])
4578
4596
  @status = data["status"]
4579
4597
  @url = data["url"]
4598
+ @destination_card_table = parse_type(data["destination_card_table"], "CardTable")
4580
4599
  @destination_todolist = parse_type(data["destination_todolist"], "Todolist")
4581
4600
  end
4582
4601
 
@@ -4587,6 +4606,71 @@ module Basecamp
4587
4606
  "source_recording_id" => @source_recording_id,
4588
4607
  "status" => @status,
4589
4608
  "url" => @url,
4609
+ "destination_card_table" => @destination_card_table,
4610
+ "destination_todolist" => @destination_todolist,
4611
+ }.compact
4612
+ end
4613
+
4614
+ def to_json(*args)
4615
+ to_h.to_json(*args)
4616
+ end
4617
+ end
4618
+
4619
+ # TemplateLibraryTodolists
4620
+ class TemplateLibraryTodolists
4621
+ include TypeHelpers
4622
+ attr_accessor :bucket, :todolists, :todoset
4623
+
4624
+ # @return [Array<Symbol>]
4625
+ def self.required_fields
4626
+ %i[bucket todolists todoset].freeze
4627
+ end
4628
+
4629
+ def initialize(data = {})
4630
+ @bucket = parse_type(data["bucket"], "RecordingBucket")
4631
+ @todolists = parse_array(data["todolists"], "Todolist")
4632
+ @todoset = parse_type(data["todoset"], "RecordingParent")
4633
+ end
4634
+
4635
+ def to_h
4636
+ {
4637
+ "bucket" => @bucket,
4638
+ "todolists" => @todolists,
4639
+ "todoset" => @todoset,
4640
+ }.compact
4641
+ end
4642
+
4643
+ def to_json(*args)
4644
+ to_h.to_json(*args)
4645
+ end
4646
+ end
4647
+
4648
+ # Templatification
4649
+ class Templatification
4650
+ include TypeHelpers
4651
+ attr_accessor :id, :source_recording_id, :status, :url, :destination_card_table, :destination_todolist
4652
+
4653
+ # @return [Array<Symbol>]
4654
+ def self.required_fields
4655
+ %i[id source_recording_id status url].freeze
4656
+ end
4657
+
4658
+ def initialize(data = {})
4659
+ @id = parse_integer(data["id"])
4660
+ @source_recording_id = parse_integer(data["source_recording_id"])
4661
+ @status = data["status"]
4662
+ @url = data["url"]
4663
+ @destination_card_table = parse_type(data["destination_card_table"], "CardTable")
4664
+ @destination_todolist = parse_type(data["destination_todolist"], "Todolist")
4665
+ end
4666
+
4667
+ def to_h
4668
+ {
4669
+ "id" => @id,
4670
+ "source_recording_id" => @source_recording_id,
4671
+ "status" => @status,
4672
+ "url" => @url,
4673
+ "destination_card_table" => @destination_card_table,
4590
4674
  "destination_todolist" => @destination_todolist,
4591
4675
  }.compact
4592
4676
  end
@@ -4827,7 +4911,7 @@ module Basecamp
4827
4911
  # Todo
4828
4912
  class Todo
4829
4913
  include TypeHelpers
4830
- attr_accessor :app_url, :bucket, :content, :created_at, :creator, :description_attachments, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :assignees, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completion_subscribers, :completion_url, :description, :due_on, :position, :starts_on, :steps, :subscription_url
4914
+ attr_accessor :app_url, :bucket, :content, :created_at, :creator, :description_attachments, :id, :inherits_status, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :assignees, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completion_subscribers, :completion_url, :description, :due_on, :position, :starts_on, :steps, :subscription_url, :subtasks_completed_count, :subtasks_count, :subtasks_url
4831
4915
 
4832
4916
  # @return [Array<Symbol>]
4833
4917
  def self.required_fields
@@ -4863,8 +4947,11 @@ module Basecamp
4863
4947
  @due_on = data["due_on"]
4864
4948
  @position = parse_integer(data["position"])
4865
4949
  @starts_on = data["starts_on"]
4866
- @steps = parse_array(data["steps"], "CardStep")
4950
+ @steps = parse_array(data["steps"], "Subtask")
4867
4951
  @subscription_url = data["subscription_url"]
4952
+ @subtasks_completed_count = parse_integer(data["subtasks_completed_count"])
4953
+ @subtasks_count = parse_integer(data["subtasks_count"])
4954
+ @subtasks_url = data["subtasks_url"]
4868
4955
  end
4869
4956
 
4870
4957
  def to_h
@@ -4899,6 +4986,9 @@ module Basecamp
4899
4986
  "starts_on" => @starts_on,
4900
4987
  "steps" => @steps,
4901
4988
  "subscription_url" => @subscription_url,
4989
+ "subtasks_completed_count" => @subtasks_completed_count,
4990
+ "subtasks_count" => @subtasks_count,
4991
+ "subtasks_url" => @subtasks_url,
4902
4992
  }.compact
4903
4993
  end
4904
4994
 
@@ -5875,5 +5965,9 @@ module Basecamp
5875
5965
  to_h.to_json(*args)
5876
5966
  end
5877
5967
  end
5968
+
5969
+ # CardStep
5970
+ # @deprecated renamed to Subtask, the same Kanban::Step record.
5971
+ CardStep = Subtask
5878
5972
  end
5879
5973
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.19.0"
5
- API_VERSION = "2026-09-02"
4
+ VERSION = "0.21.0"
5
+ API_VERSION = "2026-09-15"
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)