keenetic 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/README.md +83 -0
- data/lib/keenetic/client.rb +361 -0
- data/lib/keenetic/configuration.rb +27 -0
- data/lib/keenetic/errors.rb +19 -0
- data/lib/keenetic/resources/base.rb +76 -0
- data/lib/keenetic/resources/devices.rb +309 -0
- data/lib/keenetic/resources/dhcp.rb +195 -0
- data/lib/keenetic/resources/internet.rb +169 -0
- data/lib/keenetic/resources/logs.rb +330 -0
- data/lib/keenetic/resources/network.rb +251 -0
- data/lib/keenetic/resources/policies.rb +171 -0
- data/lib/keenetic/resources/ports.rb +115 -0
- data/lib/keenetic/resources/routing.rb +200 -0
- data/lib/keenetic/resources/system.rb +267 -0
- data/lib/keenetic/resources/wifi.rb +376 -0
- data/lib/keenetic/version.rb +4 -0
- data/lib/keenetic.rb +99 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 150b29909b27e3c0c7704dead364af88b95152e02c48ad237dd2bbdaf5d6471e
|
|
4
|
+
data.tar.gz: dde2d8a08098c812502fb07cb3cad0262d004ff6c431be3b8d11b21f2216491a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 12e93dc8b6b71b665fd88994236ef984c042dc2cbf16b3108456f9615e55c5751a76b657adaf7ff970c4a83ffd3d4aa49ade8800b772666abc27c23e082bc630
|
|
7
|
+
data.tar.gz: 7921327abfc55e5f1d2c4846602ecad522fe69478b8d78ddf2a3519ebe9b15aff87950618eeef2a8fe4a6e889c08e8b350931bfe2bc430549bb12a36668e8d95
|
data/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Keenetic
|
|
2
|
+
|
|
3
|
+
Ruby wrapper for the Keenetic router RCI (Remote Command Interface) — the same API that powers the Keenetic web UI.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem 'keenetic'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
Keenetic.configure do |config|
|
|
15
|
+
config.host = '192.168.1.1'
|
|
16
|
+
config.login = 'admin'
|
|
17
|
+
config.password = 'your_password'
|
|
18
|
+
config.timeout = 30 # optional
|
|
19
|
+
config.logger = Logger.new($stdout) # optional
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
client = Keenetic.client
|
|
27
|
+
|
|
28
|
+
# Connected devices
|
|
29
|
+
client.devices.all
|
|
30
|
+
client.devices.active
|
|
31
|
+
client.devices.find(mac: 'AA:BB:CC:DD:EE:FF')
|
|
32
|
+
client.devices.update(mac: 'AA:BB:CC:DD:EE:FF', name: 'My Phone')
|
|
33
|
+
|
|
34
|
+
# System info
|
|
35
|
+
client.system.info # model, firmware
|
|
36
|
+
client.system.resources # CPU, memory, uptime
|
|
37
|
+
|
|
38
|
+
# Network
|
|
39
|
+
client.network.interfaces
|
|
40
|
+
|
|
41
|
+
# WiFi
|
|
42
|
+
client.wifi.access_points
|
|
43
|
+
client.wifi.clients
|
|
44
|
+
|
|
45
|
+
# Internet
|
|
46
|
+
client.internet.status
|
|
47
|
+
client.internet.speed
|
|
48
|
+
|
|
49
|
+
# Ports
|
|
50
|
+
client.ports.all
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Error Handling
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
begin
|
|
57
|
+
client.devices.all
|
|
58
|
+
rescue Keenetic::AuthenticationError
|
|
59
|
+
# invalid credentials
|
|
60
|
+
rescue Keenetic::ConnectionError
|
|
61
|
+
# router unreachable
|
|
62
|
+
rescue Keenetic::TimeoutError
|
|
63
|
+
# request timed out
|
|
64
|
+
rescue Keenetic::NotFoundError
|
|
65
|
+
# resource not found
|
|
66
|
+
rescue Keenetic::ApiError => e
|
|
67
|
+
# Other API errors
|
|
68
|
+
e.status_code
|
|
69
|
+
e.response_body
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## API Reference
|
|
74
|
+
|
|
75
|
+
See [KEENETIC_API.md](KEENETIC_API.md) for complete API documentation.
|
|
76
|
+
|
|
77
|
+
## Requirements
|
|
78
|
+
|
|
79
|
+
- Ruby >= 3.0
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
require 'typhoeus'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Keenetic
|
|
6
|
+
# HTTP client for Keenetic router API.
|
|
7
|
+
#
|
|
8
|
+
# == Authentication Flow
|
|
9
|
+
# Keenetic uses a challenge-response authentication mechanism:
|
|
10
|
+
#
|
|
11
|
+
# Step 1: GET /auth
|
|
12
|
+
# Response: HTTP 401 with headers X-NDM-Challenge and X-NDM-Realm
|
|
13
|
+
# Also sets session cookie
|
|
14
|
+
#
|
|
15
|
+
# Step 2: Calculate authentication hash
|
|
16
|
+
# md5_hash = MD5(login + ":" + realm + ":" + password)
|
|
17
|
+
# auth_hash = SHA256(challenge + md5_hash)
|
|
18
|
+
#
|
|
19
|
+
# Step 3: POST /auth
|
|
20
|
+
# Body: {"login": "admin", "password": "<auth_hash>"}
|
|
21
|
+
# Response: HTTP 200 on success
|
|
22
|
+
# Session maintained via cookies
|
|
23
|
+
#
|
|
24
|
+
# == Request Types
|
|
25
|
+
#
|
|
26
|
+
# === Reading Data (GET)
|
|
27
|
+
# GET /rci/show/<path>
|
|
28
|
+
# Example: GET /rci/show/system, GET /rci/show/ip/hotspot
|
|
29
|
+
#
|
|
30
|
+
# === Writing Data (POST - Batch Format)
|
|
31
|
+
# POST /rci/
|
|
32
|
+
# Body: Array of commands (MUST be array, even for single command)
|
|
33
|
+
# Example: [{"ip":{"hotspot":{"host":{"mac":"aa:bb:cc:dd:ee:ff","permit":true}}}}]
|
|
34
|
+
#
|
|
35
|
+
# == Thread Safety
|
|
36
|
+
# The client uses a mutex to prevent concurrent authentication attempts.
|
|
37
|
+
# Resource instances are memoized and thread-safe for reading.
|
|
38
|
+
#
|
|
39
|
+
class Client
|
|
40
|
+
attr_reader :config
|
|
41
|
+
|
|
42
|
+
# Create a new client instance.
|
|
43
|
+
#
|
|
44
|
+
# @param config [Configuration, nil] Optional configuration (uses global if nil)
|
|
45
|
+
# @raise [ConfigurationError] if configuration is invalid
|
|
46
|
+
#
|
|
47
|
+
def initialize(config = nil)
|
|
48
|
+
@config = config || Keenetic.configuration
|
|
49
|
+
@config.validate!
|
|
50
|
+
@cookies = {}
|
|
51
|
+
@authenticated = false
|
|
52
|
+
@mutex = Mutex.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Resources::Devices] Device management resource
|
|
56
|
+
def devices
|
|
57
|
+
@devices ||= Resources::Devices.new(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Resources::System] System information resource
|
|
61
|
+
def system
|
|
62
|
+
@system ||= Resources::System.new(self)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Resources::Network] Network interfaces resource
|
|
66
|
+
def network
|
|
67
|
+
@network ||= Resources::Network.new(self)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Resources::WiFi] Wi-Fi resource
|
|
71
|
+
def wifi
|
|
72
|
+
@wifi ||= Resources::WiFi.new(self)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Resources::Internet] Internet status resource
|
|
76
|
+
def internet
|
|
77
|
+
@internet ||= Resources::Internet.new(self)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Resources::Ports] Physical ports resource
|
|
81
|
+
def ports
|
|
82
|
+
@ports ||= Resources::Ports.new(self)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Resources::Policies] Routing policies resource
|
|
86
|
+
def policies
|
|
87
|
+
@policies ||= Resources::Policies.new(self)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Resources::DHCP] DHCP resource
|
|
91
|
+
def dhcp
|
|
92
|
+
@dhcp ||= Resources::DHCP.new(self)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Resources::Routing] Routing resource
|
|
96
|
+
def routing
|
|
97
|
+
@routing ||= Resources::Routing.new(self)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Resources::Logs] System logs resource
|
|
101
|
+
def logs
|
|
102
|
+
@logs ||= Resources::Logs.new(self)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Make a GET request to the router API.
|
|
106
|
+
#
|
|
107
|
+
# == Keenetic API
|
|
108
|
+
# GET http://<host>/rci/show/<path>
|
|
109
|
+
#
|
|
110
|
+
# @param path [String] API path (e.g., '/rci/show/system')
|
|
111
|
+
# @param params [Hash] Optional query parameters
|
|
112
|
+
# @return [Hash, Array, nil] Parsed JSON response
|
|
113
|
+
#
|
|
114
|
+
def get(path, params = {})
|
|
115
|
+
request(:get, path, params: params)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Make a POST request to the router API.
|
|
119
|
+
#
|
|
120
|
+
# == Keenetic API
|
|
121
|
+
# POST http://<host>/rci/<path>
|
|
122
|
+
# Content-Type: application/json
|
|
123
|
+
#
|
|
124
|
+
# @param path [String] API path
|
|
125
|
+
# @param body [Hash] Request body (will be JSON encoded)
|
|
126
|
+
# @return [Hash, Array, nil] Parsed JSON response
|
|
127
|
+
#
|
|
128
|
+
def post(path, body = {})
|
|
129
|
+
request(:post, path, body: body)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Execute multiple commands in a single batch request.
|
|
133
|
+
#
|
|
134
|
+
# == Keenetic API
|
|
135
|
+
# POST http://<host>/rci/
|
|
136
|
+
# Content-Type: application/json
|
|
137
|
+
# Body: Array of command objects
|
|
138
|
+
#
|
|
139
|
+
# == Important
|
|
140
|
+
# All write operations to Keenetic MUST use batch format (array).
|
|
141
|
+
# Even single commands must be wrapped in an array.
|
|
142
|
+
#
|
|
143
|
+
# @param commands [Array<Hash>] Array of command hashes
|
|
144
|
+
# @return [Array] Array of responses in the same order as commands
|
|
145
|
+
# @raise [ArgumentError] if commands is not a non-empty array
|
|
146
|
+
#
|
|
147
|
+
# @example Read multiple values
|
|
148
|
+
# client.batch([
|
|
149
|
+
# { 'show' => { 'system' => {} } },
|
|
150
|
+
# { 'show' => { 'version' => {} } }
|
|
151
|
+
# ])
|
|
152
|
+
#
|
|
153
|
+
# @example Write command (update device)
|
|
154
|
+
# client.batch([
|
|
155
|
+
# { 'known' => { 'host' => { 'mac' => 'aa:bb:cc:dd:ee:ff', 'name' => 'My Device' } } }
|
|
156
|
+
# ])
|
|
157
|
+
#
|
|
158
|
+
def batch(commands)
|
|
159
|
+
raise ArgumentError, 'Commands must be an array' unless commands.is_a?(Array)
|
|
160
|
+
raise ArgumentError, 'Commands array cannot be empty' if commands.empty?
|
|
161
|
+
|
|
162
|
+
request(:post, '/rci/', body: commands)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Check if client is authenticated.
|
|
166
|
+
# @return [Boolean]
|
|
167
|
+
def authenticated?
|
|
168
|
+
@authenticated
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Perform authentication (thread-safe).
|
|
172
|
+
#
|
|
173
|
+
# Called automatically before first request.
|
|
174
|
+
# Uses mutex to prevent concurrent authentication attempts.
|
|
175
|
+
#
|
|
176
|
+
# @return [Boolean] true on success
|
|
177
|
+
# @raise [AuthenticationError] on failure
|
|
178
|
+
# @raise [TimeoutError] if connection times out
|
|
179
|
+
# @raise [ConnectionError] if router is unreachable
|
|
180
|
+
#
|
|
181
|
+
def authenticate!
|
|
182
|
+
@mutex.synchronize do
|
|
183
|
+
return true if @authenticated
|
|
184
|
+
|
|
185
|
+
perform_authentication
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def request(method, path, options = {})
|
|
192
|
+
authenticate! unless @authenticated || path == '/auth'
|
|
193
|
+
|
|
194
|
+
url = "#{config.base_url}#{path}"
|
|
195
|
+
|
|
196
|
+
request_options = {
|
|
197
|
+
method: method,
|
|
198
|
+
timeout: config.timeout,
|
|
199
|
+
connecttimeout: config.open_timeout,
|
|
200
|
+
headers: build_headers,
|
|
201
|
+
accept_encoding: 'gzip'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if options[:params] && !options[:params].empty?
|
|
205
|
+
url += "?#{URI.encode_www_form(options[:params])}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if options[:body]
|
|
209
|
+
request_options[:body] = options[:body].to_json
|
|
210
|
+
request_options[:headers]['Content-Type'] = 'application/json'
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
config.logger.debug { "Keenetic: #{method.upcase} #{url}" }
|
|
214
|
+
|
|
215
|
+
response = Typhoeus::Request.new(url, request_options).run
|
|
216
|
+
|
|
217
|
+
handle_response(response)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_headers
|
|
221
|
+
headers = {
|
|
222
|
+
'Accept' => 'application/json',
|
|
223
|
+
'User-Agent' => "Keenetic Ruby Client/#{VERSION}"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
headers['Cookie'] = format_cookies unless @cookies.empty?
|
|
227
|
+
headers
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def format_cookies
|
|
231
|
+
@cookies.map { |k, v| "#{k}=#{v}" }.join('; ')
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def parse_cookies(response)
|
|
235
|
+
return unless response.headers
|
|
236
|
+
|
|
237
|
+
set_cookie_headers = response.headers['Set-Cookie']
|
|
238
|
+
return unless set_cookie_headers
|
|
239
|
+
|
|
240
|
+
cookies = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [set_cookie_headers]
|
|
241
|
+
|
|
242
|
+
cookies.each do |cookie|
|
|
243
|
+
parts = cookie.split(';').first
|
|
244
|
+
next unless parts
|
|
245
|
+
|
|
246
|
+
name, value = parts.split('=', 2)
|
|
247
|
+
@cookies[name.strip] = value&.strip if name
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def handle_response(response)
|
|
252
|
+
parse_cookies(response)
|
|
253
|
+
|
|
254
|
+
if response.timed_out?
|
|
255
|
+
raise TimeoutError, "Request timed out after #{config.timeout}s"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if response.code == 0
|
|
259
|
+
raise ConnectionError, "Connection failed: #{response.return_message}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
unless response.success? || response.code == 401
|
|
263
|
+
if response.code == 404
|
|
264
|
+
raise NotFoundError, "Resource not found"
|
|
265
|
+
end
|
|
266
|
+
raise ApiError.new(
|
|
267
|
+
"API request failed with status #{response.code}",
|
|
268
|
+
status_code: response.code,
|
|
269
|
+
response_body: response.body
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
return nil if response.body.nil? || response.body.empty?
|
|
274
|
+
|
|
275
|
+
begin
|
|
276
|
+
JSON.parse(response.body)
|
|
277
|
+
rescue JSON::ParserError
|
|
278
|
+
response.body
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def perform_authentication
|
|
283
|
+
# Step 1: Get challenge from router
|
|
284
|
+
url = "#{config.base_url}/auth"
|
|
285
|
+
|
|
286
|
+
challenge_response = Typhoeus::Request.new(url, {
|
|
287
|
+
method: :get,
|
|
288
|
+
timeout: config.timeout,
|
|
289
|
+
connecttimeout: config.open_timeout,
|
|
290
|
+
headers: { 'Accept' => 'application/json' }
|
|
291
|
+
}).run
|
|
292
|
+
|
|
293
|
+
if challenge_response.timed_out?
|
|
294
|
+
raise TimeoutError, auth_error_context("Authentication timed out after #{config.timeout}s")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
if challenge_response.code == 0
|
|
298
|
+
raise ConnectionError, auth_error_context("Connection failed: #{challenge_response.return_message}")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
parse_cookies(challenge_response)
|
|
302
|
+
|
|
303
|
+
# If already authenticated (returns 200), we're done
|
|
304
|
+
if challenge_response.code == 200
|
|
305
|
+
@authenticated = true
|
|
306
|
+
config.logger.info { "Keenetic: Already authenticated" }
|
|
307
|
+
return true
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
unless challenge_response.code == 401
|
|
311
|
+
raise AuthenticationError, auth_error_context("Unexpected response: HTTP #{challenge_response.code}")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
headers = challenge_response.headers || {}
|
|
315
|
+
challenge = headers['X-NDM-Challenge']
|
|
316
|
+
realm = headers['X-NDM-Realm']
|
|
317
|
+
|
|
318
|
+
unless challenge && realm
|
|
319
|
+
raise AuthenticationError, auth_error_context("Missing challenge headers from router")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
config.logger.debug { "Keenetic: Got challenge, realm=#{realm}" }
|
|
323
|
+
|
|
324
|
+
# Step 2: Calculate authentication hash
|
|
325
|
+
# MD5(login:realm:password) -> then SHA256(challenge + md5_hash)
|
|
326
|
+
md5_hash = Digest::MD5.hexdigest("#{config.login}:#{realm}:#{config.password}")
|
|
327
|
+
auth_hash = Digest::SHA256.hexdigest("#{challenge}#{md5_hash}")
|
|
328
|
+
|
|
329
|
+
# Step 3: Send authentication request
|
|
330
|
+
auth_response = Typhoeus::Request.new(url, {
|
|
331
|
+
method: :post,
|
|
332
|
+
timeout: config.timeout,
|
|
333
|
+
connecttimeout: config.open_timeout,
|
|
334
|
+
headers: build_headers.merge('Content-Type' => 'application/json'),
|
|
335
|
+
body: { login: config.login, password: auth_hash }.to_json
|
|
336
|
+
}).run
|
|
337
|
+
|
|
338
|
+
parse_cookies(auth_response)
|
|
339
|
+
|
|
340
|
+
if auth_response.code == 200
|
|
341
|
+
@authenticated = true
|
|
342
|
+
config.logger.info { "Keenetic: Authentication successful" }
|
|
343
|
+
true
|
|
344
|
+
else
|
|
345
|
+
raise AuthenticationError, auth_error_context("Authentication failed: HTTP #{auth_response.code}")
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def auth_error_context(message)
|
|
350
|
+
details = [
|
|
351
|
+
message,
|
|
352
|
+
"host=#{config.host}",
|
|
353
|
+
"login=#{config.login}",
|
|
354
|
+
"timeout=#{config.timeout}s",
|
|
355
|
+
"connect_timeout=#{config.open_timeout}s"
|
|
356
|
+
]
|
|
357
|
+
details.join(' | ')
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require 'logger'
|
|
2
|
+
|
|
3
|
+
module Keenetic
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :host, :login, :password, :timeout, :open_timeout, :logger
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@host = nil
|
|
9
|
+
@login = nil
|
|
10
|
+
@password = nil
|
|
11
|
+
@timeout = 30
|
|
12
|
+
@open_timeout = 10
|
|
13
|
+
@logger = Logger.new(nil)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def base_url
|
|
17
|
+
"http://#{host}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate!
|
|
21
|
+
raise ConfigurationError, 'Host is required' if host.nil? || host.empty?
|
|
22
|
+
raise ConfigurationError, 'Login is required' if login.nil? || login.empty?
|
|
23
|
+
raise ConfigurationError, 'Password is required' if password.nil? || password.empty?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Keenetic
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class ConfigurationError < Error; end
|
|
5
|
+
class AuthenticationError < Error; end
|
|
6
|
+
class ConnectionError < Error; end
|
|
7
|
+
class TimeoutError < Error; end
|
|
8
|
+
class NotFoundError < Error; end
|
|
9
|
+
class ApiError < Error
|
|
10
|
+
attr_reader :status_code, :response_body
|
|
11
|
+
|
|
12
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@status_code = status_code
|
|
15
|
+
@response_body = response_body
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Keenetic
|
|
2
|
+
module Resources
|
|
3
|
+
class Base
|
|
4
|
+
attr_reader :client
|
|
5
|
+
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
def get(path, params = {})
|
|
13
|
+
client.get(path, params)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def post(path, body = {})
|
|
17
|
+
client.post(path, body)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Convert kebab-case keys to snake_case symbols
|
|
21
|
+
# @param hash [Hash] Hash with string keys
|
|
22
|
+
# @return [Hash] Hash with symbolized snake_case keys
|
|
23
|
+
def normalize_keys(hash)
|
|
24
|
+
return {} unless hash.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
hash.transform_keys { |key| key.to_s.tr('-', '_').to_sym }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Deep normalize keys in a hash (recursive)
|
|
30
|
+
# @param obj [Hash, Array, Object] Object to normalize
|
|
31
|
+
# @return [Hash, Array, Object] Normalized object
|
|
32
|
+
def deep_normalize_keys(obj)
|
|
33
|
+
case obj
|
|
34
|
+
when Hash
|
|
35
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
36
|
+
new_key = key.to_s.tr('-', '_').to_sym
|
|
37
|
+
result[new_key] = deep_normalize_keys(value)
|
|
38
|
+
end
|
|
39
|
+
when Array
|
|
40
|
+
obj.map { |item| deep_normalize_keys(item) }
|
|
41
|
+
else
|
|
42
|
+
obj
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Normalize boolean values from various formats
|
|
47
|
+
# API may return true/false or "true"/"false" strings
|
|
48
|
+
# @param value [Object] Value to normalize
|
|
49
|
+
# @return [Boolean, Object] Normalized boolean or original value
|
|
50
|
+
def normalize_boolean(value)
|
|
51
|
+
case value
|
|
52
|
+
when true, 'true', 'yes', '1', 1
|
|
53
|
+
true
|
|
54
|
+
when false, 'false', 'no', '0', 0
|
|
55
|
+
false
|
|
56
|
+
else
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Normalize all boolean values in a hash
|
|
62
|
+
# @param hash [Hash] Hash with potential boolean values
|
|
63
|
+
# @param keys [Array<Symbol>] Keys to normalize as booleans
|
|
64
|
+
# @return [Hash] Hash with normalized boolean values
|
|
65
|
+
def normalize_booleans(hash, keys)
|
|
66
|
+
return hash unless hash.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
keys.each do |key|
|
|
69
|
+
hash[key] = normalize_boolean(hash[key]) if hash.key?(key)
|
|
70
|
+
end
|
|
71
|
+
hash
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|