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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module SmilyCli
|
|
7
|
+
# Common behavior shared by every Thor command class in the CLI: building the
|
|
8
|
+
# {Context}/{Client} from resolved options, rendering {Result}s in the chosen
|
|
9
|
+
# format, reading `--data` payloads, and printing status/errors on the right
|
|
10
|
+
# stream (data on stdout, everything else on stderr).
|
|
11
|
+
class BaseCommand < Thor
|
|
12
|
+
# Make Thor exit non-zero on its own argument errors.
|
|
13
|
+
def self.exit_on_failure?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
no_commands do
|
|
18
|
+
# @return [Context] settings resolved from flags/env/profile
|
|
19
|
+
def context
|
|
20
|
+
@context ||= Context.from(options)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Client] API client for the current context
|
|
24
|
+
def client
|
|
25
|
+
@client ||= context.client
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Render a {Result} to stdout in the effective (or overridden) format.
|
|
29
|
+
#
|
|
30
|
+
# @param result [Result]
|
|
31
|
+
# @param format [String, nil] override the context format
|
|
32
|
+
# @param fields [Array<String>, nil] override the context fields
|
|
33
|
+
# @return [void]
|
|
34
|
+
def render_result(result, format: nil, fields: nil)
|
|
35
|
+
fmt = format || context.output_format
|
|
36
|
+
output = Formatters.render(result, format: fmt, fields: fields || context.fields)
|
|
37
|
+
emit(output)
|
|
38
|
+
warn_rate_limit(result)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Print data to stdout (kept free of decoration so it stays pipeable).
|
|
42
|
+
#
|
|
43
|
+
# @param string [String]
|
|
44
|
+
# @return [void]
|
|
45
|
+
def emit(string)
|
|
46
|
+
return if string.nil? || string.empty?
|
|
47
|
+
|
|
48
|
+
$stdout.puts(string)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Print an informational line to stderr unless `--quiet`.
|
|
52
|
+
#
|
|
53
|
+
# @param message [String]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def notice(message)
|
|
56
|
+
warn(message) unless context.quiet?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Print a green success line to stderr.
|
|
60
|
+
#
|
|
61
|
+
# @param message [String]
|
|
62
|
+
# @return [void]
|
|
63
|
+
def success(message)
|
|
64
|
+
notice(Color.green(message))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Read and JSON-parse the `--data` option, which may be inline JSON,
|
|
68
|
+
# `@path` to read a file, or `-` to read stdin. Raises when absent.
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash, Array]
|
|
71
|
+
def require_data
|
|
72
|
+
raw = options["data"]
|
|
73
|
+
if raw.nil? || raw.to_s.empty?
|
|
74
|
+
raise UsageError,
|
|
75
|
+
"This command needs a JSON body: pass --data '<json>', --data @file.json, or --data - for stdin."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
parse_data(raw)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @param raw [String]
|
|
82
|
+
# @return [Object] parsed JSON
|
|
83
|
+
def parse_data(raw)
|
|
84
|
+
content =
|
|
85
|
+
if raw == "-"
|
|
86
|
+
$stdin.read
|
|
87
|
+
elsif raw.start_with?("@")
|
|
88
|
+
read_data_file(raw[1..])
|
|
89
|
+
else
|
|
90
|
+
raw
|
|
91
|
+
end
|
|
92
|
+
JSON.parse(content)
|
|
93
|
+
rescue JSON::ParserError => e
|
|
94
|
+
raise UsageError, "Invalid JSON in --data: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @param path [String]
|
|
98
|
+
# @return [String]
|
|
99
|
+
def read_data_file(path)
|
|
100
|
+
File.read(File.expand_path(path))
|
|
101
|
+
rescue SystemCallError => e
|
|
102
|
+
raise UsageError, "Could not read --data file #{path.inspect}: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Guard a destructive action behind confirmation unless `--yes` is set.
|
|
106
|
+
# In a non-interactive context, refuses rather than blocking.
|
|
107
|
+
#
|
|
108
|
+
# @param message [String]
|
|
109
|
+
# @return [void]
|
|
110
|
+
def confirm!(message)
|
|
111
|
+
return if options["yes"]
|
|
112
|
+
|
|
113
|
+
unless $stdin.respond_to?(:tty?) && $stdin.tty?
|
|
114
|
+
raise UsageError, "#{message} Refusing without confirmation; pass --yes to proceed."
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
$stderr.print("#{message} [y/N] ")
|
|
118
|
+
answer = $stdin.gets&.strip&.downcase
|
|
119
|
+
raise Error, "Aborted." unless %w[y yes].include?(answer)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# In verbose mode, surface the remaining rate-limit budget.
|
|
123
|
+
#
|
|
124
|
+
# @param result [Result]
|
|
125
|
+
# @return [void]
|
|
126
|
+
def warn_rate_limit(result)
|
|
127
|
+
return unless context.verbose?
|
|
128
|
+
|
|
129
|
+
remaining = result.rate_limit_remaining
|
|
130
|
+
notice(Color.dim("rate limit remaining: #{remaining}")) if remaining
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "bookingsync/api" # for the `version` command's BookingSync::API::VERSION
|
|
6
|
+
|
|
7
|
+
module SmilyCli
|
|
8
|
+
# The top-level `smily` command. It wires the reusable resource CRUD commands
|
|
9
|
+
# (one registered subcommand per {Registry} entry), the `auth`/`config`
|
|
10
|
+
# subcommand groups, and a handful of core commands (`api`, `resources`,
|
|
11
|
+
# `whoami`, `completion`, `version`). Business logic lives in the library
|
|
12
|
+
# classes; this class only parses input and dispatches.
|
|
13
|
+
class CLI < BaseCommand
|
|
14
|
+
CLIOptions.connection(self)
|
|
15
|
+
CLIOptions.output(self)
|
|
16
|
+
|
|
17
|
+
package_name "smily"
|
|
18
|
+
|
|
19
|
+
# Single-value global flags that are safe to move past the subcommand.
|
|
20
|
+
HOISTABLE_VALUE_FLAGS = %w[
|
|
21
|
+
--profile --token --base-url --account-id --max-pages --retry
|
|
22
|
+
-o --output --mcp-token --mcp-url
|
|
23
|
+
].freeze
|
|
24
|
+
# Value-less global flags.
|
|
25
|
+
HOISTABLE_BOOL_FLAGS = %w[-v --verbose -q --quiet --no-color].freeze
|
|
26
|
+
|
|
27
|
+
# Every top-level command word, so normalization can tell a real flag value
|
|
28
|
+
# from a subcommand the user meant to run.
|
|
29
|
+
#
|
|
30
|
+
# @return [Array<String>]
|
|
31
|
+
def self.command_names
|
|
32
|
+
@command_names ||= (
|
|
33
|
+
Registry.commands + %w[api auth config mcp resources whoami completion version help]
|
|
34
|
+
).freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Thor's registered subcommands can't see class options that appear *before*
|
|
38
|
+
# the subcommand word, so `smily -o json rentals list` would drop `-o`. We
|
|
39
|
+
# normalize by moving any leading global flags to the end of the args, where
|
|
40
|
+
# every command re-declares them. (Array-valued flags like `--fields` are
|
|
41
|
+
# left alone and must follow the subcommand.)
|
|
42
|
+
#
|
|
43
|
+
# A leading value flag with no attached `=value` whose next token is missing
|
|
44
|
+
# or is itself a command word is a forgotten value (`smily --token rentals
|
|
45
|
+
# list`). We fail with a clear {UsageError} rather than swallow the
|
|
46
|
+
# subcommand into the flag — which otherwise yields a baffling "command not
|
|
47
|
+
# found" or a request fired with a bogus value. Use `--flag=value` when a
|
|
48
|
+
# value legitimately collides with a command name (e.g. a profile named like
|
|
49
|
+
# a resource).
|
|
50
|
+
#
|
|
51
|
+
# @param argv [Array<String>]
|
|
52
|
+
# @return [Array<String>]
|
|
53
|
+
# @raise [UsageError] when a leading value flag is missing its value
|
|
54
|
+
def self.normalize_argv(argv)
|
|
55
|
+
leading = []
|
|
56
|
+
rest = argv.dup
|
|
57
|
+
until rest.empty?
|
|
58
|
+
token = rest.first
|
|
59
|
+
break if token == "--"
|
|
60
|
+
|
|
61
|
+
name = token.split("=", 2).first
|
|
62
|
+
if HOISTABLE_VALUE_FLAGS.include?(name)
|
|
63
|
+
leading << rest.shift
|
|
64
|
+
next if token.include?("=") # value already attached
|
|
65
|
+
|
|
66
|
+
value = rest.first
|
|
67
|
+
unless takes_value?(value)
|
|
68
|
+
raise UsageError,
|
|
69
|
+
"Missing value for #{name}. Pass it as `#{name} <VALUE>`, or as " \
|
|
70
|
+
"`#{name}=<VALUE>` if the value is also a command name."
|
|
71
|
+
end
|
|
72
|
+
leading << rest.shift
|
|
73
|
+
elsif HOISTABLE_BOOL_FLAGS.include?(token)
|
|
74
|
+
leading << rest.shift
|
|
75
|
+
else
|
|
76
|
+
break
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
leading.empty? || rest.empty? ? argv : rest + leading
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A token is a flag's value only when it's present and not a command word
|
|
83
|
+
# the user more likely meant to run (a forgotten flag value).
|
|
84
|
+
#
|
|
85
|
+
# @param token [String, nil]
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def self.takes_value?(token)
|
|
88
|
+
!token.nil? && !command_names.include?(token)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.start(given_args = ARGV, config = {})
|
|
92
|
+
super(normalize_argv(Array(given_args)), config)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Register every known resource as a subcommand with list/get/create/... .
|
|
96
|
+
Registry.all.each do |resource|
|
|
97
|
+
verbs = resource.writable? ? "list|get|create|update|delete" : "list|get"
|
|
98
|
+
register(
|
|
99
|
+
ResourceCommand.bind(resource),
|
|
100
|
+
resource.command,
|
|
101
|
+
"#{resource.command} <#{verbs}>",
|
|
102
|
+
resource.description
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
register(Commands::AuthCommand, "auth", "auth <SUBCOMMAND>",
|
|
107
|
+
"Obtain, refresh and inspect OAuth tokens")
|
|
108
|
+
register(Commands::ConfigCommand, "config", "config <SUBCOMMAND>",
|
|
109
|
+
"Manage config profiles and settings")
|
|
110
|
+
register(Commands::McpCommand, "mcp", "mcp <SUBCOMMAND>",
|
|
111
|
+
"Talk to a BookingSync MCP server (Model Context Protocol)")
|
|
112
|
+
|
|
113
|
+
desc "api METHOD PATH", "Make a raw request to any API v3 endpoint"
|
|
114
|
+
long_desc <<~DESC
|
|
115
|
+
Escape hatch for endpoints (or verbs) the resource commands don't cover.
|
|
116
|
+
The body is sent verbatim — no envelope wrapping.
|
|
117
|
+
|
|
118
|
+
smily api get rentals --query per_page=5
|
|
119
|
+
smily api get bookings/123
|
|
120
|
+
smily api post rentals/42/bookings --data @booking.json
|
|
121
|
+
smily api get rentals --paginate -o ndjson
|
|
122
|
+
|
|
123
|
+
By default the raw JSON response is printed. With --paginate, every page
|
|
124
|
+
is fetched and the combined records are printed as a JSON array.
|
|
125
|
+
DESC
|
|
126
|
+
method_option :query, type: :array, banner: "k=v", desc: "Query params (key=value)"
|
|
127
|
+
method_option :data, type: :string, aliases: "-d", banner: "JSON|@file|-", desc: "Request body"
|
|
128
|
+
method_option :paginate, type: :boolean, desc: "Follow pagination and combine records"
|
|
129
|
+
def api(method, path)
|
|
130
|
+
verb = method.to_s.downcase
|
|
131
|
+
unless %w[get post put patch delete head].include?(verb)
|
|
132
|
+
raise UsageError, "Unknown HTTP method #{method.inspect}. Use get, post, put, patch or delete."
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
query = QueryOptions.build(fields: context.fields, query: options["query"] || [])
|
|
136
|
+
|
|
137
|
+
if options["paginate"]
|
|
138
|
+
result = client.list(path, query: query, all: true)
|
|
139
|
+
render_api(result, records: true)
|
|
140
|
+
else
|
|
141
|
+
body = options["data"] ? parse_data(options["data"]) : nil
|
|
142
|
+
result = client.request(verb, path, query: query, body: body)
|
|
143
|
+
render_api(result, records: false)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
desc "resources [FILTER]", "List the API v3 resources this CLI knows about"
|
|
148
|
+
def resources(filter = nil)
|
|
149
|
+
selected = Registry.all.select { |r| filter.nil? || r.command.include?(filter) }
|
|
150
|
+
|
|
151
|
+
if context.output_format == "table"
|
|
152
|
+
render_resources_table(selected)
|
|
153
|
+
else
|
|
154
|
+
payload = selected.map do |r|
|
|
155
|
+
{ "command" => r.command, "path" => r.path, "group" => r.group,
|
|
156
|
+
"description" => r.description, "readonly" => r.readonly }
|
|
157
|
+
end
|
|
158
|
+
render_result(Result.new(records: payload, single: false))
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
desc "whoami", "Show the current application and account (/me)"
|
|
163
|
+
def whoami
|
|
164
|
+
render_result(client.me)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
desc "completion SHELL", "Print a completion script (bash, zsh or fish)"
|
|
168
|
+
def completion(shell)
|
|
169
|
+
emit(Completion.script(shell))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
desc "version", "Show version information"
|
|
173
|
+
def version
|
|
174
|
+
emit("smily #{SmilyCli::VERSION} (ruby #{RUBY_VERSION}, bookingsync-api #{BookingSync::API::VERSION})")
|
|
175
|
+
end
|
|
176
|
+
map %w[--version] => :version
|
|
177
|
+
|
|
178
|
+
no_commands do
|
|
179
|
+
# Render an `api` result: honor an explicit non-json format, otherwise
|
|
180
|
+
# print raw JSON (records array when paginating, else the full envelope).
|
|
181
|
+
def render_api(result, records:)
|
|
182
|
+
fmt = context.requested_output_format
|
|
183
|
+
# render_result already surfaces the rate-limit note, so return early to
|
|
184
|
+
# avoid warning about it twice.
|
|
185
|
+
return render_result(result, format: fmt) if fmt && fmt != "json"
|
|
186
|
+
|
|
187
|
+
emit(JSON.pretty_generate(records ? result.records : (result.envelope || {})))
|
|
188
|
+
warn_rate_limit(result)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The grouped, human-readable resource listing (table output).
|
|
192
|
+
def render_resources_table(selected)
|
|
193
|
+
selected.group_by(&:group).each do |group, list|
|
|
194
|
+
emit(Color.bold(group))
|
|
195
|
+
width = list.map { |r| r.command.length }.max
|
|
196
|
+
list.each do |r|
|
|
197
|
+
flag = r.readonly ? Color.dim(" (read-only)") : ""
|
|
198
|
+
emit(" #{r.command.ljust(width)} #{Color.dim(r.description)}#{flag}")
|
|
199
|
+
end
|
|
200
|
+
emit("")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmilyCli
|
|
4
|
+
# Declares the reusable Thor option groups so every command exposes the same
|
|
5
|
+
# connection and output flags with identical wording. Because Thor does not
|
|
6
|
+
# forward a parent command's `class_option`s to `register`ed subcommands, each
|
|
7
|
+
# command class opts in by calling these helpers in its body.
|
|
8
|
+
module CLIOptions
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Connection / global behavior flags (token, profile, base URL, verbosity).
|
|
12
|
+
#
|
|
13
|
+
# @param thor [Class<Thor>]
|
|
14
|
+
# @return [void]
|
|
15
|
+
def connection(thor)
|
|
16
|
+
thor.class_option :profile, type: :string, banner: "NAME",
|
|
17
|
+
desc: "Config profile to use (env: SMILY_PROFILE)"
|
|
18
|
+
thor.class_option :token, type: :string, banner: "TOKEN",
|
|
19
|
+
desc: "OAuth access token (env: SMILY_TOKEN)"
|
|
20
|
+
thor.class_option :base_url, type: :string, banner: "URL",
|
|
21
|
+
desc: "API base URL (default: https://www.bookingsync.com)"
|
|
22
|
+
thor.class_option :account_id, type: :string, banner: "ID",
|
|
23
|
+
desc: "Scope requests to an account (for client-credentials tokens)"
|
|
24
|
+
thor.class_option :max_pages, type: :numeric, banner: "N",
|
|
25
|
+
desc: "Cap auto-pagination at N pages (default 1000)"
|
|
26
|
+
thor.class_option :retry, type: :numeric, banner: "N",
|
|
27
|
+
desc: "Retry 429s up to N times, honoring rate-limit reset"
|
|
28
|
+
thor.class_option :verbose, type: :boolean, aliases: "-v",
|
|
29
|
+
desc: "Log HTTP requests/responses to stderr"
|
|
30
|
+
thor.class_option :quiet, type: :boolean, aliases: "-q",
|
|
31
|
+
desc: "Suppress status messages"
|
|
32
|
+
thor.class_option :no_color, type: :boolean,
|
|
33
|
+
desc: "Disable ANSI color"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# MCP-mode connection flags. Shares profile/base-url/account/verbosity with
|
|
37
|
+
# {connection} but swaps the REST token for the dedicated MCP credential.
|
|
38
|
+
#
|
|
39
|
+
# @param thor [Class<Thor>]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def mcp(thor)
|
|
42
|
+
thor.class_option :profile, type: :string, banner: "NAME",
|
|
43
|
+
desc: "Config profile to use (env: SMILY_PROFILE)"
|
|
44
|
+
thor.class_option :mcp_token, type: :string, banner: "TOKEN",
|
|
45
|
+
desc: "MCP access token (env: SMILY_MCP_TOKEN)"
|
|
46
|
+
thor.class_option :mcp_url, type: :string, banner: "URL",
|
|
47
|
+
desc: "MCP server endpoint (default: <base-url>/mcp)"
|
|
48
|
+
thor.class_option :base_url, type: :string, banner: "URL",
|
|
49
|
+
desc: "Base URL used to derive the default MCP endpoint"
|
|
50
|
+
thor.class_option :account_id, type: :string, banner: "ID",
|
|
51
|
+
desc: "Scope tool calls to an account"
|
|
52
|
+
thor.class_option :verbose, type: :boolean, aliases: "-v",
|
|
53
|
+
desc: "Log MCP traffic to stderr"
|
|
54
|
+
thor.class_option :quiet, type: :boolean, aliases: "-q",
|
|
55
|
+
desc: "Suppress status messages"
|
|
56
|
+
thor.class_option :no_color, type: :boolean, desc: "Disable ANSI color"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Output-shaping flags (format and field selection).
|
|
60
|
+
#
|
|
61
|
+
# @param thor [Class<Thor>]
|
|
62
|
+
# @return [void]
|
|
63
|
+
def output(thor)
|
|
64
|
+
thor.class_option :output, type: :string, aliases: "-o", banner: "FORMAT",
|
|
65
|
+
desc: "Output: table, json, yaml, csv, ndjson (default: table on a TTY, else json)"
|
|
66
|
+
thor.class_option :fields, type: :array, banner: "a b c",
|
|
67
|
+
desc: "Limit fields/columns (sparse fieldset)"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "bookingsync/api"
|
|
5
|
+
|
|
6
|
+
module SmilyCli
|
|
7
|
+
# A thin, generic wrapper over the official {BookingSync::API::Client}. It
|
|
8
|
+
# reuses that client's connection handling, JSON:API serialization, `Link`
|
|
9
|
+
# header pagination and typed errors, and adds exactly what a CLI needs:
|
|
10
|
+
# path-based (rather than method-per-resource) access, cross-page collection,
|
|
11
|
+
# write-body wrapping, and translation of gem errors into {ApiError}.
|
|
12
|
+
#
|
|
13
|
+
# Everything is driven by a resource *path*, so any v3 endpoint is reachable —
|
|
14
|
+
# including ones the {Registry} doesn't list.
|
|
15
|
+
class Client
|
|
16
|
+
# @param token [String] OAuth access token
|
|
17
|
+
# @param base_url [String] site base URL (the client appends `/api/v3`)
|
|
18
|
+
# @param account_id [String, Integer, nil] scope requests to one account.
|
|
19
|
+
# Sent as the `account_id` query parameter on every request, which is how
|
|
20
|
+
# a Client-Credentials-flow token (spanning many accounts) is pinned to a
|
|
21
|
+
# single account. Harmless for single-account (Authorization Code) tokens.
|
|
22
|
+
# @param max_pages [Integer] hard backstop on auto-pagination: stop after
|
|
23
|
+
# this many pages even if the server keeps advertising a `next` link.
|
|
24
|
+
# @param retries [Integer] how many times to retry a `429 Too Many Requests`,
|
|
25
|
+
# waiting for the rate-limit window before each retry.
|
|
26
|
+
# @param max_retry_wait [Integer] cap (seconds) on any single retry wait.
|
|
27
|
+
# @param sleeper [#call] injectable sleep, for tests. Defaults to Kernel#sleep.
|
|
28
|
+
# @param verbose [Boolean] log HTTP traffic to stderr
|
|
29
|
+
def initialize(token:, base_url: Context::DEFAULT_BASE_URL, account_id: nil,
|
|
30
|
+
max_pages: Context::DEFAULT_MAX_PAGES, retries: 0,
|
|
31
|
+
max_retry_wait: 60, sleeper: nil, verbose: false)
|
|
32
|
+
@account_id = account_id
|
|
33
|
+
@max_pages = max_pages
|
|
34
|
+
@retries = retries
|
|
35
|
+
@max_retry_wait = max_retry_wait
|
|
36
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
37
|
+
@api = BookingSync::API.new(token, base_url: base_url, logger: build_logger(verbose))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# List a collection, transparently following pagination as requested.
|
|
41
|
+
#
|
|
42
|
+
# @param path [String] collection path, e.g. "rentals"
|
|
43
|
+
# @param query [Hash] query parameters (filters, include, fields, ...)
|
|
44
|
+
# @param limit [Integer, nil] stop after this many records (follows pages)
|
|
45
|
+
# @param page [Integer, nil] fetch one specific page (disables following)
|
|
46
|
+
# @param per_page [Integer, nil] page size hint
|
|
47
|
+
# @param all [Boolean] fetch every page
|
|
48
|
+
# @return [Result]
|
|
49
|
+
def list(path, query: {}, limit: nil, page: nil, per_page: nil, all: false)
|
|
50
|
+
q = stringify(query)
|
|
51
|
+
q["per_page"] = per_page if per_page
|
|
52
|
+
# With a limit but no explicit page size, request exactly what we need
|
|
53
|
+
# (capped at the API max of 100) instead of paging at the default 25.
|
|
54
|
+
q["per_page"] = [limit, 100].min if per_page.nil? && limit && page.nil?
|
|
55
|
+
q["page"] = page if page
|
|
56
|
+
|
|
57
|
+
response = call(:get, path, query: q)
|
|
58
|
+
first = Result.from_response(response)
|
|
59
|
+
records = first.records.dup
|
|
60
|
+
last = first
|
|
61
|
+
|
|
62
|
+
last = follow_pages(response, records, limit: limit) if all || (limit && page.nil?)
|
|
63
|
+
|
|
64
|
+
records = records.first(limit) if limit
|
|
65
|
+
Result.new(
|
|
66
|
+
records: records, single: false,
|
|
67
|
+
envelope: last.envelope, key: last.key,
|
|
68
|
+
status: last.status, headers: last.headers
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Fetch a single member.
|
|
73
|
+
#
|
|
74
|
+
# @param path [String] collection path
|
|
75
|
+
# @param id [String, Integer]
|
|
76
|
+
# @param query [Hash]
|
|
77
|
+
# @return [Result]
|
|
78
|
+
def get(path, id, query: {})
|
|
79
|
+
response = call(:get, member_path(path, id), query: stringify(query))
|
|
80
|
+
Result.from_response(response, single: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Create a member. Wraps attributes into the `{ key: [attrs] }` envelope v3
|
|
84
|
+
# expects, unless the caller already supplied that envelope.
|
|
85
|
+
#
|
|
86
|
+
# @param path [String] collection path
|
|
87
|
+
# @param resource_key [String] JSON:API key (usually the last path segment)
|
|
88
|
+
# @param attributes [Hash, Array] attributes or a ready envelope
|
|
89
|
+
# @param query [Hash]
|
|
90
|
+
# @return [Result]
|
|
91
|
+
def create(path, resource_key, attributes, query: {})
|
|
92
|
+
response = call(:post, path, query: stringify(query), body: wrap_body(resource_key, attributes))
|
|
93
|
+
Result.from_response(response, single: true)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Update a member (HTTP PUT, matching the v3 reference).
|
|
97
|
+
#
|
|
98
|
+
# @return [Result]
|
|
99
|
+
def update(path, resource_key, id, attributes, query: {})
|
|
100
|
+
response = call(:put, member_path(path, id), query: stringify(query), body: wrap_body(resource_key, attributes))
|
|
101
|
+
Result.from_response(response, single: true)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Delete/cancel a member. Returns an empty {Result} on 204.
|
|
105
|
+
#
|
|
106
|
+
# @return [Result]
|
|
107
|
+
def delete(path, id, body: nil, query: {})
|
|
108
|
+
response = call(:delete, member_path(path, id), query: stringify(query), body: body)
|
|
109
|
+
Result.from_response(response, single: true)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Escape hatch: issue an arbitrary request against any path. The body is
|
|
113
|
+
# sent verbatim (no envelope wrapping) so callers stay in full control.
|
|
114
|
+
#
|
|
115
|
+
# @param method [Symbol, String] :get/:post/:put/:patch/:delete
|
|
116
|
+
# @param path [String] path or full URL (base + `/api/v3` are stripped)
|
|
117
|
+
# @param query [Hash]
|
|
118
|
+
# @param body [Hash, String, nil]
|
|
119
|
+
# @return [Result]
|
|
120
|
+
def request(method, path, query: {}, body: nil)
|
|
121
|
+
response = call(method.to_sym, path, query: stringify(query), body: body)
|
|
122
|
+
Result.from_response(response)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Fetch `/me` (current application + account).
|
|
126
|
+
#
|
|
127
|
+
# @return [Result]
|
|
128
|
+
def me
|
|
129
|
+
Result.from_response(call(:get, "me", query: {}), single: true)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Perform one HTTP call through the underlying gem, mapping its typed errors
|
|
135
|
+
# into {ApiError}. Returns the raw {BookingSync::API::Response} (or nil on
|
|
136
|
+
# 204) so pagination can read `Link` relations.
|
|
137
|
+
def call(method, path, query: {}, body: nil)
|
|
138
|
+
with_error_handling do
|
|
139
|
+
m = method.to_sym
|
|
140
|
+
normalized = normalize_path(path)
|
|
141
|
+
q = with_account_scope(query)
|
|
142
|
+
if %i[get head].include?(m)
|
|
143
|
+
@api.call(m, normalized, query: q)
|
|
144
|
+
else
|
|
145
|
+
@api.call(m, normalized, body, query: q)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Fetch the next page from a `Link`-header relation, through the same error
|
|
151
|
+
# handling / retry as a normal call (the relation otherwise bypasses it).
|
|
152
|
+
def fetch_next(relation)
|
|
153
|
+
with_error_handling { relation.call({}, method: :get) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Run a request block, mapping gem errors to {ApiError} and retrying on 429
|
|
157
|
+
# (up to @retries times) after waiting for the rate-limit window.
|
|
158
|
+
def with_error_handling
|
|
159
|
+
attempt = 0
|
|
160
|
+
begin
|
|
161
|
+
yield
|
|
162
|
+
rescue BookingSync::API::RateLimitExceeded => e
|
|
163
|
+
raise ApiError.from_api(e) unless attempt < @retries
|
|
164
|
+
|
|
165
|
+
attempt += 1
|
|
166
|
+
@sleeper.call(retry_delay(e.headers))
|
|
167
|
+
retry
|
|
168
|
+
rescue BookingSync::API::Error => e
|
|
169
|
+
raise ApiError.from_api(e)
|
|
170
|
+
rescue Faraday::Error => e
|
|
171
|
+
raise ApiError, "Network error talking to the API: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Seconds to wait before a retry: prefer `Retry-After`, then the time until
|
|
176
|
+
# `X-RateLimit-Reset`, clamped to [1, @max_retry_wait].
|
|
177
|
+
def retry_delay(headers)
|
|
178
|
+
retry_after = header(headers, "Retry-After").to_i
|
|
179
|
+
return clamp_delay(retry_after) if retry_after.positive?
|
|
180
|
+
|
|
181
|
+
reset = header(headers, "X-RateLimit-Reset").to_i
|
|
182
|
+
return clamp_delay(reset - Time.now.to_i) if reset.positive?
|
|
183
|
+
|
|
184
|
+
1
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def clamp_delay(seconds)
|
|
188
|
+
seconds.clamp(1, @max_retry_wait)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def header(headers, name)
|
|
192
|
+
return nil unless headers
|
|
193
|
+
|
|
194
|
+
headers[name] || headers[name.downcase]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Walk `next` links, appending records to `records` in place, until the
|
|
198
|
+
# limit is reached, there is no next page, a page comes back empty, or the
|
|
199
|
+
# max-pages backstop trips. Returns the {Result} of the last page fetched.
|
|
200
|
+
def follow_pages(response, records, limit:)
|
|
201
|
+
current = response
|
|
202
|
+
last = Result.from_response(response)
|
|
203
|
+
pages = 1
|
|
204
|
+
|
|
205
|
+
loop do
|
|
206
|
+
break if limit && records.length >= limit
|
|
207
|
+
|
|
208
|
+
nxt = current&.relations&.[](:next)
|
|
209
|
+
break unless nxt
|
|
210
|
+
|
|
211
|
+
# We have another page to fetch but have hit the backstop — warn only
|
|
212
|
+
# here (i.e. when data is genuinely being left behind), never when the
|
|
213
|
+
# page count merely equals @max_pages on a fully-fetched collection.
|
|
214
|
+
if pages >= @max_pages
|
|
215
|
+
warn_truncated(pages)
|
|
216
|
+
break
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
current = fetch_next(nxt)
|
|
220
|
+
pages += 1
|
|
221
|
+
page_result = Result.from_response(current)
|
|
222
|
+
break if page_result.records.empty?
|
|
223
|
+
|
|
224
|
+
records.concat(page_result.records)
|
|
225
|
+
last = page_result
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
last
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def member_path(path, id)
|
|
232
|
+
"#{path}/#{id}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def warn_truncated(pages)
|
|
236
|
+
warn("warning: stopped after #{pages} pages (max-pages #{@max_pages}); " \
|
|
237
|
+
"results may be truncated — raise --max-pages to fetch more")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Add the configured account_id to a query unless the caller set one.
|
|
241
|
+
def with_account_scope(query)
|
|
242
|
+
return query if @account_id.nil? || @account_id.to_s.empty?
|
|
243
|
+
return query if query.key?("account_id") || query.key?(:account_id)
|
|
244
|
+
|
|
245
|
+
query.merge("account_id" => @account_id)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def wrap_body(resource_key, attributes)
|
|
249
|
+
case attributes
|
|
250
|
+
when Array
|
|
251
|
+
{ resource_key => attributes }
|
|
252
|
+
when Hash
|
|
253
|
+
return attributes if attributes.key?(resource_key) || attributes.key?(resource_key.to_sym)
|
|
254
|
+
|
|
255
|
+
{ resource_key => [attributes] }
|
|
256
|
+
else
|
|
257
|
+
raise UsageError, "Request body must be a JSON object or array of objects."
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def stringify(query)
|
|
262
|
+
(query || {}).each_with_object({}) do |(k, v), acc|
|
|
263
|
+
next if v.nil?
|
|
264
|
+
|
|
265
|
+
acc[k.to_s] = v
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Accept full URLs, `/api/v3/...`, or bare paths and return a bare path.
|
|
270
|
+
def normalize_path(path)
|
|
271
|
+
p = path.to_s.strip
|
|
272
|
+
p = p.sub(%r{\Ahttps?://[^/]+}, "")
|
|
273
|
+
p = p.sub(%r{\A/}, "")
|
|
274
|
+
p.sub(%r{\Aapi/v3/}, "")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def build_logger(verbose)
|
|
278
|
+
return nil unless verbose
|
|
279
|
+
|
|
280
|
+
logger = Logger.new($stderr)
|
|
281
|
+
logger.level = Logger::DEBUG
|
|
282
|
+
# The API gem's logger middleware dumps request/response headers verbatim,
|
|
283
|
+
# which includes `Authorization: Bearer <token>` — scrub before writing.
|
|
284
|
+
logger.formatter = lambda do |severity, _time, prog, msg|
|
|
285
|
+
label = prog ? "#{prog}: " : ""
|
|
286
|
+
"[smily #{severity.downcase}] #{label}#{Redaction.scrub(msg)}\n"
|
|
287
|
+
end
|
|
288
|
+
logger
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|