basecamp-sdk 0.2.1

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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +14 -0
  3. data/.yardopts +6 -0
  4. data/README.md +293 -0
  5. data/Rakefile +26 -0
  6. data/basecamp-sdk.gemspec +46 -0
  7. data/lib/basecamp/auth_strategy.rb +38 -0
  8. data/lib/basecamp/chain_hooks.rb +45 -0
  9. data/lib/basecamp/client.rb +428 -0
  10. data/lib/basecamp/config.rb +143 -0
  11. data/lib/basecamp/errors.rb +289 -0
  12. data/lib/basecamp/generated/metadata.json +2281 -0
  13. data/lib/basecamp/generated/services/attachments_service.rb +24 -0
  14. data/lib/basecamp/generated/services/boosts_service.rb +70 -0
  15. data/lib/basecamp/generated/services/campfires_service.rb +122 -0
  16. data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
  17. data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
  18. data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
  19. data/lib/basecamp/generated/services/cards_service.rb +66 -0
  20. data/lib/basecamp/generated/services/checkins_service.rb +157 -0
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
  23. data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
  25. data/lib/basecamp/generated/services/comments_service.rb +49 -0
  26. data/lib/basecamp/generated/services/documents_service.rb +52 -0
  27. data/lib/basecamp/generated/services/events_service.rb +20 -0
  28. data/lib/basecamp/generated/services/forwards_service.rb +67 -0
  29. data/lib/basecamp/generated/services/lineup_service.rb +44 -0
  30. data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
  31. data/lib/basecamp/generated/services/message_types_service.rb +59 -0
  32. data/lib/basecamp/generated/services/messages_service.rb +75 -0
  33. data/lib/basecamp/generated/services/people_service.rb +73 -0
  34. data/lib/basecamp/generated/services/projects_service.rb +63 -0
  35. data/lib/basecamp/generated/services/recordings_service.rb +64 -0
  36. data/lib/basecamp/generated/services/reports_service.rb +56 -0
  37. data/lib/basecamp/generated/services/schedules_service.rb +92 -0
  38. data/lib/basecamp/generated/services/search_service.rb +31 -0
  39. data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
  40. data/lib/basecamp/generated/services/templates_service.rb +82 -0
  41. data/lib/basecamp/generated/services/timeline_service.rb +20 -0
  42. data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
  43. data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
  44. data/lib/basecamp/generated/services/todolists_service.rb +53 -0
  45. data/lib/basecamp/generated/services/todos_service.rb +106 -0
  46. data/lib/basecamp/generated/services/todosets_service.rb +20 -0
  47. data/lib/basecamp/generated/services/tools_service.rb +80 -0
  48. data/lib/basecamp/generated/services/uploads_service.rb +61 -0
  49. data/lib/basecamp/generated/services/vaults_service.rb +49 -0
  50. data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
  51. data/lib/basecamp/generated/types.rb +3196 -0
  52. data/lib/basecamp/hooks.rb +70 -0
  53. data/lib/basecamp/http.rb +440 -0
  54. data/lib/basecamp/logger_hooks.rb +46 -0
  55. data/lib/basecamp/noop_hooks.rb +9 -0
  56. data/lib/basecamp/oauth/discovery.rb +123 -0
  57. data/lib/basecamp/oauth/errors.rb +35 -0
  58. data/lib/basecamp/oauth/exchange.rb +291 -0
  59. data/lib/basecamp/oauth/pkce.rb +68 -0
  60. data/lib/basecamp/oauth/types.rb +133 -0
  61. data/lib/basecamp/oauth.rb +56 -0
  62. data/lib/basecamp/oauth_token_provider.rb +108 -0
  63. data/lib/basecamp/operation_info.rb +17 -0
  64. data/lib/basecamp/request_info.rb +10 -0
  65. data/lib/basecamp/request_result.rb +14 -0
  66. data/lib/basecamp/security.rb +112 -0
  67. data/lib/basecamp/services/attachments_service.rb +33 -0
  68. data/lib/basecamp/services/authorization_service.rb +47 -0
  69. data/lib/basecamp/services/base_service.rb +146 -0
  70. data/lib/basecamp/services/campfires_service.rb +141 -0
  71. data/lib/basecamp/services/card_columns_service.rb +106 -0
  72. data/lib/basecamp/services/card_steps_service.rb +86 -0
  73. data/lib/basecamp/services/card_tables_service.rb +23 -0
  74. data/lib/basecamp/services/cards_service.rb +93 -0
  75. data/lib/basecamp/services/checkins_service.rb +127 -0
  76. data/lib/basecamp/services/client_approvals_service.rb +33 -0
  77. data/lib/basecamp/services/client_correspondences_service.rb +33 -0
  78. data/lib/basecamp/services/client_replies_service.rb +35 -0
  79. data/lib/basecamp/services/comments_service.rb +63 -0
  80. data/lib/basecamp/services/documents_service.rb +74 -0
  81. data/lib/basecamp/services/events_service.rb +27 -0
  82. data/lib/basecamp/services/forwards_service.rb +80 -0
  83. data/lib/basecamp/services/lineup_service.rb +67 -0
  84. data/lib/basecamp/services/message_boards_service.rb +24 -0
  85. data/lib/basecamp/services/message_types_service.rb +79 -0
  86. data/lib/basecamp/services/messages_service.rb +133 -0
  87. data/lib/basecamp/services/people_service.rb +73 -0
  88. data/lib/basecamp/services/projects_service.rb +67 -0
  89. data/lib/basecamp/services/recordings_service.rb +127 -0
  90. data/lib/basecamp/services/reports_service.rb +80 -0
  91. data/lib/basecamp/services/schedules_service.rb +156 -0
  92. data/lib/basecamp/services/search_service.rb +36 -0
  93. data/lib/basecamp/services/subscriptions_service.rb +67 -0
  94. data/lib/basecamp/services/templates_service.rb +96 -0
  95. data/lib/basecamp/services/timeline_service.rb +62 -0
  96. data/lib/basecamp/services/timesheet_service.rb +68 -0
  97. data/lib/basecamp/services/todolist_groups_service.rb +100 -0
  98. data/lib/basecamp/services/todolists_service.rb +104 -0
  99. data/lib/basecamp/services/todos_service.rb +156 -0
  100. data/lib/basecamp/services/todosets_service.rb +23 -0
  101. data/lib/basecamp/services/tools_service.rb +89 -0
  102. data/lib/basecamp/services/uploads_service.rb +84 -0
  103. data/lib/basecamp/services/vaults_service.rb +84 -0
  104. data/lib/basecamp/services/webhooks_service.rb +88 -0
  105. data/lib/basecamp/static_token_provider.rb +24 -0
  106. data/lib/basecamp/token_provider.rb +42 -0
  107. data/lib/basecamp/version.rb +6 -0
  108. data/lib/basecamp/webhooks/event.rb +52 -0
  109. data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
  110. data/lib/basecamp/webhooks/receiver.rb +161 -0
  111. data/lib/basecamp/webhooks/verify.rb +36 -0
  112. data/lib/basecamp.rb +107 -0
  113. data/scripts/generate-metadata.rb +106 -0
  114. data/scripts/generate-services.rb +778 -0
  115. data/scripts/generate-types.rb +191 -0
  116. metadata +316 -0
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "receiver"
4
+
5
+ module Basecamp
6
+ module Webhooks
7
+ # Rack middleware that intercepts POST requests to a configurable path
8
+ # and dispatches them to a WebhookReceiver for processing.
9
+ class RackMiddleware
10
+ DEFAULT_PATH = "/webhooks/basecamp"
11
+
12
+ def initialize(app, receiver:, path: DEFAULT_PATH)
13
+ @app = app
14
+ @receiver = receiver
15
+ @path = path
16
+ end
17
+
18
+ def call(env)
19
+ unless env["PATH_INFO"] == @path
20
+ return @app.call(env)
21
+ end
22
+
23
+ unless env["REQUEST_METHOD"] == "POST"
24
+ return [ 405, { "Content-Type" => "text/plain" }, [ "Method Not Allowed" ] ]
25
+ end
26
+
27
+ body = env["rack.input"].read
28
+ env["rack.input"].rewind
29
+
30
+ headers = lambda { |name|
31
+ # Rack normalizes headers to HTTP_UPPER_CASE format
32
+ rack_key = "HTTP_#{name.upcase.tr('-', '_')}"
33
+ env[rack_key]
34
+ }
35
+
36
+ begin
37
+ @receiver.handle_request(raw_body: body, headers: headers)
38
+ [ 200, { "Content-Type" => "text/plain" }, [ "OK" ] ]
39
+ rescue VerificationError
40
+ [ 401, { "Content-Type" => "text/plain" }, [ "Unauthorized" ] ]
41
+ rescue JSON::ParserError
42
+ [ 400, { "Content-Type" => "text/plain" }, [ "Bad Request" ] ]
43
+ rescue StandardError
44
+ [ 500, { "Content-Type" => "text/plain" }, [ "Internal Server Error" ] ]
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "event"
5
+ require_relative "verify"
6
+
7
+ module Basecamp
8
+ module Webhooks
9
+ class VerificationError < StandardError; end
10
+
11
+ # Receives and routes webhook events from Basecamp.
12
+ # Framework-agnostic: works with raw body strings and a header accessor.
13
+ class Receiver
14
+ DEFAULT_SIGNATURE_HEADER = "X-Basecamp-Signature"
15
+ DEFAULT_DEDUP_WINDOW_SIZE = 1000
16
+
17
+ def initialize(secret: nil, signature_header: DEFAULT_SIGNATURE_HEADER, dedup_window_size: DEFAULT_DEDUP_WINDOW_SIZE)
18
+ @secret = secret
19
+ @signature_header = signature_header
20
+ @dedup_window_size = dedup_window_size
21
+ @handlers = {}
22
+ @any_handlers = []
23
+ @middleware = []
24
+ @dedup_seen = {}
25
+ @dedup_pending = {}
26
+ @dedup_order = []
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ # Register a handler for a specific event kind pattern.
31
+ # Supports glob patterns: "todo_*" matches "todo_created", etc.
32
+ def on(pattern, &handler)
33
+ @handlers[pattern] ||= []
34
+ @handlers[pattern] << handler
35
+ self
36
+ end
37
+
38
+ # Register a handler for all events.
39
+ def on_any(&handler)
40
+ @any_handlers << handler
41
+ self
42
+ end
43
+
44
+ # Add middleware to the processing chain.
45
+ # Middleware receives (event, next_proc) and must call next_proc.call to continue.
46
+ def use(&middleware)
47
+ @middleware << middleware
48
+ self
49
+ end
50
+
51
+ # Process a raw webhook request.
52
+ # Returns the parsed Event.
53
+ # Raises VerificationError if signature is invalid.
54
+ def handle_request(raw_body:, headers:)
55
+ # Verify signature
56
+ if @secret && !@secret.empty?
57
+ signature = extract_header(headers, @signature_header)
58
+ unless Verify.valid?(payload: raw_body, signature: signature, secret: @secret)
59
+ raise VerificationError, "invalid webhook signature"
60
+ end
61
+ end
62
+
63
+ # Parse event
64
+ hash = JSON.parse(raw_body)
65
+ event = Event.new(hash)
66
+
67
+ # Atomic dedup: claim before handlers, commit on success, release on error
68
+ return event unless claim(event.id)
69
+
70
+ begin
71
+ # Build middleware chain
72
+ run_handlers = -> { dispatch_handlers(event) }
73
+ chain = @middleware.reverse.reduce(run_handlers) do |next_fn, mw|
74
+ -> { mw.call(event, next_fn) }
75
+ end
76
+
77
+ chain.call
78
+
79
+ # Promote from pending to seen on success
80
+ commit_seen(event.id)
81
+ rescue => e
82
+ # Release claim so retries can re-attempt
83
+ release_claim(event.id)
84
+ raise e
85
+ end
86
+
87
+ event
88
+ end
89
+
90
+ private
91
+
92
+ def extract_header(headers, name)
93
+ if headers.respond_to?(:call)
94
+ headers.call(name)
95
+ elsif headers.respond_to?(:[])
96
+ # Try exact match first, then case-insensitive
97
+ headers[name] || headers[name.downcase] || headers[name.upcase]
98
+ end
99
+ end
100
+
101
+ # Returns true if the event was claimed (caller should process it).
102
+ # Returns false if already seen or in-flight.
103
+ def claim(event_id)
104
+ return true if @dedup_window_size <= 0 || event_id.nil?
105
+
106
+ @mutex.synchronize do
107
+ return false if @dedup_seen.key?(event_id) || @dedup_pending.key?(event_id)
108
+ @dedup_pending[event_id] = true
109
+ true
110
+ end
111
+ end
112
+
113
+ # Promote from pending to seen after successful handling.
114
+ def commit_seen(event_id)
115
+ return if @dedup_window_size <= 0 || event_id.nil?
116
+
117
+ @mutex.synchronize do
118
+ @dedup_pending.delete(event_id)
119
+
120
+ if @dedup_order.size >= @dedup_window_size
121
+ oldest = @dedup_order.shift
122
+ @dedup_seen.delete(oldest)
123
+ end
124
+
125
+ @dedup_seen[event_id] = true
126
+ @dedup_order << event_id
127
+ end
128
+ end
129
+
130
+ # Release claim so retries can re-attempt.
131
+ def release_claim(event_id)
132
+ return if event_id.nil?
133
+
134
+ @mutex.synchronize do
135
+ @dedup_pending.delete(event_id)
136
+ end
137
+ end
138
+
139
+ def dispatch_handlers(event)
140
+ matched = []
141
+
142
+ @handlers.each do |pattern, handlers|
143
+ matched.concat(handlers) if match_pattern?(pattern, event.kind)
144
+ end
145
+
146
+ matched.concat(@any_handlers)
147
+
148
+ matched.each { |handler| handler.call(event) }
149
+ end
150
+
151
+ def match_pattern?(pattern, value)
152
+ return false if value.nil?
153
+ return true if pattern == value
154
+
155
+ # Convert glob pattern to regex
156
+ regex_str = pattern.split("*", -1).map { |part| Regexp.escape(part) }.join(".*")
157
+ Regexp.new("\\A#{regex_str}\\z").match?(value)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Basecamp
6
+ module Webhooks
7
+ # HMAC-SHA256 signature verification for webhook payloads.
8
+ module Verify
9
+ # Verifies an HMAC-SHA256 signature for a webhook payload.
10
+ # Returns false if secret or signature is empty/nil.
11
+ def self.valid?(payload:, signature:, secret:)
12
+ return false if secret.nil? || secret.empty?
13
+ return false if signature.nil? || signature.empty?
14
+
15
+ expected = compute_signature(payload: payload, secret: secret)
16
+ secure_compare(expected, signature)
17
+ end
18
+
19
+ # Computes the HMAC-SHA256 signature for a webhook payload.
20
+ def self.compute_signature(payload:, secret:)
21
+ OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
22
+ end
23
+
24
+ # Timing-safe string comparison
25
+ def self.secure_compare(a, b)
26
+ return false if a.nil? || b.nil?
27
+ return false if a.bytesize != b.bytesize
28
+
29
+ # Use OpenSSL's constant-time comparison via HMAC
30
+ OpenSSL.fixed_length_secure_compare(a, b)
31
+ end
32
+
33
+ private_class_method :secure_compare
34
+ end
35
+ end
36
+ end
data/lib/basecamp.rb ADDED
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ # Set up Zeitwerk loader
6
+ loader = Zeitwerk::Loader.for_gem
7
+ # No custom inflections - use standard Ruby camelcase (Http, Oauth, etc.)
8
+
9
+ # Ignore hand-written services - we use generated services instead (spec-conformant)
10
+ # EXCEPT: base_service.rb (infrastructure) and authorization_service.rb (OAuth, not in spec)
11
+ loader.ignore("#{__dir__}/basecamp/services")
12
+
13
+ # Collapse the generated directory so Basecamp::Generated::Services becomes Basecamp::Services
14
+ loader.collapse("#{__dir__}/basecamp/generated")
15
+
16
+ # Ignore errors.rb - it defines multiple classes, loaded explicitly below
17
+ loader.ignore("#{__dir__}/basecamp/errors.rb")
18
+ # Ignore auth_strategy.rb - defines both AuthStrategy and BearerAuth
19
+ loader.ignore("#{__dir__}/basecamp/auth_strategy.rb")
20
+ # Ignore operation_info.rb - defines both OperationInfo and OperationResult
21
+ loader.ignore("#{__dir__}/basecamp/operation_info.rb")
22
+ loader.setup
23
+
24
+ # Load infrastructure that generated services depend on
25
+ require_relative "basecamp/errors"
26
+ require_relative "basecamp/auth_strategy"
27
+ require_relative "basecamp/operation_info"
28
+ require_relative "basecamp/services/base_service"
29
+ require_relative "basecamp/services/authorization_service"
30
+
31
+ # Load generated types if available
32
+ begin
33
+ require_relative "basecamp/generated/types"
34
+ rescue LoadError
35
+ # Generated types not available yet
36
+ end
37
+
38
+ # Main entry point for the Basecamp SDK.
39
+ #
40
+ # The SDK follows a Client -> AccountClient pattern:
41
+ # - Client: Holds shared resources (HTTP client, token provider, hooks)
42
+ # - AccountClient: Bound to a specific account ID, provides service accessors
43
+ #
44
+ # @example Basic usage
45
+ # config = Basecamp::Config.new(base_url: "https://3.basecampapi.com")
46
+ # token = Basecamp::StaticTokenProvider.new(ENV["BASECAMP_TOKEN"])
47
+ #
48
+ # client = Basecamp::Client.new(config: config, token_provider: token)
49
+ # account = client.for_account("12345")
50
+ #
51
+ # # Use services (returns lazy Enumerator)
52
+ # projects = account.projects.list.to_a
53
+ #
54
+ # @example With hooks for logging
55
+ # class MyHooks
56
+ # include Basecamp::Hooks
57
+ #
58
+ # def on_request_start(info)
59
+ # puts "Starting #{info.method} #{info.url}"
60
+ # end
61
+ #
62
+ # def on_request_end(info, result)
63
+ # puts "Completed in #{result.duration}s"
64
+ # end
65
+ # end
66
+ #
67
+ # client = Basecamp::Client.new(config: config, token_provider: token, hooks: MyHooks.new)
68
+ module Basecamp
69
+ # Creates a new Basecamp client.
70
+ #
71
+ # This is a convenience method that creates a Client with the given options.
72
+ #
73
+ # @param access_token [String, nil] OAuth access token
74
+ # @param auth [AuthStrategy, nil] custom authentication strategy
75
+ # @param account_id [String, nil] Basecamp account ID (optional)
76
+ # @param base_url [String] Base URL for API requests
77
+ # @param hooks [Hooks, nil] Observability hooks
78
+ # @return [Client, AccountClient] Client if no account_id, AccountClient if account_id provided
79
+ #
80
+ # @example With access token
81
+ # client = Basecamp.client(access_token: "abc123", account_id: "12345")
82
+ # projects = client.projects.list.to_a
83
+ #
84
+ # @example With custom auth strategy
85
+ # client = Basecamp.client(auth: MyCustomAuth.new, account_id: "12345")
86
+ def self.client(
87
+ access_token: nil,
88
+ auth: nil,
89
+ account_id: nil,
90
+ base_url: Config::DEFAULT_BASE_URL,
91
+ hooks: nil
92
+ )
93
+ raise ArgumentError, "provide either access_token or auth, not both" if access_token && auth
94
+ raise ArgumentError, "provide access_token or auth" if !access_token && !auth
95
+
96
+ config = Config.new(base_url: base_url)
97
+
98
+ client = if auth
99
+ Client.new(config: config, auth_strategy: auth, hooks: hooks)
100
+ else
101
+ token_provider = StaticTokenProvider.new(access_token)
102
+ Client.new(config: config, token_provider: token_provider, hooks: hooks)
103
+ end
104
+
105
+ account_id ? client.for_account(account_id) : client
106
+ end
107
+ end
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Extracts x-basecamp-* extensions from OpenAPI spec into a runtime-accessible metadata file.
5
+ # This allows the Ruby SDK to read operation metadata at runtime for retry, pagination, etc.
6
+ #
7
+ # Usage: ruby scripts/ruby/generate-metadata.rb > lib/basecamp/generated/metadata.json
8
+
9
+ require 'json'
10
+ require 'time'
11
+
12
+ # Extract metadata from OpenAPI spec
13
+ class MetadataExtractor
14
+ METHODS = %w[get post put patch delete].freeze
15
+
16
+ def initialize(openapi_path)
17
+ @openapi = JSON.parse(File.read(openapi_path))
18
+ end
19
+
20
+ def extract
21
+ operations = {}
22
+
23
+ (@openapi['paths'] || {}).each_value do |path_item|
24
+ METHODS.each do |method|
25
+ operation = path_item[method]
26
+ next unless operation
27
+
28
+ operation_id = operation['operationId']
29
+ next unless operation_id
30
+
31
+ metadata = extract_operation_metadata(operation)
32
+ operations[operation_id] = metadata if metadata.any?
33
+ end
34
+ end
35
+
36
+ {
37
+ '$schema' => 'https://basecamp.com/schemas/sdk-metadata.json',
38
+ 'version' => '1.0.0',
39
+ 'generated' => Time.now.utc.iso8601,
40
+ 'operations' => operations
41
+ }
42
+ end
43
+
44
+ private
45
+
46
+ def extract_operation_metadata(operation)
47
+ metadata = {}
48
+
49
+ # Extract x-basecamp-retry
50
+ if (retry_config = operation['x-basecamp-retry'])
51
+ metadata['retry'] = {
52
+ 'maxAttempts' => retry_config['maxAttempts'],
53
+ 'baseDelayMs' => retry_config['baseDelayMs'],
54
+ 'backoff' => retry_config['backoff'],
55
+ 'retryOn' => retry_config['retryOn']
56
+ }
57
+ end
58
+
59
+ # Extract x-basecamp-pagination
60
+ if (pagination = operation['x-basecamp-pagination'])
61
+ metadata['pagination'] = {
62
+ 'style' => pagination['style'],
63
+ 'pageParam' => pagination['pageParam'],
64
+ 'totalCountHeader' => pagination['totalCountHeader'],
65
+ 'maxPageSize' => pagination['maxPageSize']
66
+ }.compact
67
+ end
68
+
69
+ # Extract x-basecamp-idempotent
70
+ if (idempotent = operation['x-basecamp-idempotent'])
71
+ metadata['idempotent'] = {
72
+ 'keySupported' => idempotent['keySupported'],
73
+ 'keyHeader' => idempotent['keyHeader'],
74
+ 'natural' => idempotent['natural']
75
+ }.compact
76
+ end
77
+
78
+ # Extract x-basecamp-sensitive
79
+ if (sensitive = operation['x-basecamp-sensitive'])
80
+ metadata['sensitive'] = sensitive.map do |s|
81
+ {
82
+ 'field' => s['field'],
83
+ 'category' => s['category'],
84
+ 'redact' => s['redact']
85
+ }.compact
86
+ end
87
+ end
88
+
89
+ metadata
90
+ end
91
+ end
92
+
93
+ # Main execution
94
+ if __FILE__ == $PROGRAM_NAME
95
+ openapi_path = ARGV[0] || File.expand_path('../../openapi.json', __dir__)
96
+
97
+ unless File.exist?(openapi_path)
98
+ warn "Error: OpenAPI file not found: #{openapi_path}"
99
+ exit 1
100
+ end
101
+
102
+ extractor = MetadataExtractor.new(openapi_path)
103
+ metadata = extractor.extract
104
+
105
+ puts JSON.pretty_generate(metadata)
106
+ end