smily_cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +68 -0
- data/.rubocop.yml +70 -0
- data/CHANGELOG.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +387 -0
- data/Rakefile +17 -0
- data/exe/smily +17 -0
- data/lib/smily_cli/base_command.rb +134 -0
- data/lib/smily_cli/cli.rb +205 -0
- data/lib/smily_cli/cli_options.rb +70 -0
- data/lib/smily_cli/client.rb +291 -0
- data/lib/smily_cli/color.rb +53 -0
- data/lib/smily_cli/commands/auth_command.rb +171 -0
- data/lib/smily_cli/commands/config_command.rb +159 -0
- data/lib/smily_cli/commands/mcp_command.rb +195 -0
- data/lib/smily_cli/completion.rb +85 -0
- data/lib/smily_cli/config.rb +192 -0
- data/lib/smily_cli/context.rb +278 -0
- data/lib/smily_cli/errors.rb +143 -0
- data/lib/smily_cli/formatters.rb +222 -0
- data/lib/smily_cli/mcp/client.rb +200 -0
- data/lib/smily_cli/oauth.rb +141 -0
- data/lib/smily_cli/query_options.rb +164 -0
- data/lib/smily_cli/redaction.rb +74 -0
- data/lib/smily_cli/registry.rb +175 -0
- data/lib/smily_cli/resource.rb +42 -0
- data/lib/smily_cli/resource_command.rb +140 -0
- data/lib/smily_cli/result.rb +149 -0
- data/lib/smily_cli/version.rb +5 -0
- data/lib/smily_cli.rb +37 -0
- data/sig/smily_cli.rbs +21 -0
- metadata +157 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "csv"
|
|
6
|
+
|
|
7
|
+
module SmilyCli
|
|
8
|
+
# Rendering of {Result} records into the output formats the CLI supports.
|
|
9
|
+
# Formatters operate on plain record hashes (string keys) and return a String;
|
|
10
|
+
# the command layer decides where to print it. Table output is human-first
|
|
11
|
+
# (aligned columns, optional color, vertical layout for single records); the
|
|
12
|
+
# rest are pipe-friendly and lossless.
|
|
13
|
+
module Formatters
|
|
14
|
+
# Maximum width of a single table column before truncation.
|
|
15
|
+
MAX_COL_WIDTH = 48
|
|
16
|
+
# Default cap on the number of columns shown in a multi-row table.
|
|
17
|
+
MAX_TABLE_COLUMNS = 8
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Render a result in the requested format.
|
|
22
|
+
#
|
|
23
|
+
# @param result [Result]
|
|
24
|
+
# @param format [String] one of {Context::OUTPUT_FORMATS}
|
|
25
|
+
# @param fields [Array<String>, nil] explicit column/field selection
|
|
26
|
+
# @return [String]
|
|
27
|
+
def render(result, format:, fields: nil)
|
|
28
|
+
records = result.records
|
|
29
|
+
payload = result.single? ? records.first : records
|
|
30
|
+
|
|
31
|
+
case format
|
|
32
|
+
when "json" then json(payload)
|
|
33
|
+
when "yaml" then yaml(payload)
|
|
34
|
+
when "ndjson", "jsonl" then ndjson(records)
|
|
35
|
+
when "csv" then csv(records, fields: fields)
|
|
36
|
+
when "table" then table(result, fields: fields)
|
|
37
|
+
else
|
|
38
|
+
raise UsageError, "Unknown output format #{format.inspect}."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [String] pretty JSON (object for single, array for collections)
|
|
43
|
+
def json(payload)
|
|
44
|
+
JSON.pretty_generate(payload.nil? ? [] : payload)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String]
|
|
48
|
+
def yaml(payload)
|
|
49
|
+
YAML.dump(deep_stringify(payload.nil? ? [] : payload))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [String] one compact JSON object per line
|
|
53
|
+
def ndjson(records)
|
|
54
|
+
records.map { |r| JSON.generate(r) }.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [String] CSV with a header row; nested values become compact JSON
|
|
58
|
+
def csv(records, fields: nil)
|
|
59
|
+
return "" if records.empty?
|
|
60
|
+
|
|
61
|
+
rows = rowify(records)
|
|
62
|
+
columns = fields || union_columns(rows)
|
|
63
|
+
CSV.generate do |out|
|
|
64
|
+
out << columns
|
|
65
|
+
rows.each do |record|
|
|
66
|
+
out << columns.map { |c| scalarize(record[c]) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Render a table: a vertical key/value layout for a single record, or an
|
|
72
|
+
# aligned grid for a collection.
|
|
73
|
+
#
|
|
74
|
+
# @param result [Result]
|
|
75
|
+
# @param fields [Array<String>, nil]
|
|
76
|
+
# @return [String]
|
|
77
|
+
def table(result, fields: nil)
|
|
78
|
+
records = rowify(result.records)
|
|
79
|
+
return Color.dim("No records found.") if records.empty?
|
|
80
|
+
|
|
81
|
+
if result.single?
|
|
82
|
+
vertical_table(records.first, fields: fields)
|
|
83
|
+
else
|
|
84
|
+
grid_table(records, fields: fields, meta: table_footer(result, records))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ---- table internals ---------------------------------------------------
|
|
89
|
+
|
|
90
|
+
# Coerce records so table/CSV rendering never assumes a Hash: a non-hash
|
|
91
|
+
# record (a scalar or array an arbitrary MCP tool might return) becomes a
|
|
92
|
+
# single `value` column.
|
|
93
|
+
#
|
|
94
|
+
# @api private
|
|
95
|
+
def rowify(records)
|
|
96
|
+
records.map { |record| record.is_a?(Hash) ? record : { "value" => record } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @api private
|
|
100
|
+
def vertical_table(record, fields: nil)
|
|
101
|
+
columns = fields || record.keys
|
|
102
|
+
width = columns.map { |c| c.to_s.length }.max || 0
|
|
103
|
+
lines = columns.map do |col|
|
|
104
|
+
label = Color.bold(col.to_s.ljust(width))
|
|
105
|
+
"#{label} #{scalarize(record[col], limit: 200)}"
|
|
106
|
+
end
|
|
107
|
+
lines.join("\n")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @api private
|
|
111
|
+
def grid_table(records, fields:, meta: nil)
|
|
112
|
+
columns = fields || default_columns(records)
|
|
113
|
+
widths = column_widths(records, columns)
|
|
114
|
+
numeric = numeric_columns(records, columns)
|
|
115
|
+
|
|
116
|
+
header = columns.map.with_index do |col, i|
|
|
117
|
+
Color.bold(pad(col.to_s, widths[i], right: numeric[col]))
|
|
118
|
+
end.join(" ")
|
|
119
|
+
rule = Color.dim(columns.map.with_index { |_, i| "-" * widths[i] }.join(" "))
|
|
120
|
+
|
|
121
|
+
rows = records.map do |record|
|
|
122
|
+
columns.map.with_index do |col, i|
|
|
123
|
+
pad(scalarize(record[col]), widths[i], right: numeric[col])
|
|
124
|
+
end.join(" ")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
[header, rule, *rows, meta].compact.join("\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @api private
|
|
131
|
+
def table_footer(result, records)
|
|
132
|
+
count = records.length
|
|
133
|
+
parts = []
|
|
134
|
+
parts << "#{count} #{count == 1 ? 'record' : 'records'}"
|
|
135
|
+
parts << "#{result.total_pages} pages total" if result.total_pages
|
|
136
|
+
hidden = hidden_field_note(records)
|
|
137
|
+
parts << hidden if hidden
|
|
138
|
+
Color.dim("(#{parts.join(', ')})")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @api private
|
|
142
|
+
def hidden_field_note(records)
|
|
143
|
+
return nil if records.empty?
|
|
144
|
+
|
|
145
|
+
total = union_columns(records).length
|
|
146
|
+
shown = default_columns(records).length
|
|
147
|
+
return nil if total <= shown
|
|
148
|
+
|
|
149
|
+
"#{total - shown} more fields — use --fields or -o json"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @api private
|
|
153
|
+
def default_columns(records)
|
|
154
|
+
cols = union_columns(records)
|
|
155
|
+
preferred = %w[id]
|
|
156
|
+
ordered = preferred.select { |c| cols.include?(c) } + (cols - preferred)
|
|
157
|
+
ordered.first(MAX_TABLE_COLUMNS)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @api private
|
|
161
|
+
def union_columns(records)
|
|
162
|
+
records.each_with_object([]) do |record, acc|
|
|
163
|
+
record.each_key { |k| acc << k.to_s unless acc.include?(k.to_s) }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# @api private
|
|
168
|
+
def column_widths(records, columns)
|
|
169
|
+
columns.map do |col|
|
|
170
|
+
cells = records.map { |r| scalarize(r[col]).length }
|
|
171
|
+
widest = [col.to_s.length, *cells].max
|
|
172
|
+
[widest, MAX_COL_WIDTH].min
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @api private
|
|
177
|
+
def numeric_columns(records, columns)
|
|
178
|
+
columns.each_with_object({}) do |col, acc|
|
|
179
|
+
values = records.map { |r| r[col] }.compact
|
|
180
|
+
acc[col] = !values.empty? && values.all?(Numeric)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @api private
|
|
185
|
+
def pad(text, width, right: false)
|
|
186
|
+
s = truncate(text.to_s, width)
|
|
187
|
+
right ? s.rjust(width) : s.ljust(width)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @api private
|
|
191
|
+
def truncate(text, width)
|
|
192
|
+
return text if text.length <= width
|
|
193
|
+
return text[0, width] if width <= 1
|
|
194
|
+
|
|
195
|
+
"#{text[0, width - 1]}…"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Render a value as a single-line string suitable for a cell/CSV field.
|
|
199
|
+
# Scalars pass through; Hash/Array become compact JSON.
|
|
200
|
+
#
|
|
201
|
+
# @api private
|
|
202
|
+
def scalarize(value, limit: nil)
|
|
203
|
+
s = case value
|
|
204
|
+
when nil then ""
|
|
205
|
+
when String then value
|
|
206
|
+
when Hash, Array then JSON.generate(value)
|
|
207
|
+
else value.to_s
|
|
208
|
+
end
|
|
209
|
+
s = s.gsub(/\s+/, " ").strip
|
|
210
|
+
limit ? truncate(s, limit) : s
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @api private
|
|
214
|
+
def deep_stringify(value)
|
|
215
|
+
case value
|
|
216
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
217
|
+
when Array then value.map { |v| deep_stringify(v) }
|
|
218
|
+
else value
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SmilyCli
|
|
8
|
+
module Mcp
|
|
9
|
+
# A minimal MCP client for BookingSync's Streamable-HTTP transport.
|
|
10
|
+
#
|
|
11
|
+
# It speaks JSON-RPC 2.0 over `POST <url>`: it performs the `initialize`
|
|
12
|
+
# handshake (capturing the `Mcp-Session-Id` and negotiated protocol version),
|
|
13
|
+
# then issues `tools/list` / `tools/call` (or any method) within that session.
|
|
14
|
+
# Responses are accepted as either `application/json` or a single SSE
|
|
15
|
+
# `message` frame, so it interoperates with both response modes the server
|
|
16
|
+
# may pick.
|
|
17
|
+
#
|
|
18
|
+
# The session is established lazily on first use and reused for the life of
|
|
19
|
+
# the client (i.e. for a single CLI invocation).
|
|
20
|
+
class Client
|
|
21
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
22
|
+
SESSION_HEADER = "Mcp-Session-Id"
|
|
23
|
+
|
|
24
|
+
# @param token [String] MCP access token (sent as a Bearer token)
|
|
25
|
+
# @param url [String] MCP server endpoint, e.g. https://www.bookingsync.com/mcp
|
|
26
|
+
# @param verbose [Boolean] log traffic to stderr
|
|
27
|
+
def initialize(token:, url:, verbose: false)
|
|
28
|
+
@token = token
|
|
29
|
+
@uri = URI(url)
|
|
30
|
+
@verbose = verbose
|
|
31
|
+
@id = 0
|
|
32
|
+
@session_id = nil
|
|
33
|
+
@protocol_version = nil
|
|
34
|
+
@server_info = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Perform (once) the initialize handshake and return the server's info:
|
|
38
|
+
# { "protocolVersion" =>, "capabilities" =>, "serverInfo" => }.
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash]
|
|
41
|
+
def connect
|
|
42
|
+
return @server_info if @server_info
|
|
43
|
+
|
|
44
|
+
result = rpc("initialize", {
|
|
45
|
+
"protocolVersion" => PROTOCOL_VERSION,
|
|
46
|
+
"capabilities" => {},
|
|
47
|
+
"clientInfo" => { "name" => "smily-cli", "version" => SmilyCli::VERSION }
|
|
48
|
+
}, expect_session: true)
|
|
49
|
+
|
|
50
|
+
@protocol_version = result["protocolVersion"] || PROTOCOL_VERSION
|
|
51
|
+
# Best-effort per MCP spec; the server accepts it as a 202 notification.
|
|
52
|
+
notify("notifications/initialized")
|
|
53
|
+
@server_info = result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Array<Hash>] tool definitions ({ "name", "description", "inputSchema" })
|
|
57
|
+
def list_tools
|
|
58
|
+
connect
|
|
59
|
+
collect_paginated("tools/list", "tools")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Call a tool by name.
|
|
63
|
+
#
|
|
64
|
+
# @param name [String]
|
|
65
|
+
# @param arguments [Hash]
|
|
66
|
+
# @return [Hash] the tool result ({ "content" =>, "structuredContent" =>, "isError" => })
|
|
67
|
+
def call_tool(name, arguments = {})
|
|
68
|
+
connect
|
|
69
|
+
rpc("tools/call", { "name" => name, "arguments" => arguments })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Array<Hash>] MCP resource descriptors, if the server exposes any
|
|
73
|
+
def list_resources
|
|
74
|
+
connect
|
|
75
|
+
collect_paginated("resources/list", "resources")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# End the session (best-effort; ignores failures).
|
|
79
|
+
#
|
|
80
|
+
# @return [void]
|
|
81
|
+
def close
|
|
82
|
+
return unless @session_id
|
|
83
|
+
|
|
84
|
+
http_request(Net::HTTP::Delete.new(@uri))
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
ensure
|
|
88
|
+
@session_id = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Issue a JSON-RPC request and return its `result`, raising {McpError} on a
|
|
94
|
+
# JSON-RPC error envelope.
|
|
95
|
+
def rpc(method, params = {}, expect_session: false)
|
|
96
|
+
response = post({ "jsonrpc" => "2.0", "id" => next_id, "method" => method, "params" => params })
|
|
97
|
+
capture_session(response) if expect_session
|
|
98
|
+
envelope = parse_body(response)
|
|
99
|
+
|
|
100
|
+
if (error = envelope["error"])
|
|
101
|
+
raise McpError.new(error["message"] || "MCP error", code: error["code"])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
envelope["result"] || {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Fire a notification (no id, no result expected).
|
|
108
|
+
def notify(method, params = {})
|
|
109
|
+
post({ "jsonrpc" => "2.0", "method" => method, "params" => params })
|
|
110
|
+
rescue McpError
|
|
111
|
+
nil # notifications never carry a meaningful error for us
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Follow `nextCursor` pagination, concatenating `field` across pages.
|
|
115
|
+
def collect_paginated(method, field)
|
|
116
|
+
items = []
|
|
117
|
+
cursor = nil
|
|
118
|
+
loop do
|
|
119
|
+
params = cursor ? { "cursor" => cursor } : {}
|
|
120
|
+
result = rpc(method, params)
|
|
121
|
+
items.concat(Array(result[field]))
|
|
122
|
+
cursor = result["nextCursor"]
|
|
123
|
+
break if cursor.nil? || cursor.to_s.empty?
|
|
124
|
+
end
|
|
125
|
+
items
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def post(payload)
|
|
129
|
+
request = Net::HTTP::Post.new(@uri)
|
|
130
|
+
request["Content-Type"] = "application/json"
|
|
131
|
+
request["Accept"] = "application/json, text/event-stream"
|
|
132
|
+
request.body = JSON.generate(payload)
|
|
133
|
+
http_request(request)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def http_request(request)
|
|
137
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
138
|
+
request[SESSION_HEADER] = @session_id if @session_id
|
|
139
|
+
request["MCP-Protocol-Version"] = @protocol_version if @protocol_version
|
|
140
|
+
|
|
141
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
142
|
+
http.use_ssl = @uri.scheme == "https"
|
|
143
|
+
# Scrub the wire dump so the bearer token never hits stderr in clear.
|
|
144
|
+
http.set_debug_output(Redaction.stream($stderr)) if @verbose
|
|
145
|
+
|
|
146
|
+
response = http.request(request)
|
|
147
|
+
check_transport!(response)
|
|
148
|
+
response
|
|
149
|
+
rescue SocketError, Errno::ECONNREFUSED, Timeout::Error => e
|
|
150
|
+
raise McpError, "Could not reach the MCP server at #{@uri}: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def check_transport!(response)
|
|
154
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
155
|
+
|
|
156
|
+
# Non-2xx: try to surface a JSON-RPC error message, else a generic one.
|
|
157
|
+
detail = begin
|
|
158
|
+
JSON.parse(response.body.to_s).dig("error", "message")
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
code = response.code.to_i
|
|
163
|
+
raise McpError.new(detail || "MCP request failed (HTTP #{response.code})",
|
|
164
|
+
code: code == 401 ? -32_000 : nil)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def capture_session(response)
|
|
168
|
+
session = response[SESSION_HEADER] || response["mcp-session-id"]
|
|
169
|
+
@session_id = session if session && !session.empty?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Parse a response body that may be plain JSON or a single SSE frame
|
|
173
|
+
# (`event: message\ndata: {json}\n\n`).
|
|
174
|
+
def parse_body(response)
|
|
175
|
+
body = response.body.to_s
|
|
176
|
+
return {} if body.empty? || response.code.to_i == 202
|
|
177
|
+
|
|
178
|
+
json = sse?(response, body) ? extract_sse_data(body) : body
|
|
179
|
+
JSON.parse(json)
|
|
180
|
+
rescue JSON::ParserError => e
|
|
181
|
+
raise McpError, "Malformed MCP response: #{e.message}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def sse?(response, body)
|
|
185
|
+
content_type = response["content-type"].to_s
|
|
186
|
+
content_type.include?("text/event-stream") || body.lstrip.start_with?("event:", "data:")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def extract_sse_data(body)
|
|
190
|
+
body.each_line.filter_map do |line|
|
|
191
|
+
line.start_with?("data:") ? line.sub(/\Adata:\s?/, "").chomp : nil
|
|
192
|
+
end.join
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def next_id
|
|
196
|
+
@id += 1
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SmilyCli
|
|
8
|
+
# OAuth 2.0 helper for obtaining and refreshing access tokens against the
|
|
9
|
+
# BookingSync token endpoint (`<base_url>/oauth/token`). Supports the flows
|
|
10
|
+
# documented in the API reference: client credentials, refresh token, and the
|
|
11
|
+
# authorization-code exchange (plus building the authorize URL for the
|
|
12
|
+
# interactive step of that flow).
|
|
13
|
+
#
|
|
14
|
+
# Kept deliberately on Net::HTTP so token acquisition has no dependency on the
|
|
15
|
+
# API client's connection stack.
|
|
16
|
+
class OAuth
|
|
17
|
+
# Structured token response.
|
|
18
|
+
Token = Struct.new(
|
|
19
|
+
:access_token, :token_type, :expires_in, :refresh_token, :scope,
|
|
20
|
+
:created_at, :raw, keyword_init: true
|
|
21
|
+
) do
|
|
22
|
+
# @return [Time, nil] absolute expiry, when derivable
|
|
23
|
+
def expires_at
|
|
24
|
+
return nil unless created_at && expires_in
|
|
25
|
+
|
|
26
|
+
Time.at(created_at.to_i + expires_in.to_i)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param base_url [String] site base URL
|
|
31
|
+
# @param verbose [Boolean]
|
|
32
|
+
def initialize(base_url: Context::DEFAULT_BASE_URL, verbose: false)
|
|
33
|
+
@base_url = base_url
|
|
34
|
+
@verbose = verbose
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Client Credentials flow — an app-level token limited to the `public`
|
|
38
|
+
# scope, able to read across every account that authorized the app.
|
|
39
|
+
#
|
|
40
|
+
# @return [Token]
|
|
41
|
+
def client_credentials(client_id:, client_secret:, scope: nil)
|
|
42
|
+
request_token(
|
|
43
|
+
grant_type: "client_credentials",
|
|
44
|
+
client_id: client_id,
|
|
45
|
+
client_secret: client_secret,
|
|
46
|
+
scope: scope
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Refresh an access token using a refresh token.
|
|
51
|
+
#
|
|
52
|
+
# @return [Token]
|
|
53
|
+
def refresh(client_id:, client_secret:, refresh_token:, redirect_uri: nil)
|
|
54
|
+
request_token(
|
|
55
|
+
grant_type: "refresh_token",
|
|
56
|
+
client_id: client_id,
|
|
57
|
+
client_secret: client_secret,
|
|
58
|
+
refresh_token: refresh_token,
|
|
59
|
+
redirect_uri: redirect_uri
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Exchange an authorization code (from the redirect) for tokens.
|
|
64
|
+
#
|
|
65
|
+
# @return [Token]
|
|
66
|
+
def authorization_code(client_id:, client_secret:, code:, redirect_uri:)
|
|
67
|
+
request_token(
|
|
68
|
+
grant_type: "authorization_code",
|
|
69
|
+
client_id: client_id,
|
|
70
|
+
client_secret: client_secret,
|
|
71
|
+
code: code,
|
|
72
|
+
redirect_uri: redirect_uri
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build the URL a user visits to authorize the app (step 1 of the
|
|
77
|
+
# authorization-code / implicit flows).
|
|
78
|
+
#
|
|
79
|
+
# @param response_type [String] "code" (server-side) or "token" (implicit)
|
|
80
|
+
# @return [String]
|
|
81
|
+
def authorize_url(client_id:, redirect_uri:, scope: nil, state: nil, account_id: nil, response_type: "code")
|
|
82
|
+
params = {
|
|
83
|
+
client_id: client_id,
|
|
84
|
+
redirect_uri: redirect_uri,
|
|
85
|
+
response_type: response_type,
|
|
86
|
+
scope: scope,
|
|
87
|
+
state: state,
|
|
88
|
+
account_id: account_id
|
|
89
|
+
}.reject { |_, v| v.nil? || v.to_s.empty? }
|
|
90
|
+
|
|
91
|
+
uri = URI.join(@base_url, "/oauth/authorize")
|
|
92
|
+
uri.query = URI.encode_www_form(params)
|
|
93
|
+
uri.to_s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def request_token(params)
|
|
99
|
+
body = params.reject { |_, v| v.nil? || v.to_s.empty? }
|
|
100
|
+
uri = URI.join(@base_url, "/oauth/token")
|
|
101
|
+
|
|
102
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
103
|
+
http.use_ssl = uri.scheme == "https"
|
|
104
|
+
# Scrub the wire dump so client_secret / tokens never hit stderr in clear.
|
|
105
|
+
http.set_debug_output(Redaction.stream($stderr)) if @verbose
|
|
106
|
+
|
|
107
|
+
req = Net::HTTP::Post.new(uri)
|
|
108
|
+
req["Accept"] = "application/json"
|
|
109
|
+
req.set_form_data(body)
|
|
110
|
+
|
|
111
|
+
response = http.request(req)
|
|
112
|
+
parse_token(response)
|
|
113
|
+
rescue SocketError, Errno::ECONNREFUSED, Timeout::Error => e
|
|
114
|
+
raise ApiError.new("Could not reach the OAuth endpoint at #{uri}: #{e.message}")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_token(response)
|
|
118
|
+
data = begin
|
|
119
|
+
JSON.parse(response.body.to_s)
|
|
120
|
+
rescue JSON::ParserError
|
|
121
|
+
{}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
125
|
+
message = data["error_description"] || data["error"] || response.message
|
|
126
|
+
raise ApiError.new("OAuth request failed (HTTP #{response.code}): #{message}",
|
|
127
|
+
status: response.code.to_i, body: data)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
Token.new(
|
|
131
|
+
access_token: data["access_token"],
|
|
132
|
+
token_type: data["token_type"],
|
|
133
|
+
expires_in: data["expires_in"],
|
|
134
|
+
refresh_token: data["refresh_token"],
|
|
135
|
+
scope: data["scope"],
|
|
136
|
+
created_at: data["created_at"],
|
|
137
|
+
raw: data
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|