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.
- checksums.yaml +7 -0
- data/.rubocop.yml +18 -0
- data/Rakefile +26 -0
- data/fizzy-sdk.gemspec +45 -0
- data/lib/fizzy/auth_strategy.rb +38 -0
- data/lib/fizzy/bulkhead.rb +68 -0
- data/lib/fizzy/cache.rb +101 -0
- data/lib/fizzy/chain_hooks.rb +45 -0
- data/lib/fizzy/circuit_breaker.rb +115 -0
- data/lib/fizzy/client.rb +212 -0
- data/lib/fizzy/config.rb +143 -0
- data/lib/fizzy/cookie_auth.rb +27 -0
- data/lib/fizzy/errors.rb +291 -0
- data/lib/fizzy/generated/metadata.json +1341 -0
- data/lib/fizzy/generated/services/boards_service.rb +91 -0
- data/lib/fizzy/generated/services/cards_service.rb +313 -0
- data/lib/fizzy/generated/services/columns_service.rb +69 -0
- data/lib/fizzy/generated/services/comments_service.rb +68 -0
- data/lib/fizzy/generated/services/devices_service.rb +35 -0
- data/lib/fizzy/generated/services/identity_service.rb +19 -0
- data/lib/fizzy/generated/services/miscellaneous_service.rb +256 -0
- data/lib/fizzy/generated/services/notifications_service.rb +65 -0
- data/lib/fizzy/generated/services/pins_service.rb +19 -0
- data/lib/fizzy/generated/services/reactions_service.rb +80 -0
- data/lib/fizzy/generated/services/sessions_service.rb +58 -0
- data/lib/fizzy/generated/services/steps_service.rb +69 -0
- data/lib/fizzy/generated/services/tags_service.rb +20 -0
- data/lib/fizzy/generated/services/uploads_service.rb +24 -0
- data/lib/fizzy/generated/services/users_service.rb +52 -0
- data/lib/fizzy/generated/services/webhooks_service.rb +83 -0
- data/lib/fizzy/generated/types.rb +988 -0
- data/lib/fizzy/hooks.rb +70 -0
- data/lib/fizzy/http.rb +411 -0
- data/lib/fizzy/logger_hooks.rb +46 -0
- data/lib/fizzy/magic_link_flow.rb +57 -0
- data/lib/fizzy/noop_hooks.rb +9 -0
- data/lib/fizzy/operation_info.rb +17 -0
- data/lib/fizzy/rate_limiter.rb +68 -0
- data/lib/fizzy/request_info.rb +10 -0
- data/lib/fizzy/request_result.rb +14 -0
- data/lib/fizzy/resilience.rb +59 -0
- data/lib/fizzy/security.rb +103 -0
- data/lib/fizzy/services/base_service.rb +116 -0
- data/lib/fizzy/static_token_provider.rb +24 -0
- data/lib/fizzy/token_provider.rb +42 -0
- data/lib/fizzy/version.rb +6 -0
- data/lib/fizzy/webhooks/verify.rb +36 -0
- data/lib/fizzy.rb +95 -0
- data/scripts/generate-metadata.rb +105 -0
- data/scripts/generate-services.rb +681 -0
- data/scripts/generate-types.rb +160 -0
- 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,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
|