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
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
@@ -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
@@ -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