fizzy-sdk 0.1.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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +18 -0
  3. data/Rakefile +26 -0
  4. data/fizzy-sdk.gemspec +45 -0
  5. data/lib/fizzy/auth_strategy.rb +38 -0
  6. data/lib/fizzy/bulkhead.rb +68 -0
  7. data/lib/fizzy/cache.rb +101 -0
  8. data/lib/fizzy/chain_hooks.rb +45 -0
  9. data/lib/fizzy/circuit_breaker.rb +115 -0
  10. data/lib/fizzy/client.rb +212 -0
  11. data/lib/fizzy/config.rb +143 -0
  12. data/lib/fizzy/cookie_auth.rb +27 -0
  13. data/lib/fizzy/errors.rb +291 -0
  14. data/lib/fizzy/generated/metadata.json +1341 -0
  15. data/lib/fizzy/generated/services/boards_service.rb +91 -0
  16. data/lib/fizzy/generated/services/cards_service.rb +313 -0
  17. data/lib/fizzy/generated/services/columns_service.rb +69 -0
  18. data/lib/fizzy/generated/services/comments_service.rb +68 -0
  19. data/lib/fizzy/generated/services/devices_service.rb +35 -0
  20. data/lib/fizzy/generated/services/identity_service.rb +19 -0
  21. data/lib/fizzy/generated/services/miscellaneous_service.rb +256 -0
  22. data/lib/fizzy/generated/services/notifications_service.rb +65 -0
  23. data/lib/fizzy/generated/services/pins_service.rb +19 -0
  24. data/lib/fizzy/generated/services/reactions_service.rb +80 -0
  25. data/lib/fizzy/generated/services/sessions_service.rb +58 -0
  26. data/lib/fizzy/generated/services/steps_service.rb +69 -0
  27. data/lib/fizzy/generated/services/tags_service.rb +20 -0
  28. data/lib/fizzy/generated/services/uploads_service.rb +24 -0
  29. data/lib/fizzy/generated/services/users_service.rb +52 -0
  30. data/lib/fizzy/generated/services/webhooks_service.rb +83 -0
  31. data/lib/fizzy/generated/types.rb +988 -0
  32. data/lib/fizzy/hooks.rb +70 -0
  33. data/lib/fizzy/http.rb +411 -0
  34. data/lib/fizzy/logger_hooks.rb +46 -0
  35. data/lib/fizzy/magic_link_flow.rb +57 -0
  36. data/lib/fizzy/noop_hooks.rb +9 -0
  37. data/lib/fizzy/operation_info.rb +17 -0
  38. data/lib/fizzy/rate_limiter.rb +68 -0
  39. data/lib/fizzy/request_info.rb +10 -0
  40. data/lib/fizzy/request_result.rb +14 -0
  41. data/lib/fizzy/resilience.rb +59 -0
  42. data/lib/fizzy/security.rb +103 -0
  43. data/lib/fizzy/services/base_service.rb +116 -0
  44. data/lib/fizzy/static_token_provider.rb +24 -0
  45. data/lib/fizzy/token_provider.rb +42 -0
  46. data/lib/fizzy/version.rb +6 -0
  47. data/lib/fizzy/webhooks/verify.rb +36 -0
  48. data/lib/fizzy.rb +95 -0
  49. data/scripts/generate-metadata.rb +105 -0
  50. data/scripts/generate-services.rb +681 -0
  51. data/scripts/generate-types.rb +160 -0
  52. metadata +252 -0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Result information for completed HTTP requests.
