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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Fizzy
6
+ # Configuration for the Fizzy API client.
7
+ #
8
+ # @example Creating config with defaults
9
+ # config = Fizzy::Config.new
10
+ #
11
+ # @example Creating config with custom values
12
+ # config = Fizzy::Config.new(
13
+ # base_url: "https://fizzy.do",
14
+ # timeout: 60,
15
+ # max_retries: 3
16
+ # )
17
+ #
18
+ # @example Loading config from environment
19
+ # config = Fizzy::Config.from_env
20
+ class Config
21
+ # @return [String] API base URL
22
+ attr_accessor :base_url
23
+
24
+ # @return [Integer] request timeout in seconds
25
+ attr_accessor :timeout
26
+
27
+ # @return [Integer] maximum retry attempts for GET requests
28
+ attr_accessor :max_retries
29
+
30
+ # @return [Float] initial backoff delay in seconds
31
+ attr_accessor :base_delay
32
+
33
+ # @return [Float] maximum jitter to add to delays in seconds
34
+ attr_accessor :max_jitter
35
+
36
+ # @return [Integer] maximum pages to fetch in paginated requests
37
+ attr_accessor :max_pages
38
+
39
+ # Default values
40
+ DEFAULT_BASE_URL = "https://fizzy.do"
41
+ DEFAULT_TIMEOUT = 30
42
+ DEFAULT_MAX_RETRIES = 3
43
+ DEFAULT_BASE_DELAY = 1.0
44
+ DEFAULT_MAX_JITTER = 0.1
45
+ DEFAULT_MAX_PAGES = 10_000
46
+
47
+ # Creates a new configuration with the given options.
48
+ #
49
+ # @param base_url [String] API base URL
50
+ # @param timeout [Integer] request timeout in seconds
51
+ # @param max_retries [Integer] maximum retry attempts
52
+ # @param base_delay [Float] initial backoff delay
53
+ # @param max_jitter [Float] maximum jitter
54
+ # @param max_pages [Integer] maximum pages to fetch
55
+ def initialize(
56
+ base_url: DEFAULT_BASE_URL,
57
+ timeout: DEFAULT_TIMEOUT,
58
+ max_retries: DEFAULT_MAX_RETRIES,
59
+ base_delay: DEFAULT_BASE_DELAY,
60
+ max_jitter: DEFAULT_MAX_JITTER,
61
+ max_pages: DEFAULT_MAX_PAGES
62
+ )
63
+ @base_url = normalize_url(base_url)
64
+ @timeout = timeout
65
+ @max_retries = max_retries
66
+ @base_delay = base_delay
67
+ @max_jitter = max_jitter
68
+ @max_pages = max_pages
69
+
70
+ unless @base_url == normalize_url(DEFAULT_BASE_URL) || localhost?(@base_url)
71
+ Fizzy::Security.require_https!(@base_url, "base URL")
72
+ end
73
+ validate!
74
+ end
75
+
76
+ # Creates a Config from environment variables.
77
+ #
78
+ # Environment variables:
79
+ # - FIZZY_BASE_URL: API base URL
80
+ # - FIZZY_TIMEOUT: Request timeout in seconds
81
+ # - FIZZY_MAX_RETRIES: Maximum retry attempts
82
+ #
83
+ # @return [Config]
84
+ def self.from_env
85
+ new(
86
+ base_url: ENV.fetch("FIZZY_BASE_URL", DEFAULT_BASE_URL),
87
+ timeout: ENV.fetch("FIZZY_TIMEOUT", DEFAULT_TIMEOUT).to_i,
88
+ max_retries: ENV.fetch("FIZZY_MAX_RETRIES", DEFAULT_MAX_RETRIES).to_i
89
+ )
90
+ end
91
+
92
+ # Loads configuration from a JSON file, with environment overrides.
93
+ #
94
+ # @param path [String] path to JSON config file
95
+ # @return [Config]
96
+ def self.from_file(path)
97
+ data = JSON.parse(File.read(path))
98
+ config = new(
99
+ base_url: data["base_url"] || DEFAULT_BASE_URL,
100
+ timeout: data["timeout"] || DEFAULT_TIMEOUT,
101
+ max_retries: data["max_retries"] || DEFAULT_MAX_RETRIES
102
+ )
103
+ config.load_from_env
104
+ config
105
+ rescue Errno::ENOENT
106
+ from_env
107
+ end
108
+
109
+ # Loads environment variable overrides into this config.
110
+ # @return [self]
111
+ def load_from_env
112
+ @base_url = normalize_url(ENV["FIZZY_BASE_URL"]) if ENV["FIZZY_BASE_URL"]
113
+ @timeout = ENV["FIZZY_TIMEOUT"].to_i if ENV["FIZZY_TIMEOUT"]
114
+ @max_retries = ENV["FIZZY_MAX_RETRIES"].to_i if ENV["FIZZY_MAX_RETRIES"]
115
+ Fizzy::Security.require_https!(@base_url, "base URL") unless localhost?(@base_url)
116
+ validate!
117
+ self
118
+ end
119
+
120
+ # Returns the default global config directory.
121
+ # @return [String]
122
+ def self.global_config_dir
123
+ config_dir = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
124
+ File.join(config_dir, "fizzy")
125
+ end
126
+
127
+ private
128
+
129
+ def validate!
130
+ raise ArgumentError, "timeout must be positive" unless @timeout.is_a?(Numeric) && @timeout > 0
131
+ raise ArgumentError, "max_retries must be non-negative" unless @max_retries.is_a?(Integer) && @max_retries >= 0
132
+ raise ArgumentError, "max_pages must be positive" unless @max_pages.is_a?(Integer) && @max_pages > 0
133
+ end
134
+
135
+ def normalize_url(url)
136
+ url&.chomp("/")
137
+ end
138
+
139
+ def localhost?(url)
140
+ Fizzy::Security.localhost?(url)
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ # Cookie-based authentication strategy.
5
+ # Sets the Cookie header with the session token for session-based auth
6
+ # (mobile/web clients).
7
+ #
8
+ # @example
9
+ # auth = Fizzy::CookieAuth.new("session_token_value")
10
+ # client = Fizzy.client(auth: auth)
11
+ class CookieAuth
12
+ include AuthStrategy
13
+
14
+ # @param session_token [String] the session token value
15
+ # @param cookie_name [String] the cookie name (defaults to "session_token")
16
+ def initialize(session_token, cookie_name: "session_token")
17
+ raise ArgumentError, "session_token cannot be nil or empty" if session_token.nil? || session_token.empty?
18
+
19
+ @session_token = session_token
20
+ @cookie_name = cookie_name
21
+ end
22
+
23
+ def authenticate(headers)
24
+ headers["Cookie"] = "#{@cookie_name}=#{@session_token}"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # Error types and codes for the Fizzy SDK.
6
+ module Fizzy
7
+ # Error codes for API responses
8
+ module ErrorCode
9
+ USAGE = "usage"
10
+ NOT_FOUND = "not_found"
11
+ AUTH = "auth_required"
12
+ FORBIDDEN = "forbidden"
13
+ RATE_LIMIT = "rate_limit"
14
+ NETWORK = "network"
15
+ API = "api_error"
16
+ AMBIGUOUS = "ambiguous"
17
+ VALIDATION = "validation"
18
+ end
19
+
20
+ # Exit codes for CLI tools
21
+ module ExitCode
22
+ OK = 0
23
+ USAGE = 1
24
+ NOT_FOUND = 2
25
+ AUTH = 3
26
+ FORBIDDEN = 4
27
+ RATE_LIMIT = 5
28
+ NETWORK = 6
29
+ API = 7
30
+ AMBIGUOUS = 8
31
+ VALIDATION = 9
32
+ end
33
+
34
+ # Base error class for all Fizzy SDK errors.
35
+ # Provides structured error handling with codes, hints, and CLI exit codes.
36
+ #
37
+ # @example Catching errors
38
+ # begin
39
+ # client.boards.list
40
+ # rescue Fizzy::Error => e
41
+ # puts "#{e.code}: #{e.message}"
42
+ # puts "Hint: #{e.hint}" if e.hint
43
+ # exit e.exit_code
44
+ # end
45
+ class Error < StandardError
46
+ # @return [String] error category code
47
+ attr_reader :code
48
+
49
+ # @return [String, nil] user-friendly hint for resolving the error
50
+ attr_reader :hint
51
+
52
+ # @return [Integer, nil] HTTP status code that caused the error
53
+ attr_reader :http_status
54
+
55
+ # @return [Boolean] whether the operation can be retried
56
+ attr_reader :retryable
57
+
58
+ # @return [Integer, nil] seconds to wait before retrying (for rate limits)
59
+ attr_reader :retry_after
60
+
61
+ # @return [String, nil] X-Request-Id from the response
62
+ attr_reader :request_id
63
+
64
+ # @return [Exception, nil] original error that caused this error
65
+ attr_reader :cause
66
+
67
+ # @param code [String] error category code
68
+ # @param message [String] error message
69
+ # @param hint [String, nil] user-friendly hint
70
+ # @param http_status [Integer, nil] HTTP status code
71
+ # @param retryable [Boolean] whether operation can be retried
72
+ # @param retry_after [Integer, nil] seconds to wait before retry
73
+ # @param request_id [String, nil] X-Request-Id from response
74
+ # @param cause [Exception, nil] underlying cause
75
+ def initialize(code:, message:, hint: nil, http_status: nil, retryable: false, retry_after: nil, request_id: nil, cause: nil)
76
+ super(message)
77
+ @code = code
78
+ @hint = hint
79
+ @http_status = http_status
80
+ @retryable = retryable
81
+ @retry_after = retry_after
82
+ @request_id = request_id
83
+ @cause = cause
84
+ end
85
+
86
+ # Returns the exit code for CLI applications.
87
+ # @return [Integer]
88
+ def exit_code
89
+ self.class.exit_code_for(@code)
90
+ end
91
+
92
+ # Returns whether this error can be retried.
93
+ # @return [Boolean]
94
+ def retryable?
95
+ @retryable
96
+ end
97
+
98
+ # Maps error codes to exit codes.
99
+ # @param code [String]
100
+ # @return [Integer]
101
+ def self.exit_code_for(code)
102
+ case code
103
+ when ErrorCode::USAGE then ExitCode::USAGE
104
+ when ErrorCode::NOT_FOUND then ExitCode::NOT_FOUND
105
+ when ErrorCode::AUTH then ExitCode::AUTH
106
+ when ErrorCode::FORBIDDEN then ExitCode::FORBIDDEN
107
+ when ErrorCode::RATE_LIMIT then ExitCode::RATE_LIMIT
108
+ when ErrorCode::NETWORK then ExitCode::NETWORK
109
+ when ErrorCode::API then ExitCode::API
110
+ when ErrorCode::AMBIGUOUS then ExitCode::AMBIGUOUS
111
+ when ErrorCode::VALIDATION then ExitCode::VALIDATION
112
+ else ExitCode::API
113
+ end
114
+ end
115
+ end
116
+
117
+ # Raised when there's a usage error (invalid arguments, missing config).
118
+ class UsageError < Error
119
+ def initialize(message, hint: nil)
120
+ super(code: ErrorCode::USAGE, message: message, hint: hint)
121
+ end
122
+ end
123
+
124
+ # Raised when a resource is not found (404).
125
+ class NotFoundError < Error
126
+ def initialize(resource, identifier, hint: nil)
127
+ super(
128
+ code: ErrorCode::NOT_FOUND,
129
+ message: "#{resource} not found: #{identifier}",
130
+ hint: hint,
131
+ http_status: 404
132
+ )
133
+ end
134
+ end
135
+
136
+ # Raised when authentication fails (401).
137
+ class AuthError < Error
138
+ def initialize(message = "Authentication required", hint: nil, cause: nil)
139
+ super(
140
+ code: ErrorCode::AUTH,
141
+ message: message,
142
+ hint: hint || "Check your access token or session cookie",
143
+ http_status: 401,
144
+ cause: cause
145
+ )
146
+ end
147
+ end
148
+
149
+ # Raised when access is denied (403).
150
+ class ForbiddenError < Error
151
+ def initialize(message = "Access denied", hint: nil)
152
+ super(
153
+ code: ErrorCode::FORBIDDEN,
154
+ message: message,
155
+ hint: hint || "You do not have permission to access this resource",
156
+ http_status: 403
157
+ )
158
+ end
159
+ end
160
+
161
+ # Raised when rate limited (429).
162
+ class RateLimitError < Error
163
+ def initialize(retry_after: nil, cause: nil)
164
+ hint = retry_after ? "Try again in #{retry_after} seconds" : "Please slow down requests"
165
+ super(
166
+ code: ErrorCode::RATE_LIMIT,
167
+ message: "Rate limit exceeded",
168
+ hint: hint,
169
+ http_status: 429,
170
+ retryable: true,
171
+ retry_after: retry_after,
172
+ cause: cause
173
+ )
174
+ end
175
+ end
176
+
177
+ # Raised when there's a network error (connection, timeout, DNS).
178
+ class NetworkError < Error
179
+ def initialize(message = "Network error", cause: nil)
180
+ super(
181
+ code: ErrorCode::NETWORK,
182
+ message: message,
183
+ hint: cause&.message || "Check your network connection",
184
+ retryable: true,
185
+ cause: cause
186
+ )
187
+ end
188
+ end
189
+
190
+ # Raised for generic API errors.
191
+ class APIError < Error
192
+ def initialize(message, http_status: nil, hint: nil, retryable: false, cause: nil)
193
+ super(
194
+ code: ErrorCode::API,
195
+ message: message,
196
+ hint: hint,
197
+ http_status: http_status,
198
+ retryable: retryable,
199
+ cause: cause
200
+ )
201
+ end
202
+
203
+ # Creates an APIError from an HTTP status code.
204
+ # @param status [Integer] HTTP status code
205
+ # @param message [String, nil] optional error message
206
+ # @return [APIError]
207
+ def self.from_status(status, message = nil)
208
+ message ||= "Request failed (HTTP #{status})"
209
+ retryable = status >= 500 && status < 600
210
+ new(message, http_status: status, retryable: retryable)
211
+ end
212
+ end
213
+
214
+ # Raised when a name/identifier matches multiple resources.
215
+ class AmbiguousError < Error
216
+ # @return [Array<String>] list of matching resources
217
+ attr_reader :matches
218
+
219
+ def initialize(resource, matches: [])
220
+ @matches = matches
221
+ hint = if matches.any? && matches.length <= 5
222
+ "Did you mean: #{matches.join(", ")}"
223
+ else
224
+ "Be more specific"
225
+ end
226
+ super(
227
+ code: ErrorCode::AMBIGUOUS,
228
+ message: "Ambiguous #{resource}",
229
+ hint: hint
230
+ )
231
+ end
232
+ end
233
+
234
+ # Raised for validation errors (400, 422).
235
+ class ValidationError < Error
236
+ def initialize(message, hint: nil, http_status: 400)
237
+ super(
238
+ code: ErrorCode::VALIDATION,
239
+ message: message,
240
+ hint: hint,
241
+ http_status: http_status
242
+ )
243
+ end
244
+ end
245
+
246
+ # Maps an HTTP response to the appropriate error class.
247
+ #
248
+ # @param status [Integer] HTTP status code
249
+ # @param body [String, nil] response body (will attempt JSON parse)
250
+ # @param retry_after [Integer, nil] Retry-After header value
251
+ # @return [Error]
252
+ def self.error_from_response(status, body = nil, retry_after: nil)
253
+ message = parse_error_message(body) || "Request failed"
254
+
255
+ case status
256
+ when 400, 422
257
+ ValidationError.new(message, http_status: status)
258
+ when 401
259
+ AuthError.new(message)
260
+ when 403
261
+ ForbiddenError.new(message)
262
+ when 404
263
+ NotFoundError.new("Resource", "unknown")
264
+ when 429
265
+ RateLimitError.new(retry_after: retry_after)
266
+ when 500
267
+ APIError.new("Server error (500)", http_status: 500, retryable: true)
268
+ when 502, 503, 504
269
+ APIError.new("Gateway error (#{status})", http_status: status, retryable: true)
270
+ else
271
+ APIError.from_status(status, message)
272
+ end
273
+ end
274
+
275
+ # Parses error message from response body.
276
+ # @param body [String, nil]
277
+ # @return [String, nil]
278
+ def self.parse_error_message(body)
279
+ return nil if body.nil? || body.empty?
280
+
281
+ # Guard against oversized error bodies before parsing
282
+ Fizzy::Security.check_body_size!(body, Fizzy::Security::MAX_ERROR_BODY_BYTES, "Error")
283
+
284
+ data = JSON.parse(body)
285
+ msg = data["error"] || data["message"]
286
+ msg ? Fizzy::Security.truncate(msg) : nil
287
+ rescue JSON::ParserError, Fizzy::APIError
288
+ # Return nil on parse errors or oversized bodies to preserve normal error type mapping
289
+ nil
290
+ end
291
+ end