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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 335822e6052678da32698d8c2546640f4e78535904305e39ab64a83aec2abf16
|
|
4
|
+
data.tar.gz: e2e54f187ed12c89fb07862b30595a345bf611508cd0283302369d0708f17ee2
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c462ced9ffa65dc73febf9b6c42d16264b4decb68374185e3db4f0affe58420e3712f824a8e64581ed08c260982f6de671be915697d76c6322fd2378a18e2b94
|
|
7
|
+
data.tar.gz: ec9026294b2270549849c08fbbc09758f9fceb636b9640f8bbbced9b4c820250a036e83529bd0e05c7c4fc1350065f41d1bbb608ea39a972b1c06f75d0d087bd
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# 37signals house style
|
|
2
|
+
inherit_gem:
|
|
3
|
+
rubocop-37signals: rubocop.yml
|
|
4
|
+
|
|
5
|
+
AllCops:
|
|
6
|
+
TargetRubyVersion: 3.2
|
|
7
|
+
Exclude:
|
|
8
|
+
- "vendor/**/*"
|
|
9
|
+
- "lib/fizzy/generated/**/*"
|
|
10
|
+
|
|
11
|
+
# Documentation not required for tests
|
|
12
|
+
Style/Documentation:
|
|
13
|
+
Exclude:
|
|
14
|
+
- "test/**/*"
|
|
15
|
+
|
|
16
|
+
# This gem uses Minitest (not Rails), so refute is correct
|
|
17
|
+
Rails/RefuteMethods:
|
|
18
|
+
Enabled: false
|
data/Rakefile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
require 'rubocop/rake_task'
|
|
6
|
+
require 'yard'
|
|
7
|
+
|
|
8
|
+
Rake::TestTask.new(:test) do |t|
|
|
9
|
+
t.libs << 'test'
|
|
10
|
+
t.libs << 'lib'
|
|
11
|
+
t.test_files = FileList['test/**/*_test.rb']
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
RuboCop::RakeTask.new
|
|
15
|
+
|
|
16
|
+
YARD::Rake::YardocTask.new(:doc) do |t|
|
|
17
|
+
t.files = [ 'lib/**/*.rb' ]
|
|
18
|
+
t.options = [ '--output-dir', 'doc' ]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc 'Start an interactive console with the SDK loaded'
|
|
22
|
+
task :console do
|
|
23
|
+
exec 'bin/console'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
task default: %i[test rubocop]
|
data/fizzy-sdk.gemspec
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/fizzy/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'fizzy-sdk'
|
|
7
|
+
spec.version = Fizzy::VERSION
|
|
8
|
+
spec.authors = [ 'Basecamp' ]
|
|
9
|
+
spec.email = [ 'support@basecamp.com' ]
|
|
10
|
+
|
|
11
|
+
spec.summary = 'Official Ruby SDK for the Fizzy API'
|
|
12
|
+
spec.description = 'A Ruby SDK for the Fizzy API with automatic retry, ' \
|
|
13
|
+
'exponential backoff, Link header pagination, and observability hooks.'
|
|
14
|
+
spec.homepage = 'https://github.com/basecamp/fizzy-sdk'
|
|
15
|
+
spec.license = 'MIT'
|
|
16
|
+
spec.required_ruby_version = '>= 3.2.0'
|
|
17
|
+
|
|
18
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
19
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
20
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/releases"
|
|
21
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
22
|
+
|
|
23
|
+
spec.files = Dir.chdir(__dir__) do
|
|
24
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
25
|
+
(File.expand_path(f) == __FILE__) ||
|
|
26
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
spec.require_paths = [ 'lib' ]
|
|
30
|
+
|
|
31
|
+
# Runtime dependencies
|
|
32
|
+
spec.add_dependency 'faraday', '~> 2.0'
|
|
33
|
+
spec.add_dependency 'zeitwerk', '~> 2.6'
|
|
34
|
+
|
|
35
|
+
# Development dependencies
|
|
36
|
+
spec.add_development_dependency 'minitest', '~> 6.0'
|
|
37
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
|
38
|
+
spec.add_development_dependency 'rubocop-37signals'
|
|
39
|
+
spec.add_development_dependency 'simplecov', '~> 0.22'
|
|
40
|
+
spec.add_development_dependency 'webmock', '~> 3.24'
|
|
41
|
+
spec.add_development_dependency 'irb', '~> 1.15'
|
|
42
|
+
spec.add_development_dependency 'rdoc', '~> 7.1'
|
|
43
|
+
spec.add_development_dependency 'webrick', '~> 1.9'
|
|
44
|
+
spec.add_development_dependency 'yard', '~> 0.9'
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fizzy
|
|
4
|
+
# AuthStrategy controls how authentication is applied to HTTP requests.
|
|
5
|
+
# The default strategy is BearerAuth, which uses a TokenProvider to set
|
|
6
|
+
# the Authorization header with a Bearer token.
|
|
7
|
+
#
|
|
8
|
+
# Custom strategies can implement alternative auth schemes such as
|
|
9
|
+
# cookie-based auth or magic link flows.
|
|
10
|
+
#
|
|
11
|
+
# To implement a custom strategy, create a class that responds to
|
|
12
|
+
# #authenticate(headers), where headers is a Hash that you can modify.
|
|
13
|
+
module AuthStrategy
|
|
14
|
+
# Apply authentication to the given headers hash.
|
|
15
|
+
# @param headers [Hash] the request headers to modify
|
|
16
|
+
def authenticate(headers)
|
|
17
|
+
raise NotImplementedError, "#{self.class} must implement #authenticate"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Bearer token authentication strategy (default).
|
|
22
|
+
# Sets the Authorization header with "Bearer {token}".
|
|
23
|
+
class BearerAuth
|
|
24
|
+
include AuthStrategy
|
|
25
|
+
|
|
26
|
+
# @param token_provider [TokenProvider] provides access tokens
|
|
27
|
+
def initialize(token_provider)
|
|
28
|
+
@token_provider = token_provider
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [TokenProvider] the underlying token provider
|
|
32
|
+
attr_reader :token_provider
|
|
33
|
+
|
|
34
|
+
def authenticate(headers)
|
|
35
|
+
headers["Authorization"] = "Bearer #{@token_provider.access_token}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fizzy
|
|
4
|
+
# Semaphore-based concurrency limiter (bulkhead pattern).
|
|
5
|
+
#
|
|
6
|
+
# Limits the number of concurrent operations to prevent resource exhaustion.
|
|
7
|
+
# When the limit is reached, callers block until a slot becomes available
|
|
8
|
+
# or the timeout expires.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# bulkhead = Fizzy::Bulkhead.new(max_concurrent: 10, timeout: 5)
|
|
12
|
+
# bulkhead.call { http.get("/boards") }
|
|
13
|
+
class Bulkhead
|
|
14
|
+
# @param max_concurrent [Integer] maximum concurrent operations
|
|
15
|
+
# @param timeout [Numeric] seconds to wait for a slot (0 = fail immediately)
|
|
16
|
+
def initialize(max_concurrent: 10, timeout: 5)
|
|
17
|
+
@max_concurrent = max_concurrent
|
|
18
|
+
@timeout = timeout
|
|
19
|
+
@semaphore = Mutex.new
|
|
20
|
+
@condition = ConditionVariable.new
|
|
21
|
+
@current = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Integer] number of currently active operations
|
|
25
|
+
attr_reader :current
|
|
26
|
+
|
|
27
|
+
# Executes the block within the concurrency limit.
|
|
28
|
+
#
|
|
29
|
+
# @yield the operation to execute
|
|
30
|
+
# @return the result of the block
|
|
31
|
+
# @raise [Fizzy::APIError] if no slot is available within timeout
|
|
32
|
+
def call
|
|
33
|
+
acquire_slot
|
|
34
|
+
begin
|
|
35
|
+
yield
|
|
36
|
+
ensure
|
|
37
|
+
release_slot
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def acquire_slot
|
|
44
|
+
deadline = Time.now + @timeout
|
|
45
|
+
|
|
46
|
+
@semaphore.synchronize do
|
|
47
|
+
while @current >= @max_concurrent
|
|
48
|
+
remaining = deadline - Time.now
|
|
49
|
+
if remaining <= 0
|
|
50
|
+
raise Fizzy::APIError.new(
|
|
51
|
+
"Bulkhead limit reached (#{@max_concurrent} concurrent)",
|
|
52
|
+
hint: "Too many concurrent requests, try again later"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
@condition.wait(@semaphore, remaining)
|
|
56
|
+
end
|
|
57
|
+
@current += 1
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def release_slot
|
|
62
|
+
@semaphore.synchronize do
|
|
63
|
+
@current -= 1
|
|
64
|
+
@condition.signal
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/fizzy/cache.rb
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Fizzy
|
|
8
|
+
# ETag-based HTTP cache for GET requests (file-based, opt-in).
|
|
9
|
+
#
|
|
10
|
+
# Stores responses keyed by URL with ETag validation. On cache hit with
|
|
11
|
+
# matching ETag, returns 304 Not Modified without re-downloading the body.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# cache = Fizzy::Cache.new(dir: "/tmp/fizzy-cache")
|
|
15
|
+
# # Used internally by Http when cache is configured
|
|
16
|
+
class Cache
|
|
17
|
+
# @param dir [String] directory for cache files
|
|
18
|
+
# @param max_entries [Integer] maximum cache entries before eviction
|
|
19
|
+
def initialize(dir:, max_entries: 1000)
|
|
20
|
+
@dir = dir
|
|
21
|
+
@max_entries = max_entries
|
|
22
|
+
FileUtils.mkdir_p(@dir)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns cached response headers for conditional request.
|
|
26
|
+
# @param url [String] the request URL
|
|
27
|
+
# @return [Hash, nil] headers with If-None-Match if cached
|
|
28
|
+
def conditional_headers(url)
|
|
29
|
+
entry = read_entry(url)
|
|
30
|
+
return nil unless entry
|
|
31
|
+
|
|
32
|
+
{ "If-None-Match" => entry["etag"] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Stores a response in the cache.
|
|
36
|
+
# @param url [String] the request URL
|
|
37
|
+
# @param etag [String] the ETag header value
|
|
38
|
+
# @param body [String] the response body
|
|
39
|
+
def store(url, etag:, body:)
|
|
40
|
+
return if etag.nil? || etag.empty?
|
|
41
|
+
|
|
42
|
+
evict_if_full
|
|
43
|
+
|
|
44
|
+
entry = {
|
|
45
|
+
"etag" => etag,
|
|
46
|
+
"body" => body,
|
|
47
|
+
"cached_at" => Time.now.to_i
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
path = entry_path(url)
|
|
51
|
+
File.write(path, JSON.generate(entry))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns cached body if available.
|
|
55
|
+
# @param url [String] the request URL
|
|
56
|
+
# @return [String, nil] the cached body
|
|
57
|
+
def get(url)
|
|
58
|
+
entry = read_entry(url)
|
|
59
|
+
entry&.dig("body")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Invalidates a cache entry.
|
|
63
|
+
# @param url [String] the request URL
|
|
64
|
+
def invalidate(url)
|
|
65
|
+
path = entry_path(url)
|
|
66
|
+
File.delete(path) if File.exist?(path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Clears the entire cache.
|
|
70
|
+
def clear
|
|
71
|
+
Dir.glob(File.join(@dir, "*.json")).each { |f| File.delete(f) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def entry_path(url)
|
|
77
|
+
key = Digest::SHA256.hexdigest(url)
|
|
78
|
+
File.join(@dir, "#{key}.json")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def read_entry(url)
|
|
82
|
+
path = entry_path(url)
|
|
83
|
+
return nil unless File.exist?(path)
|
|
84
|
+
|
|
85
|
+
JSON.parse(File.read(path))
|
|
86
|
+
rescue JSON::ParserError
|
|
87
|
+
File.delete(path)
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def evict_if_full
|
|
92
|
+
entries = Dir.glob(File.join(@dir, "*.json"))
|
|
93
|
+
return if entries.length < @max_entries
|
|
94
|
+
|
|
95
|
+
# Evict oldest entries (by mtime)
|
|
96
|
+
entries.sort_by { |f| File.mtime(f) }
|
|
97
|
+
.first(entries.length - @max_entries + 1)
|
|
98
|
+
.each { |f| File.delete(f) }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fizzy
|
|
4
|
+
# Composes multiple Hooks implementations, calling them in sequence.
|
|
5
|
+
# Start events are called in order; end events are called in reverse order.
|
|
6
|
+
class ChainHooks
|
|
7
|
+
include Hooks
|
|
8
|
+
|
|
9
|
+
def initialize(*hooks)
|
|
10
|
+
@hooks = hooks
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def on_operation_start(info)
|
|
14
|
+
@hooks.each { |h| safe_call { h.on_operation_start(info) } }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def on_operation_end(info, result)
|
|
18
|
+
@hooks.reverse_each { |h| safe_call { h.on_operation_end(info, result) } }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def on_request_start(info)
|
|
22
|
+
@hooks.each { |h| safe_call { h.on_request_start(info) } }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_request_end(info, result)
|
|
26
|
+
@hooks.reverse_each { |h| safe_call { h.on_request_end(info, result) } }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def on_retry(info, attempt, error, delay)
|
|
30
|
+
@hooks.each { |h| safe_call { h.on_retry(info, attempt, error, delay) } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def on_paginate(url, page)
|
|
34
|
+
@hooks.each { |h| safe_call { h.on_paginate(url, page) } }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def safe_call
|
|
40
|
+
yield
|
|
41
|
+
rescue => e
|
|
42
|
+
warn "Fizzy::ChainHooks: hook raised #{e.class}: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fizzy
|
|
4
|
+
# Circuit breaker pattern for fault tolerance.
|
|
5
|
+
#
|
|
6
|
+
# Tracks consecutive failures and opens the circuit when the threshold is
|
|
7
|
+
# reached, preventing further requests until the recovery timeout expires.
|
|
8
|
+
#
|
|
9
|
+
# States:
|
|
10
|
+
# - :closed -- normal operation, requests flow through
|
|
11
|
+
# - :open -- circuit tripped, requests fail immediately
|
|
12
|
+
# - :half_open -- recovery probe, single request allowed through
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# breaker = Fizzy::CircuitBreaker.new(threshold: 5, timeout: 30)
|
|
16
|
+
# breaker.call { http.get("/boards") }
|
|
17
|
+
class CircuitBreaker
|
|
18
|
+
# @param threshold [Integer] consecutive failures before opening
|
|
19
|
+
# @param timeout [Numeric] seconds to wait before half-open probe
|
|
20
|
+
def initialize(threshold: 5, timeout: 30)
|
|
21
|
+
@threshold = threshold
|
|
22
|
+
@timeout = timeout
|
|
23
|
+
@failure_count = 0
|
|
24
|
+
@last_failure_at = nil
|
|
25
|
+
@state = :closed
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Symbol] current circuit state (:closed, :open, :half_open)
|
|
30
|
+
def state
|
|
31
|
+
@mutex.synchronize { effective_state }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Executes the block through the circuit breaker.
|
|
35
|
+
#
|
|
36
|
+
# @yield the operation to protect
|
|
37
|
+
# @return the result of the block
|
|
38
|
+
# @raise [Fizzy::APIError] if circuit is open
|
|
39
|
+
def call
|
|
40
|
+
half_open_probe = false
|
|
41
|
+
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
case effective_state
|
|
44
|
+
when :open
|
|
45
|
+
raise Fizzy::APIError.new(
|
|
46
|
+
"Circuit breaker is open",
|
|
47
|
+
retryable: true,
|
|
48
|
+
hint: "Service appears unavailable, will retry after #{@timeout}s"
|
|
49
|
+
)
|
|
50
|
+
when :half_open
|
|
51
|
+
half_open_probe = true
|
|
52
|
+
@state = :half_open
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if half_open_probe
|
|
57
|
+
# Single-probe: hold the probe flag so concurrent callers see :half_open
|
|
58
|
+
# and block (they'll see :open until this probe completes).
|
|
59
|
+
@mutex.synchronize { @state = :open }
|
|
60
|
+
begin
|
|
61
|
+
result = yield
|
|
62
|
+
record_success
|
|
63
|
+
return result
|
|
64
|
+
rescue Fizzy::NetworkError, Fizzy::APIError => e
|
|
65
|
+
record_failure if e.retryable?
|
|
66
|
+
raise
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
result = yield
|
|
72
|
+
record_success
|
|
73
|
+
result
|
|
74
|
+
rescue Fizzy::NetworkError, Fizzy::APIError => e
|
|
75
|
+
record_failure if e.retryable?
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Resets the circuit breaker to closed state.
|
|
81
|
+
def reset
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@failure_count = 0
|
|
84
|
+
@last_failure_at = nil
|
|
85
|
+
@state = :closed
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def effective_state
|
|
92
|
+
if @state == :open && @last_failure_at && \
|
|
93
|
+
(Time.now - @last_failure_at) >= @timeout
|
|
94
|
+
:half_open
|
|
95
|
+
else
|
|
96
|
+
@state
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def record_success
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
@failure_count = 0
|
|
103
|
+
@state = :closed
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def record_failure
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
@failure_count += 1
|
|
110
|
+
@last_failure_at = Time.now
|
|
111
|
+
@state = :open if @failure_count >= @threshold
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/fizzy/client.rb
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fizzy
|
|
4
|
+
# Main client for the Fizzy API.
|
|
5
|
+
#
|
|
6
|
+
# Client holds shared resources and provides service accessors for all
|
|
7
|
+
# 15 Fizzy services. Unlike Basecamp's Client -> AccountClient pattern,
|
|
8
|
+
# Fizzy does not require an account ID -- all services are available
|
|
9
|
+
# directly on the Client.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# config = Fizzy::Config.from_env
|
|
13
|
+
# token_provider = Fizzy::StaticTokenProvider.new(ENV["FIZZY_ACCESS_TOKEN"])
|
|
14
|
+
# client = Fizzy::Client.new(config: config, token_provider: token_provider)
|
|
15
|
+
#
|
|
16
|
+
# boards = client.boards.list.to_a
|
|
17
|
+
# card = client.cards.get(board_id: 1, card_id: 42)
|
|
18
|
+
#
|
|
19
|
+
# @example With custom hooks
|
|
20
|
+
# require "logger"
|
|
21
|
+
# logger = Logger.new($stdout)
|
|
22
|
+
# hooks = Fizzy::LoggerHooks.new(logger)
|
|
23
|
+
#
|
|
24
|
+
# client = Fizzy::Client.new(
|
|
25
|
+
# config: config,
|
|
26
|
+
# token_provider: token_provider,
|
|
27
|
+
# hooks: hooks
|
|
28
|
+
# )
|
|
29
|
+
class Client
|
|
30
|
+
# @return [Config] client configuration
|
|
31
|
+
attr_reader :config
|
|
32
|
+
|
|
33
|
+
# Creates a new Fizzy API client.
|
|
34
|
+
#
|
|
35
|
+
# @param config [Config] configuration settings
|
|
36
|
+
# @param token_provider [TokenProvider, nil] token provider (deprecated, use auth_strategy)
|
|
37
|
+
# @param auth_strategy [AuthStrategy, nil] authentication strategy
|
|
38
|
+
# @param hooks [Hooks, nil] observability hooks
|
|
39
|
+
def initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil)
|
|
40
|
+
raise ArgumentError, "provide either token_provider or auth_strategy, not both" if token_provider && auth_strategy
|
|
41
|
+
raise ArgumentError, "provide token_provider or auth_strategy" if !token_provider && !auth_strategy
|
|
42
|
+
|
|
43
|
+
@config = config
|
|
44
|
+
@hooks = hooks || NoopHooks.new
|
|
45
|
+
@http = Http.new(config: config, token_provider: token_provider, auth_strategy: auth_strategy, hooks: @hooks)
|
|
46
|
+
@services = {}
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @api private
|
|
51
|
+
# Returns the HTTP client for making requests.
|
|
52
|
+
# @return [Http]
|
|
53
|
+
attr_reader :http
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
# Returns the observability hooks.
|
|
57
|
+
# @return [Hooks]
|
|
58
|
+
attr_reader :hooks
|
|
59
|
+
|
|
60
|
+
# Performs a GET request.
|
|
61
|
+
# @param path [String] URL path
|
|
62
|
+
# @param params [Hash] query parameters
|
|
63
|
+
# @return [Response]
|
|
64
|
+
def get(path, params: {})
|
|
65
|
+
@http.get(path, params: params)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Performs a POST request.
|
|
69
|
+
# @param path [String] URL path
|
|
70
|
+
# @param body [Hash, nil] request body
|
|
71
|
+
# @param retryable [Boolean, nil] override retry behavior (true for idempotent POSTs)
|
|
72
|
+
# @return [Response]
|
|
73
|
+
def post(path, body: nil, retryable: nil)
|
|
74
|
+
@http.post(path, body: body, retryable: retryable)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Performs a PUT request.
|
|
78
|
+
# @param path [String] URL path
|
|
79
|
+
# @param body [Hash, nil] request body
|
|
80
|
+
# @return [Response]
|
|
81
|
+
def put(path, body: nil)
|
|
82
|
+
@http.put(path, body: body)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Performs a PATCH request.
|
|
86
|
+
# @param path [String] URL path
|
|
87
|
+
# @param body [Hash, nil] request body
|
|
88
|
+
# @return [Response]
|
|
89
|
+
def patch(path, body: nil)
|
|
90
|
+
@http.patch(path, body: body)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Performs a DELETE request.
|
|
94
|
+
# @param path [String] URL path
|
|
95
|
+
# @param retryable [Boolean, nil] override retry behavior
|
|
96
|
+
# @return [Response]
|
|
97
|
+
def delete(path, retryable: nil)
|
|
98
|
+
@http.delete(path, retryable: retryable)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Performs a POST request with raw binary data.
|
|
102
|
+
# Used for file uploads.
|
|
103
|
+
# @param path [String] URL path
|
|
104
|
+
# @param body [String, IO] raw binary data
|
|
105
|
+
# @param content_type [String] MIME content type
|
|
106
|
+
# @return [Response]
|
|
107
|
+
def post_raw(path, body:, content_type:)
|
|
108
|
+
@http.post_raw(path, body: body, content_type: content_type)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Fetches all pages of a paginated resource.
|
|
112
|
+
# @param path [String] URL path
|
|
113
|
+
# @param params [Hash] query parameters
|
|
114
|
+
# @yield [Hash] each item from the response
|
|
115
|
+
# @return [Enumerator] if no block given
|
|
116
|
+
def paginate(path, params: {}, &)
|
|
117
|
+
@http.paginate(path, params: params, &)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @!group Services
|
|
121
|
+
|
|
122
|
+
# @return [Services::IdentityService]
|
|
123
|
+
def identity
|
|
124
|
+
service(:identity) { Services::IdentityService.new(self) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Services::BoardsService]
|
|
128
|
+
def boards
|
|
129
|
+
service(:boards) { Services::BoardsService.new(self) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Services::ColumnsService]
|
|
133
|
+
def columns
|
|
134
|
+
service(:columns) { Services::ColumnsService.new(self) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [Services::CardsService]
|
|
138
|
+
def cards
|
|
139
|
+
service(:cards) { Services::CardsService.new(self) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Services::CommentsService]
|
|
143
|
+
def comments
|
|
144
|
+
service(:comments) { Services::CommentsService.new(self) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Services::StepsService]
|
|
148
|
+
def steps
|
|
149
|
+
service(:steps) { Services::StepsService.new(self) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Services::ReactionsService]
|
|
153
|
+
def reactions
|
|
154
|
+
service(:reactions) { Services::ReactionsService.new(self) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Services::NotificationsService]
|
|
158
|
+
def notifications
|
|
159
|
+
service(:notifications) { Services::NotificationsService.new(self) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @return [Services::TagsService]
|
|
163
|
+
def tags
|
|
164
|
+
service(:tags) { Services::TagsService.new(self) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# @return [Services::UsersService]
|
|
168
|
+
def users
|
|
169
|
+
service(:users) { Services::UsersService.new(self) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @return [Services::PinsService]
|
|
173
|
+
def pins
|
|
174
|
+
service(:pins) { Services::PinsService.new(self) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @return [Services::UploadsService]
|
|
178
|
+
def uploads
|
|
179
|
+
service(:uploads) { Services::UploadsService.new(self) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @return [Services::WebhooksService]
|
|
183
|
+
def webhooks
|
|
184
|
+
service(:webhooks) { Services::WebhooksService.new(self) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @return [Services::SessionsService]
|
|
188
|
+
def sessions
|
|
189
|
+
service(:sessions) { Services::SessionsService.new(self) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @return [Services::DevicesService]
|
|
193
|
+
def devices
|
|
194
|
+
service(:devices) { Services::DevicesService.new(self) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @return [Services::MiscellaneousService]
|
|
198
|
+
def miscellaneous
|
|
199
|
+
service(:miscellaneous) { Services::MiscellaneousService.new(self) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @!endgroup
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def service(name)
|
|
207
|
+
@mutex.synchronize do
|
|
208
|
+
@services[name] ||= yield
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|