sillydev 1.0.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.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "uri"
6
+
7
+ module SillyDev
8
+ # Client for the Silly Development API.
9
+ #
10
+ # api_key defaults to the SILLYDEV_API_KEY environment variable and base_url to SILLYDEV_BASE_URL
11
+ # or https://sillydev.co.uk/panel/api/client. Requests answered 429 are retried up to max_retries
12
+ # times, and reads answered 502, 503 or 504 too, waiting as long as Retry-After asks.
13
+ #
14
+ # client = SillyDev::Client.new
15
+ # client.panel.servers.list.each { |server| puts "#{server["identifier"]} #{server["name"]}" }
16
+ class Client
17
+ DEFAULT_BASE_URL = "https://sillydev.co.uk/panel/api/client"
18
+ DEFAULT_TIMEOUT = 60
19
+ DEFAULT_MAX_RETRIES = 2
20
+ RETRY_STATUSES = [429, 502, 503, 504].freeze
21
+ # A POST answered 502, 503 or 504 may still have reached the server: the node can have run the
22
+ # command or the power action before the gateway gave up. Only 429, which is refused before
23
+ # anything runs, is safe to retry for those, unless the request carries an Idempotency-Key
24
+ # the panel uses to recognise a repeat.
25
+ UNSAFE_RETRY_STATUSES = [429].freeze
26
+ SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
27
+ MAX_RETRY_AFTER = 60.0
28
+
29
+ attr_reader :api_key, :base_url, :max_retries, :panel
30
+ # How the client waits between retries and checks; replaceable in tests.
31
+ attr_accessor :sleeper
32
+
33
+ def initialize(api_key = nil, base_url: nil, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
34
+ default_headers: {}, transport: nil)
35
+ key = api_key || ENV.fetch("SILLYDEV_API_KEY", nil)
36
+ if key.nil? || key.empty?
37
+ raise Error, "No API key. Pass one to SillyDev::Client.new or set the SILLYDEV_API_KEY environment " \
38
+ "variable. Create a key in the panel under Account, API keys."
39
+ end
40
+
41
+ @api_key = key
42
+ base = base_url || ENV.fetch("SILLYDEV_BASE_URL", nil)
43
+ base = DEFAULT_BASE_URL if base.nil? || base.empty?
44
+ @base_url = base.chomp("/")
45
+ @max_retries = [max_retries.to_i, 0].max
46
+ @default_headers = default_headers.to_h
47
+ @transport = transport || NetHTTPTransport.new(timeout: timeout)
48
+ @sleeper = ->(seconds) { sleep(seconds) }
49
+ @panel = Panel.new(self)
50
+ end
51
+
52
+ def inspect
53
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
54
+ end
55
+
56
+ # Quotes one path parameter so that a slash or space in it cannot change the route.
57
+ def self.segment(value)
58
+ value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) }
59
+ end
60
+
61
+ # Sends one request and returns the unwrapped answer.
62
+ #
63
+ # json: is sent as a JSON body; content: as text (String) or binary (a String in
64
+ # Encoding::BINARY). raw: true skips unwrapping, text: true returns the body as a String.
65
+ def request(method, path, params: nil, json: :none, content: nil, headers: nil, raw: false, text: false)
66
+ method = method.to_s.upcase
67
+ attempt = 0
68
+ loop do
69
+ req = build(method, path, params, json, content, headers)
70
+ begin
71
+ response = @transport.call(req)
72
+ rescue ConnectFailed => e
73
+ if attempt < @max_retries
74
+ @sleeper.call(retry_delay(attempt, nil))
75
+ attempt += 1
76
+ next
77
+ end
78
+ raise APIConnectionError, "Could not reach #{@base_url}: #{e.message}"
79
+ rescue *Errors::TRANSPORT_ERRORS => e
80
+ raise APIConnectionError, "Could not reach #{@base_url}: #{e.message}"
81
+ end
82
+ if retry?(req, response, attempt)
83
+ @sleeper.call(retry_delay(attempt, response))
84
+ attempt += 1
85
+ next
86
+ end
87
+ return parse(response, raw: raw, text: text)
88
+ end
89
+ end
90
+
91
+ # One page of a paginated list.
92
+ def page(path, params = nil)
93
+ Page.from_response(request("GET", path, params: params, raw: true))
94
+ end
95
+
96
+ # Every item of a paginated list, fetching each page when it is reached.
97
+ def paginate(path, params = nil)
98
+ params = (params || {}).dup
99
+ first = (params.delete(:page) || params.delete("page") || 1).to_i
100
+ Enumerator.new do |yielder|
101
+ number = first
102
+ loop do
103
+ result = page(path, params.merge(page: number))
104
+ result.each { |item| yielder << item }
105
+ break unless result.next_page?
106
+
107
+ number = result.current_page + 1
108
+ end
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def url(path)
115
+ return @base_url if path.nil? || path == "" || path == "/"
116
+
117
+ @base_url + (path.start_with?("/") ? path : "/#{path}")
118
+ end
119
+
120
+ def build(method, path, params, json, content, headers)
121
+ extra = (headers || {}).to_h { |k, v| [k.to_s, v.to_s] }
122
+ body = nil
123
+ if json != :none
124
+ body = JSON.generate(json)
125
+ extra["Content-Type"] ||= "application/json"
126
+ elsif !content.nil?
127
+ body = content
128
+ extra["Content-Type"] ||= content.encoding == Encoding::BINARY ? "application/octet-stream" : "text/plain"
129
+ end
130
+ full = url(path)
131
+ query = encode_params(params)
132
+ full += "?#{query}" unless query.empty?
133
+ Request.new(verb: method, url: full, headers: request_headers(extra), body: body)
134
+ end
135
+
136
+ def request_headers(extra)
137
+ {
138
+ "Authorization" => "Bearer #{@api_key}",
139
+ "Accept" => "application/json",
140
+ "User-Agent" => "sillydev-ruby/#{VERSION}",
141
+ }.merge(@default_headers.to_h { |k, v| [k.to_s, v.to_s] }).merge(extra)
142
+ end
143
+
144
+ def encode_params(params)
145
+ pairs = (params || {}).filter_map do |key, value|
146
+ next if value.nil?
147
+
148
+ value = case value
149
+ # Laravel's boolean rule accepts 1 and 0 but not the strings "true" and "false".
150
+ when true then "1"
151
+ when false then "0"
152
+ when Array then value.join(",")
153
+ else value.to_s
154
+ end
155
+ [key.to_s, value]
156
+ end
157
+ URI.encode_www_form(pairs)
158
+ end
159
+
160
+ def retry?(request, response, attempt)
161
+ return false if attempt >= @max_retries
162
+
163
+ safe = SAFE_METHODS.include?(request.verb) || !request.header("Idempotency-Key").nil?
164
+ (safe ? RETRY_STATUSES : UNSAFE_RETRY_STATUSES).include?(response.status)
165
+ end
166
+
167
+ def retry_delay(attempt, response)
168
+ if response
169
+ retry_after = Errors.parse_retry_after(response.header("retry-after"))
170
+ return [retry_after, MAX_RETRY_AFTER].min unless retry_after.nil?
171
+ end
172
+ [0.5 * (2**attempt), 8.0].min * rand(0.75..1.25)
173
+ end
174
+
175
+ def parse(response, raw:, text:)
176
+ raise Errors.for_response(response) unless response.success?
177
+ return response.body.to_s if text
178
+ return nil if response.status == 204 || response.body.nil? || response.body.empty?
179
+
180
+ begin
181
+ body = JSON.parse(response.body)
182
+ rescue JSON::ParserError
183
+ return response.body
184
+ end
185
+ # Endpoints that answer 202 with an empty JSON array (reinstall, run a schedule).
186
+ return nil if body == []
187
+
188
+ raw ? body : Envelope.unwrap(body)
189
+ end
190
+ end
191
+
192
+ module Errors
193
+ # Failures after the connection was made: the request may have reached the panel, so they are
194
+ # not retried.
195
+ TRANSPORT_ERRORS = [
196
+ IOError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError, EOFError
197
+ ].freeze
198
+ end
199
+ end