basecamp-sdk 0.7.3 → 0.8.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.
@@ -10,6 +10,15 @@ module Basecamp
10
10
  MAX_RESPONSE_BODY_BYTES = 50 * 1024 * 1024 # 50 MB
11
11
  MAX_ERROR_BODY_BYTES = 1 * 1024 * 1024 # 1 MB
12
12
 
13
+ # The Launchpad authorization endpoint is on a different origin than the
14
+ # configured API base URL, so it is a sanctioned destination for a
15
+ # credentialed cross-origin request. Resource-first discovery may also
16
+ # authorize one specific discovered-and-validated issuer origin (see
17
+ # Http#get_authorization_document, whose issuer comes from internal discovery
18
+ # of the configured base URL, not a caller argument); every other foreign
19
+ # origin is rejected.
20
+ LAUNCHPAD_AUTHORIZATION_URL = "https://launchpad.37signals.com/authorization.json"
21
+
13
22
  def self.truncate(str, max = MAX_ERROR_MESSAGE_BYTES)
14
23
  return str if str.nil? || str.bytesize <= max
15
24
 
@@ -64,6 +73,9 @@ module Basecamp
64
73
  uri = URI.parse(url.to_s)
65
74
  host = uri.host&.downcase
66
75
  return false if host.nil?
76
+ # The carve-out is limited to HTTP(S) so credential guards fail closed
77
+ # on any other scheme (e.g. ws://localhost).
78
+ return false unless %w[http https].include?(uri.scheme&.downcase)
67
79
 
68
80
  host == "localhost" ||
69
81
  host == "127.0.0.1" ||
@@ -80,6 +92,77 @@ module Basecamp
80
92
  require_https!(url, label)
81
93
  end
82
94
 