5
+ RequestResult = Data.define(:status_code, :duration, :error, :retry_after, :from_cache) do
6
+ def initialize(status_code: nil, duration: 0.0, error: nil, retry_after: nil, from_cache: false)
7
+ super
8
+ end
9
+
10
+ def success?
11
+ status_code && status_code >= 200 && status_code < 300
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Configuration container for resilience patterns.
5
+ #
6
+ # Bundles circuit breaker, bulkhead, and rate limiter settings
7
+ # into a single configuration object.
8
+ #
9
+ # @example
10
+ # resilience = Fizzy::ResilienceConfig.new(
11
+ # circuit_breaker: { threshold: 5, timeout: 30 },
12
+ # bulkhead: { max_concurrent: 10, timeout: 5 },
13
+ # rate_limiter: { rate: 10, burst: 20 }
14
+ # )
15
+ class ResilienceConfig
16
+ # @return [CircuitBreaker, nil]
17
+ attr_reader :circuit_breaker
18
+
19
+ # @return [Bulkhead, nil]
20
+ attr_reader :bulkhead
21
+
22
+ # @return [RateLimiter, nil]
23
+ attr_reader :rate_limiter
24
+
25
+ # @param circuit_breaker [Hash, nil] CircuitBreaker options
26
+ # @param bulkhead [Hash, nil] Bulkhead options
27
+ # @param rate_limiter [Hash, nil] RateLimiter options
28
+ def initialize(circuit_breaker: nil, bulkhead: nil, rate_limiter: nil)
29
+ @circuit_breaker = circuit_breaker ? CircuitBreaker.new(**circuit_breaker) : nil
30
+ @bulkhead = bulkhead ? Bulkhead.new(**bulkhead) : nil
31
+ @rate_limiter = rate_limiter ? RateLimiter.new(**rate_limiter) : nil
32
+ end
33
+
34
+ # Wraps a block with all configured resilience patterns.
35
+ #
36
+ # Execution order: rate_limiter -> bulkhead -> circuit_breaker -> block
37
+ #
38
+ # @yield the operation to protect
39
+ # @return the result of the block
40
+ def call(&block)
41
+ block = wrap_with_circuit_breaker(block) if @circuit_breaker
42
+ block = wrap_with_bulkhead(block) if @bulkhead
43
+ @rate_limiter&.acquire
44
+ block.call
45
+ end
46
+
47
+ private
48
+
49
+ def wrap_with_circuit_breaker(block)
50
+ breaker = @circuit_breaker
51
+ -> { breaker.call(&block) }
52
+ end
53
+
54
+ def wrap_with_bulkhead(block)
55
+ bh = @bulkhead
56
+ -> { bh.call(&block) }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Fizzy
6
+ # Security helpers for URL validation, message truncation, and header redaction.
7
+ # Used across the SDK to enforce HTTPS, prevent SSRF, and protect sensitive data.
8
+ module Security
9
+ MAX_ERROR_MESSAGE_BYTES = 500
10
+ MAX_RESPONSE_BODY_BYTES = 50 * 1024 * 1024 # 50 MB
11
+ MAX_ERROR_BODY_BYTES = 1 * 1024 * 1024 # 1 MB
12
+
13
+ def self.truncate(str, max = MAX_ERROR_MESSAGE_BYTES)
14
+ return str if str.nil? || str.bytesize <= max
15
+
16
+ max <= 3 ? str.byteslice(0, max) : str.byteslice(0, max - 3) + "..."
17
+ end
18
+
19
+ def self.require_https!(url, label = "URL")
20
+ uri = URI.parse(url.to_s)
21
+ raise UsageError.new("#{label} must use HTTPS: #{url}") unless uri.scheme&.downcase == "https"
22
+ rescue URI::InvalidURIError
23
+ raise UsageError.new("Invalid #{label}: #{url}")
24
+ end
25
+
26
+ def self.same_origin?(a, b)
27
+ ua = URI.parse(a)
28
+ ub = URI.parse(b)
29
+ return false if ua.scheme.nil? || ub.scheme.nil?
30
+
31
+ ua.scheme.downcase == ub.scheme.downcase &&
32
+ normalize_host(ua) == normalize_host(ub)
33
+ rescue URI::InvalidURIError
34
+ false
35
+ end
36
+
37
+ def self.resolve_url(base, target)
38
+ URI.join(base, target).to_s
39
+ rescue URI::InvalidURIError
40
+ target
41
+ end
42
+
43
+ def self.normalize_host(uri)
44
+ host = uri.host&.downcase
45
+ port = uri.port
46
+ return host if port.nil?
47
+ return host if uri.scheme&.downcase == "https" && port == 443
48
+ return host if uri.scheme&.downcase == "http" && port == 80
49
+
50
+ "#{host}:#{port}"
51
+ end
52
+
53
+ def self.check_body_size!(body, max, label = "Response")
54
+ return if body.nil?
55
+
56
+ if body.bytesize > max
57
+ raise Fizzy::APIError.new(
58
+ "#{label} body too large (#{body.bytesize} bytes, max #{max})"
59
+ )
60
+ end
61
+ end
62
+
63
+ def self.localhost?(url)
64
+ uri = URI.parse(url.to_s)
65
+ host = uri.host&.downcase
66
+ return false if host.nil?
67
+
68
+ host == "localhost" ||
69
+ host == "127.0.0.1" ||
70
+ host == "::1" ||
71
+ host == "[::1]" ||
72
+ host.end_with?(".localhost")
73
+ rescue URI::InvalidURIError
74
+ false
75
+ end
76
+
77
+ def self.require_https_unless_localhost!(url, label = "URL")
78
+ return if localhost?(url)
79
+
80
+ require_https!(url, label)
81
+ end
82
+
83
+ # Headers that contain sensitive values and should be redacted.
84
+ SENSITIVE_HEADERS = %w[
85
+ authorization
86
+ cookie
87
+ set-cookie
88
+ x-csrf-token
89
+ ].freeze
90
+
91
+ # Returns a copy of the headers with sensitive values replaced by "[REDACTED]".
92
+ #
93
+ # @param headers [Hash] the headers hash (case-insensitive keys)
94
+ # @return [Hash] a new hash with sensitive values redacted
95
+ def self.redact_headers(headers)
96
+ result = {}
97
+ headers.each do |key, value|
98
+ result[key] = SENSITIVE_HEADERS.include?(key.to_s.downcase) ? "[REDACTED]" : value
99
+ end
100
+ result
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require_relative "../errors"
5
+
6
+ module Fizzy
7
+ module Services
8
+ # Base service class for Fizzy API services.
9
+ #
10
+ # Provides shared functionality for all service classes including:
11
+ # - HTTP method delegation (http_get, http_post, etc.)
12
+ # - Pagination support
13
+ # - Operation hooks (with_operation, wrap_paginated)
14
+ #
15
+ # @example
16
+ # class CardsService < BaseService
17
+ # def list(board_id:)
18
+ # paginate("/boards/#{board_id}/cards")
19
+ # end
20
+ # end
21
+ class BaseService
22
+ # @param client [Object] the parent client (Client)
23
+ def initialize(client)
24
+ @client = client
25
+ @hooks = client.hooks
26
+ end
27
+
28
+ protected
29
+
30
+ # Wraps a service operation with hooks for observability.
31
+ # @param service [String] service name (e.g., "boards")
32
+ # @param operation [String] operation name (e.g., "list")
33
+ # @param resource_type [String, nil] resource type (e.g., "Board")
34
+ # @param is_mutation [Boolean] whether this is a write operation
35
+ # @param resource_id [Integer, String, nil] resource ID
36
+ # @yield the operation to execute
37
+ # @return the result of the block
38
+ def with_operation(service:, operation:, resource_type: nil, is_mutation: false, resource_id: nil)
39
+ info = OperationInfo.new(
40
+ service: service, operation: operation, resource_type: resource_type,
41
+ is_mutation: is_mutation, resource_id: resource_id
42
+ )
43
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ safe_hook { @hooks.on_operation_start(info) }
45
+ result = yield
46
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
47
+ safe_hook { @hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: nil)) }
48
+ result
49
+ rescue => e
50
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
51
+ safe_hook { @hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: e)) }
52
+ raise
53
+ end
54
+
55
+ # Wraps a lazy Enumerator so operation hooks fire around actual iteration,
56
+ # not at enumerator creation time. Hooks fire when the consumer begins
57
+ # iterating (.each, .to_a, .first, etc.) and end fires via ensure when
58
+ # iteration completes, errors, or is cut short by break/take.
59
+ def wrap_paginated(service:, operation:, is_mutation: false, resource_id: nil)
60
+ info = OperationInfo.new(
61
+ service: service, operation: operation,
62
+ is_mutation: is_mutation, resource_id: resource_id
63
+ )
64
+ enum = yield
65
+
66
+ hooks = @hooks
67
+ Enumerator.new do |yielder|
68
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ error = nil
70
+ begin
71
+ safe_hook { hooks.on_operation_start(info) }
72
+ enum.each { |item| yielder.yield(item) }
73
+ rescue => e
74
+ error = e
75
+ raise
76
+ ensure
77
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
78
+ safe_hook { hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: error)) }
79
+ end
80
+ end
81
+ end
82
+
83
+ # Invoke a hook callback, swallowing exceptions so hooks never break SDK behavior.
84
+ def safe_hook
85
+ yield
86
+ rescue => e
87
+ warn "Fizzy hook error: #{e.class}: #{e.message}"
88
+ end
89
+
90
+ # @return [Http] the HTTP client for direct access
91
+ def http
92
+ @client.http
93
+ end
94
+
95
+ # Helper to remove nil values from a hash.
96
+ # @param hash [Hash] the input hash
97
+ # @return [Hash] hash with nil values removed
98
+ def compact_params(**kwargs)
99
+ kwargs.compact
100
+ end
101
+
102
+ # Delegate HTTP methods to the client with http_ prefix to avoid conflicts
103
+ # with service method names (e.g., service.get vs http_get)
104
+ %i[get post put patch delete post_raw].each do |method|
105
+ define_method(:"http_#{method}") do |*args, **kwargs, &block|
106
+ @client.public_send(method, *args, **kwargs, &block)
107
+ end
108
+ end
109
+
110
+ # Paginate doesn't conflict with service methods, keep as-is
111
+ def paginate(...)
112
+ @client.paginate(...)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # A simple token provider that returns a static access token.
5
+ # Useful for testing or when you manage token refresh externally.
6
+ #
7
+ # @example
8
+ # provider = Fizzy::StaticTokenProvider.new(ENV["FIZZY_ACCESS_TOKEN"])
9
+ class StaticTokenProvider
10
+ include TokenProvider
11
+
12
+ # @param token [String] the static access token
13
+ def initialize(token)
14
+ raise ArgumentError, "token cannot be nil or empty" if token.nil? || token.empty?
15
+
16
+ @token = token
17
+ end
18
+
19
+ # @return [String] the access token
20
+ def access_token
21
+ @token
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Interface for providing access tokens.
5
+ # Implement this to provide custom token management.
6
+ #
7
+ # @example Static token provider
8
+ # token_provider = Fizzy::StaticTokenProvider.new("your-access-token")
9
+ # client = Fizzy::Client.new(config: config, token_provider: token_provider)
10
+ #
11
+ # @example Custom token provider with refresh
12
+ # class MyTokenProvider
13
+ # include Fizzy::TokenProvider
14
+ #
15
+ # def access_token
16
+ # # Return current token, refreshing if needed
17
+ # end
18
+ #
19
+ # def refresh
20
+ # # Refresh the token
21
+ # end
22
+ # end
23
+ module TokenProvider
24
+ # Returns the current access token.
25
+ # @return [String] the access token
26
+ def access_token
27
+ raise NotImplementedError, "#{self.class} must implement #access_token"
28
+ end
29
+
30
+ # Refreshes the access token.
31
+ # @return [Boolean] true if refresh succeeded
32
+ def refresh
33
+ false
34
+ end
35
+
36
+ # Returns whether token refresh is supported.
37
+ # @return [Boolean]
38
+ def refreshable?
39
+ false
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ VERSION = "0.1.0"
5
+ API_VERSION = "2026-03-01"
6
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Fizzy
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/fizzy.rb ADDED
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ # Set up Zeitwerk loader
6
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
7
+
8
+ # Ignore hand-written services - we use generated services instead (spec-conformant)
9
+ # EXCEPT: base_service.rb (infrastructure)
10
+ loader.ignore("#{__dir__}/fizzy/services")
11
+
12
+ # Collapse the generated directory so Fizzy::Generated::Services becomes Fizzy::Services
13
+ loader.collapse("#{__dir__}/fizzy/generated")
14
+
15
+ # Ignore errors.rb - it defines multiple classes, loaded explicitly below
16
+ loader.ignore("#{__dir__}/fizzy/errors.rb")
17
+ # Ignore auth_strategy.rb - defines both AuthStrategy and BearerAuth
18
+ loader.ignore("#{__dir__}/fizzy/auth_strategy.rb")
19
+ # Ignore operation_info.rb - defines both OperationInfo and OperationResult
20
+ loader.ignore("#{__dir__}/fizzy/operation_info.rb")
21
+ loader.setup
22
+
23
+ # Load infrastructure that generated services depend on
24
+ require_relative "fizzy/errors"
25
+ require_relative "fizzy/auth_strategy"
26
+ require_relative "fizzy/operation_info"
27
+ require_relative "fizzy/services/base_service"
28
+
29
+ # Load generated types if available
30
+ begin
31
+ require_relative "fizzy/generated/types"
32
+ rescue LoadError
33
+ # Generated types not available yet
34
+ end
35
+
36
+ # Main entry point for the Fizzy SDK.
37
+ #
38
+ # The SDK follows a Client pattern:
39
+ # - Client: Holds shared resources (HTTP client, auth strategy, hooks)
40
+ # - Provides service accessors for all 15 Fizzy services
41
+ #
42
+ # @example Basic usage
43
+ # client = Fizzy.client(access_token: ENV["FIZZY_ACCESS_TOKEN"])
44
+ # boards = client.boards.list.to_a
45
+ #
46
+ # @example With hooks for logging
47
+ # class MyHooks
48
+ # include Fizzy::Hooks
49
+ #
50
+ # def on_request_start(info)
51
+ # puts "Starting #{info.method} #{info.url}"
52
+ # end
53
+ #
54
+ # def on_request_end(info, result)
55
+ # puts "Completed in #{result.duration}s"
56
+ # end
57
+ # end
58
+ #
59
+ # client = Fizzy.client(access_token: token, hooks: MyHooks.new)
60
+ module Fizzy
61
+ # Creates a new Fizzy client.
62
+ #
63
+ # This is a convenience method that creates a Client with the given options.
64
+ #
65
+ # @param access_token [String, nil] API access token
66
+ # @param auth [AuthStrategy, nil] custom authentication strategy
67
+ # @param base_url [String] Base URL for API requests
68
+ # @param hooks [Hooks, nil] Observability hooks
69
+ # @return [Client]
70
+ #
71
+ # @example With access token
72
+ # client = Fizzy.client(access_token: "abc123")
73
+ # boards = client.boards.list.to_a
74
+ #
75
+ # @example With custom auth strategy
76
+ # client = Fizzy.client(auth: Fizzy::CookieAuth.new("session_value"))
77
+ def self.client(
78
+ access_token: nil,
79
+ auth: nil,
80
+ base_url: Config::DEFAULT_BASE_URL,
81
+ hooks: nil
82
+ )
83
+ raise ArgumentError, "provide either access_token or auth, not both" if access_token && auth
84
+ raise ArgumentError, "provide access_token or auth" if !access_token && !auth
85
+
86
+ config = Config.new(base_url: base_url)
87
+
88
+ if auth
89
+ Client.new(config: config, auth_strategy: auth, hooks: hooks)
90
+ else
91
+ token_provider = StaticTokenProvider.new(access_token)
92
+ Client.new(config: config, token_provider: token_provider, hooks: hooks)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Extracts x-fizzy-* 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/generate-metadata.rb > lib/fizzy/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://fizzy.do/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-fizzy-retry
50
+ if (retry_config = operation['x-fizzy-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-fizzy-pagination
60
+ if (pagination = operation['x-fizzy-pagination'])
61
+ metadata['pagination'] = {
62
+ 'style' => pagination['style'],
63
+ 'pageParam' => pagination['pageParam'],
64
+ 'maxPageSize' => pagination['maxPageSize']
65
+ }.compact
66
+ end
67
+
68
+ # Extract x-fizzy-idempotent
69
+ if (idempotent = operation['x-fizzy-idempotent'])
70
+ metadata['idempotent'] = {
71
+ 'keySupported' => idempotent['keySupported'],
72
+ 'keyHeader' => idempotent['keyHeader'],
73
+ 'natural' => idempotent['natural']
74
+ }.compact
75
+ end
76
+
77
+ # Extract x-fizzy-sensitive
78
+ if (sensitive = operation['x-fizzy-sensitive'])
79
+ metadata['sensitive'] = sensitive.map do |s|
80
+ {
81
+ 'field' => s['field'],
82
+ 'category' => s['category'],
83
+ 'redact' => s['redact']
84
+ }.compact
85
+ end
86
+ end
87
+
88
+ metadata
89
+ end
90
+ end
91
+
92
+ # Main execution
93
+ if __FILE__ == $PROGRAM_NAME
94
+ openapi_path = ARGV[0] || File.expand_path('../../openapi.json', __dir__)
95
+
96
+ unless File.exist?(openapi_path)
97
+ warn "Error: OpenAPI file not found: #{openapi_path}"
98
+ exit 1
99
+ end
100
+
101
+ extractor = MetadataExtractor.new(openapi_path)
102
+ metadata = extractor.extract
103
+
104
+ puts JSON.pretty_generate(metadata)
105
+ end