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.
- checksums.yaml +4 -4
- data/README.md +63 -0
- data/basecamp-sdk.gemspec +2 -3
- data/lib/basecamp/generated/metadata.json +57 -43
- data/lib/basecamp/generated/services/campfires_service.rb +13 -1
- data/lib/basecamp/generated/services/card_columns_service.rb +26 -23
- data/lib/basecamp/generated/services/reports_service.rb +1 -1
- data/lib/basecamp/generated/services/todos_service.rb +4 -4
- data/lib/basecamp/generated/services/tools_service.rb +7 -6
- data/lib/basecamp/generated/services/uploads_service.rb +17 -0
- data/lib/basecamp/generated/types.rb +32 -8
- data/lib/basecamp/http.rb +86 -15
- data/lib/basecamp/oauth/config.rb +20 -5
- data/lib/basecamp/oauth/discovery.rb +134 -64
- data/lib/basecamp/oauth/discovery_result.rb +33 -0
- data/lib/basecamp/oauth/discovery_selection_error.rb +30 -0
- data/lib/basecamp/oauth/fetcher.rb +204 -0
- data/lib/basecamp/oauth/protected_resource_metadata.rb +22 -0
- data/lib/basecamp/oauth/resource.rb +95 -0
- data/lib/basecamp/oauth.rb +173 -0
- data/lib/basecamp/security.rb +83 -0
- data/lib/basecamp/services/authorization_service.rb +45 -0
- data/lib/basecamp/services/todos_extensions.rb +121 -0
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +6 -0
- data/scripts/generate-services.rb +37 -3
- metadata +13 -21
- data/lib/basecamp/generated/services/authorization_service.rb +0 -47
data/lib/basecamp/security.rb
CHANGED
|
@@ -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
|
data/lib/basecamp/version.rb
CHANGED
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
|
|
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
|
|
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.
|
|
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-
|
|
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
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|