basecamp-sdk 0.2.1
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 +14 -0
- data/.yardopts +6 -0
- data/README.md +293 -0
- data/Rakefile +26 -0
- data/basecamp-sdk.gemspec +46 -0
- data/lib/basecamp/auth_strategy.rb +38 -0
- data/lib/basecamp/chain_hooks.rb +45 -0
- data/lib/basecamp/client.rb +428 -0
- data/lib/basecamp/config.rb +143 -0
- data/lib/basecamp/errors.rb +289 -0
- data/lib/basecamp/generated/metadata.json +2281 -0
- data/lib/basecamp/generated/services/attachments_service.rb +24 -0
- data/lib/basecamp/generated/services/boosts_service.rb +70 -0
- data/lib/basecamp/generated/services/campfires_service.rb +122 -0
- data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
- data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
- data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
- data/lib/basecamp/generated/services/cards_service.rb +66 -0
- data/lib/basecamp/generated/services/checkins_service.rb +157 -0
- data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
- data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
- data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
- data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
- data/lib/basecamp/generated/services/comments_service.rb +49 -0
- data/lib/basecamp/generated/services/documents_service.rb +52 -0
- data/lib/basecamp/generated/services/events_service.rb +20 -0
- data/lib/basecamp/generated/services/forwards_service.rb +67 -0
- data/lib/basecamp/generated/services/lineup_service.rb +44 -0
- data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
- data/lib/basecamp/generated/services/message_types_service.rb +59 -0
- data/lib/basecamp/generated/services/messages_service.rb +75 -0
- data/lib/basecamp/generated/services/people_service.rb +73 -0
- data/lib/basecamp/generated/services/projects_service.rb +63 -0
- data/lib/basecamp/generated/services/recordings_service.rb +64 -0
- data/lib/basecamp/generated/services/reports_service.rb +56 -0
- data/lib/basecamp/generated/services/schedules_service.rb +92 -0
- data/lib/basecamp/generated/services/search_service.rb +31 -0
- data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
- data/lib/basecamp/generated/services/templates_service.rb +82 -0
- data/lib/basecamp/generated/services/timeline_service.rb +20 -0
- data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
- data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
- data/lib/basecamp/generated/services/todolists_service.rb +53 -0
- data/lib/basecamp/generated/services/todos_service.rb +106 -0
- data/lib/basecamp/generated/services/todosets_service.rb +20 -0
- data/lib/basecamp/generated/services/tools_service.rb +80 -0
- data/lib/basecamp/generated/services/uploads_service.rb +61 -0
- data/lib/basecamp/generated/services/vaults_service.rb +49 -0
- data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
- data/lib/basecamp/generated/types.rb +3196 -0
- data/lib/basecamp/hooks.rb +70 -0
- data/lib/basecamp/http.rb +440 -0
- data/lib/basecamp/logger_hooks.rb +46 -0
- data/lib/basecamp/noop_hooks.rb +9 -0
- data/lib/basecamp/oauth/discovery.rb +123 -0
- data/lib/basecamp/oauth/errors.rb +35 -0
- data/lib/basecamp/oauth/exchange.rb +291 -0
- data/lib/basecamp/oauth/pkce.rb +68 -0
- data/lib/basecamp/oauth/types.rb +133 -0
- data/lib/basecamp/oauth.rb +56 -0
- data/lib/basecamp/oauth_token_provider.rb +108 -0
- data/lib/basecamp/operation_info.rb +17 -0
- data/lib/basecamp/request_info.rb +10 -0
- data/lib/basecamp/request_result.rb +14 -0
- data/lib/basecamp/security.rb +112 -0
- data/lib/basecamp/services/attachments_service.rb +33 -0
- data/lib/basecamp/services/authorization_service.rb +47 -0
- data/lib/basecamp/services/base_service.rb +146 -0
- data/lib/basecamp/services/campfires_service.rb +141 -0
- data/lib/basecamp/services/card_columns_service.rb +106 -0
- data/lib/basecamp/services/card_steps_service.rb +86 -0
- data/lib/basecamp/services/card_tables_service.rb +23 -0
- data/lib/basecamp/services/cards_service.rb +93 -0
- data/lib/basecamp/services/checkins_service.rb +127 -0
- data/lib/basecamp/services/client_approvals_service.rb +33 -0
- data/lib/basecamp/services/client_correspondences_service.rb +33 -0
- data/lib/basecamp/services/client_replies_service.rb +35 -0
- data/lib/basecamp/services/comments_service.rb +63 -0
- data/lib/basecamp/services/documents_service.rb +74 -0
- data/lib/basecamp/services/events_service.rb +27 -0
- data/lib/basecamp/services/forwards_service.rb +80 -0
- data/lib/basecamp/services/lineup_service.rb +67 -0
- data/lib/basecamp/services/message_boards_service.rb +24 -0
- data/lib/basecamp/services/message_types_service.rb +79 -0
- data/lib/basecamp/services/messages_service.rb +133 -0
- data/lib/basecamp/services/people_service.rb +73 -0
- data/lib/basecamp/services/projects_service.rb +67 -0
- data/lib/basecamp/services/recordings_service.rb +127 -0
- data/lib/basecamp/services/reports_service.rb +80 -0
- data/lib/basecamp/services/schedules_service.rb +156 -0
- data/lib/basecamp/services/search_service.rb +36 -0
- data/lib/basecamp/services/subscriptions_service.rb +67 -0
- data/lib/basecamp/services/templates_service.rb +96 -0
- data/lib/basecamp/services/timeline_service.rb +62 -0
- data/lib/basecamp/services/timesheet_service.rb +68 -0
- data/lib/basecamp/services/todolist_groups_service.rb +100 -0
- data/lib/basecamp/services/todolists_service.rb +104 -0
- data/lib/basecamp/services/todos_service.rb +156 -0
- data/lib/basecamp/services/todosets_service.rb +23 -0
- data/lib/basecamp/services/tools_service.rb +89 -0
- data/lib/basecamp/services/uploads_service.rb +84 -0
- data/lib/basecamp/services/vaults_service.rb +84 -0
- data/lib/basecamp/services/webhooks_service.rb +88 -0
- data/lib/basecamp/static_token_provider.rb +24 -0
- data/lib/basecamp/token_provider.rb +42 -0
- data/lib/basecamp/version.rb +6 -0
- data/lib/basecamp/webhooks/event.rb +52 -0
- data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
- data/lib/basecamp/webhooks/receiver.rb +161 -0
- data/lib/basecamp/webhooks/verify.rb +36 -0
- data/lib/basecamp.rb +107 -0
- data/scripts/generate-metadata.rb +106 -0
- data/scripts/generate-services.rb +778 -0
- data/scripts/generate-types.rb +191 -0
- metadata +316 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "receiver"
|
|
4
|
+
|
|
5
|
+
module Basecamp
|
|
6
|
+
module Webhooks
|
|
7
|
+
# Rack middleware that intercepts POST requests to a configurable path
|
|
8
|
+
# and dispatches them to a WebhookReceiver for processing.
|
|
9
|
+
class RackMiddleware
|
|
10
|
+
DEFAULT_PATH = "/webhooks/basecamp"
|
|
11
|
+
|
|
12
|
+
def initialize(app, receiver:, path: DEFAULT_PATH)
|
|
13
|
+
@app = app
|
|
14
|
+
@receiver = receiver
|
|
15
|
+
@path = path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(env)
|
|
19
|
+
unless env["PATH_INFO"] == @path
|
|
20
|
+
return @app.call(env)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
unless env["REQUEST_METHOD"] == "POST"
|
|
24
|
+
return [ 405, { "Content-Type" => "text/plain" }, [ "Method Not Allowed" ] ]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
body = env["rack.input"].read
|
|
28
|
+
env["rack.input"].rewind
|
|
29
|
+
|
|
30
|
+
headers = lambda { |name|
|
|
31
|
+
# Rack normalizes headers to HTTP_UPPER_CASE format
|
|
32
|
+
rack_key = "HTTP_#{name.upcase.tr('-', '_')}"
|
|
33
|
+
env[rack_key]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
@receiver.handle_request(raw_body: body, headers: headers)
|
|
38
|
+
[ 200, { "Content-Type" => "text/plain" }, [ "OK" ] ]
|
|
39
|
+
rescue VerificationError
|
|
40
|
+
[ 401, { "Content-Type" => "text/plain" }, [ "Unauthorized" ] ]
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
[ 400, { "Content-Type" => "text/plain" }, [ "Bad Request" ] ]
|
|
43
|
+
rescue StandardError
|
|
44
|
+
[ 500, { "Content-Type" => "text/plain" }, [ "Internal Server Error" ] ]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "event"
|
|
5
|
+
require_relative "verify"
|
|
6
|
+
|
|
7
|
+
module Basecamp
|
|
8
|
+
module Webhooks
|
|
9
|
+
class VerificationError < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Receives and routes webhook events from Basecamp.
|
|
12
|
+
# Framework-agnostic: works with raw body strings and a header accessor.
|
|
13
|
+
class Receiver
|
|
14
|
+
DEFAULT_SIGNATURE_HEADER = "X-Basecamp-Signature"
|
|
15
|
+
DEFAULT_DEDUP_WINDOW_SIZE = 1000
|
|
16
|
+
|
|
17
|
+
def initialize(secret: nil, signature_header: DEFAULT_SIGNATURE_HEADER, dedup_window_size: DEFAULT_DEDUP_WINDOW_SIZE)
|
|
18
|
+
@secret = secret
|
|
19
|
+
@signature_header = signature_header
|
|
20
|
+
@dedup_window_size = dedup_window_size
|
|
21
|
+
@handlers = {}
|
|
22
|
+
@any_handlers = []
|
|
23
|
+
@middleware = []
|
|
24
|
+
@dedup_seen = {}
|
|
25
|
+
@dedup_pending = {}
|
|
26
|
+
@dedup_order = []
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Register a handler for a specific event kind pattern.
|
|
31
|
+
# Supports glob patterns: "todo_*" matches "todo_created", etc.
|
|
32
|
+
def on(pattern, &handler)
|
|
33
|
+
@handlers[pattern] ||= []
|
|
34
|
+
@handlers[pattern] << handler
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Register a handler for all events.
|
|
39
|
+
def on_any(&handler)
|
|
40
|
+
@any_handlers << handler
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Add middleware to the processing chain.
|
|
45
|
+
# Middleware receives (event, next_proc) and must call next_proc.call to continue.
|
|
46
|
+
def use(&middleware)
|
|
47
|
+
@middleware << middleware
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Process a raw webhook request.
|
|
52
|
+
# Returns the parsed Event.
|
|
53
|
+
# Raises VerificationError if signature is invalid.
|
|
54
|
+
def handle_request(raw_body:, headers:)
|
|
55
|
+
# Verify signature
|
|
56
|
+
if @secret && !@secret.empty?
|
|
57
|
+
signature = extract_header(headers, @signature_header)
|
|
58
|
+
unless Verify.valid?(payload: raw_body, signature: signature, secret: @secret)
|
|
59
|
+
raise VerificationError, "invalid webhook signature"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parse event
|
|
64
|
+
hash = JSON.parse(raw_body)
|
|
65
|
+
event = Event.new(hash)
|
|
66
|
+
|
|
67
|
+
# Atomic dedup: claim before handlers, commit on success, release on error
|
|
68
|
+
return event unless claim(event.id)
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
# Build middleware chain
|
|
72
|
+
run_handlers = -> { dispatch_handlers(event) }
|
|
73
|
+
chain = @middleware.reverse.reduce(run_handlers) do |next_fn, mw|
|
|
74
|
+
-> { mw.call(event, next_fn) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
chain.call
|
|
78
|
+
|
|
79
|
+
# Promote from pending to seen on success
|
|
80
|
+
commit_seen(event.id)
|
|
81
|
+
rescue => e
|
|
82
|
+
# Release claim so retries can re-attempt
|
|
83
|
+
release_claim(event.id)
|
|
84
|
+
raise e
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
event
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def extract_header(headers, name)
|
|
93
|
+
if headers.respond_to?(:call)
|
|
94
|
+
headers.call(name)
|
|
95
|
+
elsif headers.respond_to?(:[])
|
|
96
|
+
# Try exact match first, then case-insensitive
|
|
97
|
+
headers[name] || headers[name.downcase] || headers[name.upcase]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns true if the event was claimed (caller should process it).
|
|
102
|
+
# Returns false if already seen or in-flight.
|
|
103
|
+
def claim(event_id)
|
|
104
|
+
return true if @dedup_window_size <= 0 || event_id.nil?
|
|
105
|
+
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
return false if @dedup_seen.key?(event_id) || @dedup_pending.key?(event_id)
|
|
108
|
+
@dedup_pending[event_id] = true
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Promote from pending to seen after successful handling.
|
|
114
|
+
def commit_seen(event_id)
|
|
115
|
+
return if @dedup_window_size <= 0 || event_id.nil?
|
|
116
|
+
|
|
117
|
+
@mutex.synchronize do
|
|
118
|
+
@dedup_pending.delete(event_id)
|
|
119
|
+
|
|
120
|
+
if @dedup_order.size >= @dedup_window_size
|
|
121
|
+
oldest = @dedup_order.shift
|
|
122
|
+
@dedup_seen.delete(oldest)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@dedup_seen[event_id] = true
|
|
126
|
+
@dedup_order << event_id
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Release claim so retries can re-attempt.
|
|
131
|
+
def release_claim(event_id)
|
|
132
|
+
return if event_id.nil?
|
|
133
|
+
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
@dedup_pending.delete(event_id)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def dispatch_handlers(event)
|
|
140
|
+
matched = []
|
|
141
|
+
|
|
142
|
+
@handlers.each do |pattern, handlers|
|
|
143
|
+
matched.concat(handlers) if match_pattern?(pattern, event.kind)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
matched.concat(@any_handlers)
|
|
147
|
+
|
|
148
|
+
matched.each { |handler| handler.call(event) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def match_pattern?(pattern, value)
|
|
152
|
+
return false if value.nil?
|
|
153
|
+
return true if pattern == value
|
|
154
|
+
|
|
155
|
+
# Convert glob pattern to regex
|
|
156
|
+
regex_str = pattern.split("*", -1).map { |part| Regexp.escape(part) }.join(".*")
|
|
157
|
+
Regexp.new("\\A#{regex_str}\\z").match?(value)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Basecamp
|
|
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/basecamp.rb
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zeitwerk"
|
|
4
|
+
|
|
5
|
+
# Set up Zeitwerk loader
|
|
6
|
+
loader = Zeitwerk::Loader.for_gem
|
|
7
|
+
# No custom inflections - use standard Ruby camelcase (Http, Oauth, etc.)
|
|
8
|
+
|
|
9
|
+
# Ignore hand-written services - we use generated services instead (spec-conformant)
|
|
10
|
+
# EXCEPT: base_service.rb (infrastructure) and authorization_service.rb (OAuth, not in spec)
|
|
11
|
+
loader.ignore("#{__dir__}/basecamp/services")
|
|
12
|
+
|
|
13
|
+
# Collapse the generated directory so Basecamp::Generated::Services becomes Basecamp::Services
|
|
14
|
+
loader.collapse("#{__dir__}/basecamp/generated")
|
|
15
|
+
|
|
16
|
+
# Ignore errors.rb - it defines multiple classes, loaded explicitly below
|
|
17
|
+
loader.ignore("#{__dir__}/basecamp/errors.rb")
|
|
18
|
+
# Ignore auth_strategy.rb - defines both AuthStrategy and BearerAuth
|
|
19
|
+
loader.ignore("#{__dir__}/basecamp/auth_strategy.rb")
|
|
20
|
+
# Ignore operation_info.rb - defines both OperationInfo and OperationResult
|
|
21
|
+
loader.ignore("#{__dir__}/basecamp/operation_info.rb")
|
|
22
|
+
loader.setup
|
|
23
|
+
|
|
24
|
+
# Load infrastructure that generated services depend on
|
|
25
|
+
require_relative "basecamp/errors"
|
|
26
|
+
require_relative "basecamp/auth_strategy"
|
|
27
|
+
require_relative "basecamp/operation_info"
|
|
28
|
+
require_relative "basecamp/services/base_service"
|
|
29
|
+
require_relative "basecamp/services/authorization_service"
|
|
30
|
+
|
|
31
|
+
# Load generated types if available
|
|
32
|
+
begin
|
|
33
|
+
require_relative "basecamp/generated/types"
|
|
34
|
+
rescue LoadError
|
|
35
|
+
# Generated types not available yet
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Main entry point for the Basecamp SDK.
|
|
39
|
+
#
|
|
40
|
+
# The SDK follows a Client -> AccountClient pattern:
|
|
41
|
+
# - Client: Holds shared resources (HTTP client, token provider, hooks)
|
|
42
|
+
# - AccountClient: Bound to a specific account ID, provides service accessors
|
|
43
|
+
#
|
|
44
|
+
# @example Basic usage
|
|
45
|
+
# config = Basecamp::Config.new(base_url: "https://3.basecampapi.com")
|
|
46
|
+
# token = Basecamp::StaticTokenProvider.new(ENV["BASECAMP_TOKEN"])
|
|
47
|
+
#
|
|
48
|
+
# client = Basecamp::Client.new(config: config, token_provider: token)
|
|
49
|
+
# account = client.for_account("12345")
|
|
50
|
+
#
|
|
51
|
+
# # Use services (returns lazy Enumerator)
|
|
52
|
+
# projects = account.projects.list.to_a
|
|
53
|
+
#
|
|
54
|
+
# @example With hooks for logging
|
|
55
|
+
# class MyHooks
|
|
56
|
+
# include Basecamp::Hooks
|
|
57
|
+
#
|
|
58
|
+
# def on_request_start(info)
|
|
59
|
+
# puts "Starting #{info.method} #{info.url}"
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
# def on_request_end(info, result)
|
|
63
|
+
# puts "Completed in #{result.duration}s"
|
|
64
|
+
# end
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# client = Basecamp::Client.new(config: config, token_provider: token, hooks: MyHooks.new)
|
|
68
|
+
module Basecamp
|
|
69
|
+
# Creates a new Basecamp client.
|
|
70
|
+
#
|
|
71
|
+
# This is a convenience method that creates a Client with the given options.
|
|
72
|
+
#
|
|
73
|
+
# @param access_token [String, nil] OAuth access token
|
|
74
|
+
# @param auth [AuthStrategy, nil] custom authentication strategy
|
|
75
|
+
# @param account_id [String, nil] Basecamp account ID (optional)
|
|
76
|
+
# @param base_url [String] Base URL for API requests
|
|
77
|
+
# @param hooks [Hooks, nil] Observability hooks
|
|
78
|
+
# @return [Client, AccountClient] Client if no account_id, AccountClient if account_id provided
|
|
79
|
+
#
|
|
80
|
+
# @example With access token
|
|
81
|
+
# client = Basecamp.client(access_token: "abc123", account_id: "12345")
|
|
82
|
+
# projects = client.projects.list.to_a
|
|
83
|
+
#
|
|
84
|
+
# @example With custom auth strategy
|
|
85
|
+
# client = Basecamp.client(auth: MyCustomAuth.new, account_id: "12345")
|
|
86
|
+
def self.client(
|
|
87
|
+
access_token: nil,
|
|
88
|
+
auth: nil,
|
|
89
|
+
account_id: nil,
|
|
90
|
+
base_url: Config::DEFAULT_BASE_URL,
|
|
91
|
+
hooks: nil
|
|
92
|
+
)
|
|
93
|
+
raise ArgumentError, "provide either access_token or auth, not both" if access_token && auth
|
|
94
|
+
raise ArgumentError, "provide access_token or auth" if !access_token && !auth
|
|
95
|
+
|
|
96
|
+
config = Config.new(base_url: base_url)
|
|
97
|
+
|
|
98
|
+
client = if auth
|
|
99
|
+
Client.new(config: config, auth_strategy: auth, hooks: hooks)
|
|
100
|
+
else
|
|
101
|
+
token_provider = StaticTokenProvider.new(access_token)
|
|
102
|
+
Client.new(config: config, token_provider: token_provider, hooks: hooks)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
account_id ? client.for_account(account_id) : client
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Extracts x-basecamp-* 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/ruby/generate-metadata.rb > lib/basecamp/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://basecamp.com/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-basecamp-retry
|
|
50
|
+
if (retry_config = operation['x-basecamp-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-basecamp-pagination
|
|
60
|
+
if (pagination = operation['x-basecamp-pagination'])
|
|
61
|
+
metadata['pagination'] = {
|
|
62
|
+
'style' => pagination['style'],
|
|
63
|
+
'pageParam' => pagination['pageParam'],
|
|
64
|
+
'totalCountHeader' => pagination['totalCountHeader'],
|
|
65
|
+
'maxPageSize' => pagination['maxPageSize']
|
|
66
|
+
}.compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Extract x-basecamp-idempotent
|
|
70
|
+
if (idempotent = operation['x-basecamp-idempotent'])
|
|
71
|
+
metadata['idempotent'] = {
|
|
72
|
+
'keySupported' => idempotent['keySupported'],
|
|
73
|
+
'keyHeader' => idempotent['keyHeader'],
|
|
74
|
+
'natural' => idempotent['natural']
|
|
75
|
+
}.compact
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Extract x-basecamp-sensitive
|
|
79
|
+
if (sensitive = operation['x-basecamp-sensitive'])
|
|
80
|
+
metadata['sensitive'] = sensitive.map do |s|
|
|
81
|
+
{
|
|
82
|
+
'field' => s['field'],
|
|
83
|
+
'category' => s['category'],
|
|
84
|
+
'redact' => s['redact']
|
|
85
|
+
}.compact
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
metadata
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Main execution
|
|
94
|
+
if __FILE__ == $PROGRAM_NAME
|
|
95
|
+
openapi_path = ARGV[0] || File.expand_path('../../openapi.json', __dir__)
|
|
96
|
+
|
|
97
|
+
unless File.exist?(openapi_path)
|
|
98
|
+
warn "Error: OpenAPI file not found: #{openapi_path}"
|
|
99
|
+
exit 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
extractor = MetadataExtractor.new(openapi_path)
|
|
103
|
+
metadata = extractor.extract
|
|
104
|
+
|
|
105
|
+
puts JSON.pretty_generate(metadata)
|
|
106
|
+
end
|