95
+ # Parses a caller- or metadata-supplied origin and enforces the origin-root
96
+ # profile (SPEC.md §16): https (or http on localhost), host present, optional
97
+ # valid numeric port, path empty or exactly "/", and no query, fragment, or
98
+ # userinfo. Parsing uses Ruby's +URI+ (never a regex) so bracketed IPv6
99
+ # (+http://[::1]:3000+) and ports agree with the host the client dials.
100
+ #
101
+ # A bad *caller* origin is a usage error; callers validating an *advertised*
102
+ # origin rescue {UsageError} and reclassify.
103
+ #
104
+ # @param raw [String] the origin to validate
105
+ # @param label [String] a label for error messages
106
+ # @return [String] the normalized origin (+scheme://host[:port]+, no trailing slash)
107
+ # @raise [UsageError] on any profile violation or parse failure
108
+ def self.require_origin_root!(raw, label = "origin")
109
+ # Reject C0 controls, space, and backslash up front: URL parsers variously
110
+ # strip tabs/newlines/surrounding spaces or convert backslashes, so a
111
+ # malformed spelling could be cleaned and accepted. None is legitimate here.
112
+ if raw.to_s.match?(/[\x00-\x20\\]/)
113
+ raise UsageError.new("#{label} contains invalid characters: #{raw}")
114
+ end
115
+
116
+ uri = URI.parse(raw.to_s)
117
+ scheme = uri.scheme&.downcase
118
+
119
+ unless scheme == "https" || (scheme == "http" && localhost?(raw))
120
+ raise UsageError.new("#{label} must use HTTPS (or http on localhost): #{raw}")
121
+ end
122
+ raise UsageError.new("#{label} has no host: #{raw}") if uri.host.nil? || uri.host.empty?
123
+
124
+ # The raw authority (between "://" and the first "/?#") backs the presence
125
+ # checks the parsed fields miss: URI reports delimiter-only userinfo
126
+ # ("https://@example.com") as an empty (falsy) string, and normalizes a
127
+ # dangling port ("https://example.com:") to the default-port origin.
128
+ authority = raw.to_s.split("://", 2)[1].to_s.split(%r{[/?#]}, 2)[0].to_s
129
+
130
+ # Reject on the PRESENCE of userinfo, not truthiness: an "@" in the authority
131
+ # is always a userinfo delimiter (a host cannot contain one).
132
+ if uri.userinfo || authority.include?("@")
133
+ raise UsageError.new("#{label} must not contain userinfo: #{raw}")
134
+ end
135
+ raise UsageError.new("#{label} must not contain a query or fragment: #{raw}") if uri.query || uri.fragment
136
+ unless uri.path.nil? || uri.path.empty? || uri.path == "/"
137
+ raise UsageError.new("#{label} must be an origin root (no path): #{raw}")
138
+ end
139
+
140
+ # A dangling port delimiter ("https://example.com:") silently accepts a
141
+ # malformed authority. IPv6 authorities legitimately end with "]" (e.g.
142
+ # "[::1]"), so only a trailing ":" is a dangling port.
143
+ if authority.end_with?(":")
144
+ raise UsageError.new("#{label} has an invalid port: #{raw}")
145
+ end
146
+
147
+ # URI.parse rejects a non-numeric port, but it happily accepts a numeric
148
+ # port outside the valid TCP range (e.g. :0 or :99999). Reject anything
149
+ # outside 1–65535 so a structurally-parseable-but-undialable port can never
150
+ # be treated as a trusted origin.
151
+ if uri.port && !uri.port.between?(1, 65_535)
152
+ raise UsageError.new("#{label} has an out-of-range port: #{raw}")
153
+ end
154
+
155
+ # A surviving uri now has a structurally valid, in-range (or default) port.
156
+ # Drop the default port.
157
+ if uri.port && uri.port != uri.default_port
158
+ "#{scheme}://#{uri.host}:#{uri.port}"
159
+ else
160
+ "#{scheme}://#{uri.host}"
161
+ end
162
+ rescue URI::InvalidURIError
163
+ raise UsageError.new("Invalid #{label}: not a valid absolute URL: #{raw}")
164
+ end
165
+
83
166
  # Headers that contain sensitive values and should be redacted.
84
167
  SENSITIVE_HEADERS = %w[
85
168
  authorization
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Service for authorization operations.
6
+ # This is the only service that doesn't require an account context.
7
+ #
8
+ # @example Get authorization info
9
+ # auth = client.authorization.get
10
+ # puts "Identity: #{auth["identity"]["email_address"]}"
11
+ # auth["accounts"].each do |account|
12
+ # puts "Account: #{account["name"]} (#{account["id"]})"
13
+ # end
14
+ class AuthorizationService < BaseService
15
+ # Gets authorization information for the current user.
16
+ #
17
+ # Delegates to {Basecamp::Http#get_authorization_document}, which resolves
18
+ # the issuer via resource-first OAuth discovery (SPEC.md §16) on the client's
19
+ # own configured base URL and fetches the fixed +authorization.json+ path.
20
+ # Only the two *soft* fallback outcomes (+resource_discovery_failed+,
21
+ # +no_as_advertised+) fall back to Launchpad; every *hard* selection failure
22
+ # propagates as a {Oauth::DiscoverySelectionError} — a hard failure is never
23
+ # silently converted into a Launchpad request. This service passes NO issuer,
24
+ # config, or origin to the credentialed fetch, so there is no caller-supplied
25
+ # path through which the bearer token could be sent to a foreign host.
26
+ #
27
+ # Returns the authenticated user's identity and list of accounts
28
+ # they have access to.
29
+ #
30
+ # @return [Hash] authorization info with string keys "identity" and
31
+ # "accounts" (parsed JSON, not symbol keys)
32
+ # @raise [Oauth::DiscoverySelectionError] on a hard discovery failure after
33
+ # a BC5 issuer was advertised and selected
34
+ # @see https://github.com/basecamp/bc3-api/blob/master/sections/authentication.md
35
+ def get
36
+ # Wrap in with_operation like every other service call so observability
37
+ # hooks (logging, metrics, tracing) see this credentialed request and its
38
+ # failures too.
39
+ with_operation(service: "authorization", operation: "get", is_mutation: false) do
40
+ http.get_authorization_document.json
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Merge-safe +update+ and read-modify-write +edit+ for todos, prepended
6
+ # onto the generated {TodosService} (see the +on_load+ hook in
7
+ # +basecamp.rb+).
8
+ #
9
+ # Both compose the public +get+ and +replace+ methods, so hooks observe
10
+ # the two wire operations (+get+ then +replace+), not a synthetic
11
+ # composite.
12
+ #
13
+ # Neither is atomic: there is no conditional-update signal on this
14
+ # endpoint, so a concurrent write between the GET and PUT is
15
+ # overwritten — last write wins for the whole representation. The
16
+ # window is one round-trip. Use +replace+ to overwrite deliberately.
17
+ module TodosExtensions
18
+ # A todo's full writable state, yielded to the +edit+ block. The
19
+ # whole struct is PUT back to the server, so clearing a field means
20
+ # setting it empty (+""+ for strings and dates, +[]+ for ID lists) —
21
+ # there is no third state. +notify+ is a send directive, not todo
22
+ # state: never populated from the current todo, sent only when true.
23
+ TodoFields = Struct.new(
24
+ :content, :description, :assignee_ids, :completion_subscriber_ids,
25
+ :due_on, :starts_on, :notify,
26
+ keyword_init: true
27
+ )
28
+
29
+ # Sets the given fields on a todo and preserves everything else:
30
+ # GETs the current todo, overlays the explicitly-passed keyword
31
+ # arguments, and PUTs the full representation back. An omitted
32
+ # (+nil+) field is untouched, guaranteed; an explicitly-passed empty
33
+ # array clears.
34
+ #
35
+ # Not atomic — see the module docs for the GET→PUT race. Use
36
+ # {#replace} to overwrite deliberately, or {#edit} to clear fields.
37
+ #
38
+ # @param todo_id [Integer] todo id
39
+ # @param content [String, nil] new content (nil = keep current)
40
+ # @param description [String, nil] new description (nil = keep current)
41
+ # @param assignee_ids [Array, nil] complete assignee list ([] clears)
42
+ # @param completion_subscriber_ids [Array, nil] complete subscriber list ([] clears)
43
+ # @param notify [Boolean, nil] notify assignees about this write
44
+ # @param due_on [String, nil] due date YYYY-MM-DD (nil = keep current)
45
+ # @param starts_on [String, nil] start date YYYY-MM-DD (nil = keep current)
46
+ # @return [Hash] the updated todo
47
+ def update(todo_id:, content: nil, description: nil, assignee_ids: nil, completion_subscriber_ids: nil, notify: nil, due_on: nil, starts_on: nil)
48
+ fields = fields_from_todo(get(todo_id: todo_id))
49
+ fields.content = content unless content.nil?
50
+ fields.description = description unless description.nil?
51
+ fields.assignee_ids = assignee_ids unless assignee_ids.nil?
52
+ fields.completion_subscriber_ids = completion_subscriber_ids unless completion_subscriber_ids.nil?
53
+ fields.due_on = due_on unless due_on.nil?
54
+ fields.starts_on = starts_on unless starts_on.nil?
55
+ fields.notify = notify unless notify.nil?
56
+ put_fields(todo_id, fields)
57
+ end
58
+
59
+ # Applies a read-modify-write block to a todo: GETs the current todo,
60
+ # yields its full writable state ({TodoFields}), and PUTs the whole
61
+ # thing back. Clearing a field means setting it empty (+""+ / +[]+) —
62
+ # an untouched field keeps its current value. If the block raises,
63
+ # the edit aborts and nothing is written.
64
+ #
65
+ # Not atomic — see the module docs for the GET→PUT race.
66
+ #
67
+ # @example
68
+ # account.todos.edit(todo_id: 123) do |t|
69
+ # t.content = "🚨 #{t.content}"
70
+ # t.due_on = "" # clearing = setting empty on a full object
71
+ # end
72
+ #
73
+ # @param todo_id [Integer] todo id
74
+ # @yieldparam fields [TodoFields] the todo's writable state, to mutate in place
75
+ # @return [Hash] the updated todo
76
+ # @raise [ArgumentError] if no block is given
77
+ def edit(todo_id:)
78
+ raise ArgumentError, "edit requires a block" unless block_given?
79
+
80
+ fields = fields_from_todo(get(todo_id: todo_id))
81
+ yield fields
82
+ put_fields(todo_id, fields)
83
+ end
84
+
85
+ private
86
+
87
+ # Derives the full writable state from a GET response.
88
+ def fields_from_todo(todo)
89
+ TodoFields.new(
90
+ content: todo["content"] || "",
91
+ description: todo["description"] || "",
92
+ assignee_ids: (todo["assignees"] || []).map { |p| p["id"] },
93
+ completion_subscriber_ids: (todo["completion_subscribers"] || []).map { |p| p["id"] },
94
+ due_on: todo["due_on"] || "",
95
+ starts_on: todo["starts_on"] || "",
96
+ notify: false
97
+ )
98
+ end
99
+
100
+ # PUTs the full writable state via +replace+: content, description,
101
+ # and both ID lists are always sent (empties included, so clears
102
+ # survive); dates only when non-empty (the server clears an omitted
103
+ # date, and +""+ is a format error); notify only when true.
104
+ def put_fields(todo_id, fields)
105
+ %i[assignee_ids completion_subscriber_ids].each do |key|
106
+ raise UsageError, "#{key} must be an array of person IDs; use [] to clear — a full write has no nil state" if fields[key].nil?
107
+ end
108
+ replace(
109
+ todo_id: todo_id,
110
+ content: fields.content,
111
+ description: fields.description,
112
+ assignee_ids: fields.assignee_ids,
113
+ completion_subscriber_ids: fields.completion_subscriber_ids,
114
+ due_on: fields.due_on.to_s.empty? ? nil : fields.due_on,
115
+ starts_on: fields.starts_on.to_s.empty? ? nil : fields.starts_on,
116
+ notify: fields.notify ? true : nil
117
+ )
118
+ end
119
+ end
120
+ end
121
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.7.3"
5
- API_VERSION = "2026-03-23"
4
+ VERSION = "0.8.0"
5
+ API_VERSION = "2026-07-22"
6
6
  end
data/lib/basecamp.rb CHANGED
@@ -4,6 +4,12 @@ require "zeitwerk"
4
4
 
5
5
  loader = Zeitwerk::Loader.for_gem
6
6
  loader.collapse("#{__dir__}/basecamp/generated")
7
+ # The generated class owns the Basecamp::Services::TodosService constant
8
+ # (generated/ is collapsed), so the hand-written merge-safe update/edit
9
+ # surface is prepended onto it as soon as zeitwerk loads it.
10
+ loader.on_load("Basecamp::Services::TodosService") do |klass, _abspath|
11
+ klass.prepend(Basecamp::Services::TodosExtensions)
12
+ end
7
13
  loader.setup
8
14
 
9
15
  # Load generated types if available
@@ -45,7 +45,7 @@ class ServiceGenerator
45
45
  'Campfires' => %w[
46
46
  GetCampfire ListCampfires
47
47
  ListChatbots CreateChatbot GetChatbot UpdateChatbot DeleteChatbot
48
- ListCampfireLines CreateCampfireLine GetCampfireLine DeleteCampfireLine
48
+ ListCampfireLines CreateCampfireLine GetCampfireLine UpdateCampfireLine DeleteCampfireLine
49
49
  ListCampfireUploads CreateCampfireUpload
50
50
  ]
51
51
  },
@@ -69,7 +69,7 @@ class ServiceGenerator
69
69
  'Documents' => %w[GetDocument UpdateDocument ListDocuments CreateDocument]
70
70
  },
71
71
  'Automation' => {
72
- 'Tools' => %w[GetTool UpdateTool DeleteTool CloneTool EnableTool DisableTool RepositionTool],
72
+ 'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool],
73
73
  'Recordings' => %w[GetRecording ArchiveRecording UnarchiveRecording TrashRecording ListRecordings],
74
74
  'Webhooks' => %w[ListWebhooks CreateWebhook GetWebhook UpdateWebhook DeleteWebhook],
75
75
  'Events' => %w[ListEvents],
@@ -115,7 +115,7 @@ class ServiceGenerator
115
115
  'ClientVisibility' => %w[SetClientVisibility]
116
116
  },
117
117
  'Todos' => {
118
- 'Todos' => %w[ListTodos CreateTodo GetTodo UpdateTodo CompleteTodo UncompleteTodo TrashTodo],
118
+ 'Todos' => %w[ListTodos CreateTodo GetTodo ReplaceTodo CompleteTodo UncompleteTodo TrashTodo],
119
119
  'Todolists' => %w[GetTodolistOrGroup UpdateTodolistOrGroup ListTodolists CreateTodolist],
120
120
  'Todosets' => %w[GetTodoset],
121
121
  'HillCharts' => %w[GetHillChart UpdateHillChartSettings],
@@ -190,6 +190,7 @@ class ServiceGenerator
190
190
  'ListCampfireLines' => 'list_lines',
191
191
  'CreateCampfireLine' => 'create_line',
192
192
  'GetCampfireLine' => 'get_line',
193
+ 'UpdateCampfireLine' => 'update_line',
193
194
  'DeleteCampfireLine' => 'delete_line',
194
195
  'ListCampfireUploads' => 'list_uploads',
195
196
  'CreateCampfireUpload' => 'create_upload',
@@ -244,6 +245,7 @@ class ServiceGenerator
244
245
  { prefix: 'Get', method: 'get' },
245
246
  { prefix: 'Create', method: 'create' },
246
247
  { prefix: 'Update', method: 'update' },
248
+ { prefix: 'Replace', method: 'replace' },
247
249
  { prefix: 'Delete', method: 'delete' },
248
250
  { prefix: 'Trash', method: 'trash' },
249
251
  { prefix: 'Archive', method: 'archive' },
@@ -263,6 +265,31 @@ class ServiceGenerator
263
265
  { prefix: 'Search', method: 'search' }
264
266
  ].freeze
265
267
 
268
+ # Hand-written methods appended to specific generated services.
269
+ # Keyed by service name; value is an array of method code strings indented to match generated output.
270
+ HAND_WRITTEN_METHODS = {
271
+ 'Uploads' => [
272
+ <<~RUBY.chomp
273
+ # Download an upload's file content in one call.
274
+ # Fetches upload metadata, then delegates to the AccountClient download
275
+ # primitive so the auth'd-hop + 302-follow flow lives in one place.
276
+ # @param upload_id [Integer] upload id ID
277
+ # @return [Basecamp::DownloadResult]
278
+ def download(upload_id:)
279
+ with_operation(service: "uploads", operation: "download", is_mutation: false, resource_id: upload_id) do
280
+ upload = get(upload_id: upload_id)
281
+ url = upload["download_url"]
282
+ raise UsageError.new("upload \#{upload_id} has no download_url") if url.nil? || url.empty?
283
+
284
+ result = @client.download_url(url)
285
+ filename = upload["filename"]
286
+ filename.to_s.empty? ? result : result.with(filename: filename)
287
+ end
288
+ end
289
+ RUBY
290
+ ]
291
+ }.freeze
292
+
266
293
  SIMPLE_RESOURCES = %w[
267
294
  todo todos todolist todolists todoset message messages comment comments
268
295
  card cards cardtable cardcolumn cardstep column step project projects
@@ -542,6 +569,13 @@ class ServiceGenerator
542
569
  lines.concat(generate_method(op, service_name: service[:name]))
543
570
  end
544
571
 
572
+ (HAND_WRITTEN_METHODS[service[:name]] || []).each do |method_code|
573
+ lines << ''
574
+ method_code.each_line do |l|
575
+ lines << (l.chomp.empty? ? '' : " #{l.chomp}")
576
+ end
577
+ end
578
+
545
579
  lines << ' end'
546
580
  lines << ' end'
547
581
  lines << 'end'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecamp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Basecamp
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-30 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -86,14 +86,14 @@ dependencies:
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '0.22'
89
+ version: '1.0'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '0.22'
96
+ version: '1.0'
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: webmock
99
99
  requirement: !ruby/object:Gem::Requirement
@@ -128,28 +128,14 @@ dependencies:
128
128
  requirements:
129
129
  - - "~>"
130
130
  - !ruby/object:Gem::Version
131
- version: '7.1'
131
+ version: '8.0'
132
132
  type: :development
133
133
  prerelease: false
134
134
  version_requirements: !ruby/object:Gem::Requirement
135
135
  requirements:
136
136
  - - "~>"
137
137
  - !ruby/object:Gem::Version
138
- version: '7.1'
139
- - !ruby/object:Gem::Dependency
140
- name: webrick
141
- requirement: !ruby/object:Gem::Requirement
142
- requirements:
143
- - - "~>"
144
- - !ruby/object:Gem::Version
145
- version: '1.9'
146
- type: :development
147
- prerelease: false
148
- version_requirements: !ruby/object:Gem::Requirement
149
- requirements:
150
- - - "~>"
151
- - !ruby/object:Gem::Version
152
- version: '1.9'
138
+ version: '8.0'
153
139
  - !ruby/object:Gem::Dependency
154
140
  name: yard
155
141
  requirement: !ruby/object:Gem::Requirement
@@ -194,7 +180,6 @@ files:
194
180
  - lib/basecamp/generated/metadata.json
195
181
  - lib/basecamp/generated/services/account_service.rb
196
182
  - lib/basecamp/generated/services/attachments_service.rb
197
- - lib/basecamp/generated/services/authorization_service.rb
198
183
  - lib/basecamp/generated/services/automation_service.rb
199
184
  - lib/basecamp/generated/services/base_service.rb
200
185
  - lib/basecamp/generated/services/boosts_service.rb
@@ -248,11 +233,16 @@ files:
248
233
  - lib/basecamp/oauth.rb
249
234
  - lib/basecamp/oauth/config.rb
250
235
  - lib/basecamp/oauth/discovery.rb
236
+ - lib/basecamp/oauth/discovery_result.rb
237
+ - lib/basecamp/oauth/discovery_selection_error.rb
251
238
  - lib/basecamp/oauth/exchange.rb
252
239
  - lib/basecamp/oauth/exchange_request.rb
240
+ - lib/basecamp/oauth/fetcher.rb
253
241
  - lib/basecamp/oauth/oauth_error.rb
254
242
  - lib/basecamp/oauth/pkce.rb
243
+ - lib/basecamp/oauth/protected_resource_metadata.rb
255
244
  - lib/basecamp/oauth/refresh_request.rb
245
+ - lib/basecamp/oauth/resource.rb
256
246
  - lib/basecamp/oauth/token.rb
257
247
  - lib/basecamp/oauth_token_provider.rb
258
248
  - lib/basecamp/operation_info.rb
@@ -261,6 +251,8 @@ files:
261
251
  - lib/basecamp/request_info.rb
262
252
  - lib/basecamp/request_result.rb
263
253
  - lib/basecamp/security.rb
254
+ - lib/basecamp/services/authorization_service.rb
255
+ - lib/basecamp/services/todos_extensions.rb
264
256
  - lib/basecamp/static_token_provider.rb
265
257
  - lib/basecamp/token_provider.rb
266
258
  - lib/basecamp/usage_error.rb
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Basecamp
4
- module Services
5
- # Service for authorization operations.
6
- # This is the only service that doesn't require an account context.
7
- #
8
- # @example Get authorization info
9
- # auth = client.authorization.get
10
- # puts "Identity: #{auth["identity"]["email_address"]}"
11
- # auth["accounts"].each do |account|
12
- # puts "Account: #{account["name"]} (#{account["id"]})"
13
- # end
14
- class AuthorizationService < BaseService
15
- # Fallback Launchpad endpoint for authorization
16
- LAUNCHPAD_AUTHORIZATION_URL = "https://launchpad.37signals.com/authorization.json"
17
-
18
- # Gets authorization information for the current user.
19
- #
20
- # Attempts to use the authorization endpoint discovered via OAuth discovery
21
- # on the configured base URL. Falls back to Launchpad if discovery fails.
22
- #
23
- # Returns the authenticated user's identity and list of accounts
24
- # they have access to.
25
- #
26
- # @return [Hash] authorization info with :identity and :accounts
27
- # @see https://github.com/basecamp/bc3-api/blob/master/sections/authentication.md
28
- def get
29
- url = discover_authorization_url
30
- response = http.get_absolute(url)
31
- response.json
32
- end
33
-
34
- private
35
-
36
- def discover_authorization_url
37
- # Try OAuth discovery on the configured base URL
38
- config = Oauth.discover(http.base_url)
39
- # Use issuer as base for authorization.json
40
- "#{config.issuer.chomp("/")}/authorization.json"
41
- rescue Oauth::OauthError
42
- # Fall back to Launchpad
43
- LAUNCHPAD_AUTHORIZATION_URL
44
- end
45
- end
46
- end
47
- end