dannykeane 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/LICENSE +21 -0
- data/README.md +88 -0
- data/exe/dannykeane +6 -0
- data/lib/dannykeane/cli.rb +292 -0
- data/lib/dannykeane/client.rb +176 -0
- data/lib/dannykeane/errors.rb +54 -0
- data/lib/dannykeane/version.rb +5 -0
- data/lib/dannykeane.rb +14 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d38a8a66ba6a406a36dcec88f9bff39e15160d8283a2bf0267009cda1b72a092
|
|
4
|
+
data.tar.gz: 391c52c002b41a24c4689a10c25ccadd205c2e2ea5d85705d822ae9048e3479b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 66417a2fd69f75b1163fe59ee54e914e119971941256ec6f356c080efbe2c7eb29601b8baa2d8b43fd356a3dc103b2841674c290e69a8fbeb4e5461d1566ea90
|
|
7
|
+
data.tar.gz: 6f4b2d2f99b5e451a7519f40709990f296996ea151678d6b9bcb57d62c0c83634a79d44f5f28a50a7174506d8cb6cb7757b49562925cbfea77845bee840dd759
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danny Keane
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# dannykeane
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK and CLI for the [dannykeane.com](https://dannykeane.com) public API: Danny Keane's work history, GitHub contribution calendar, live visitor stats, and the Habbo room NPC chat.
|
|
4
|
+
|
|
5
|
+
Zero dependencies, standard library only. Ruby 2.6+.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
gem install dannykeane
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "dannykeane"
|
|
15
|
+
|
|
16
|
+
dk = DannyKeane::Client.new
|
|
17
|
+
dk.iter_work { |role| puts "#{role["company"]} #{role["years"]}" }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Every endpoint is public, so there is no API key. Pass `base_url:` to point at a local or preview deploy.
|
|
21
|
+
|
|
22
|
+
| Method | API operation | Endpoint |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `list_work(limit: nil, cursor: nil)` | `listWork` | `GET /api/v1/work` |
|
|
25
|
+
| `iter_work(limit: nil)` | `listWork`, every page | `GET /api/v1/work` |
|
|
26
|
+
| `get_stats` | `getStats` | `GET /api/v1/stats` |
|
|
27
|
+
| `get_github_contributions` | `getGithubContributions` | `GET /api/v1/github-contributions` |
|
|
28
|
+
| `npc_chat(message, npc_id, context: nil)` | `npcChat` | `POST /api/v1/npc-chat` |
|
|
29
|
+
| `npc_chat_health` | `npcChatHealth` | `GET /api/v1/npc-chat` |
|
|
30
|
+
| `run_batch(requests)` | `runBatch` | `POST /api/v1/batch` |
|
|
31
|
+
|
|
32
|
+
Results are the API's own JSON, parsed into Hashes with string keys. The full contract is the OpenAPI document at https://dannykeane.com/openapi.json.
|
|
33
|
+
|
|
34
|
+
### Pagination
|
|
35
|
+
|
|
36
|
+
`list_work` returns one page: `{"data" => [...], "next_cursor" => "..."}`, with `next_cursor` set to `nil` on the last page. `iter_work` follows the cursors for you; without a block it returns an Enumerator that fetches each page only when iteration reaches it.
|
|
37
|
+
|
|
38
|
+
### Errors
|
|
39
|
+
|
|
40
|
+
Every non-2xx response raises `DannyKeane::ApiError`, parsed from the API's ErrorResponse body:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
begin
|
|
44
|
+
DannyKeane::Client.new.list_work(limit: 50)
|
|
45
|
+
rescue DannyKeane::ApiError => error
|
|
46
|
+
puts error.status, error.code, error.message, error.hint
|
|
47
|
+
# 400 invalid_parameter limit must be an integer from 1 to 20 ...
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`error.body` holds the full parsed body. Network failures (DNS, refused connection, TLS, timeout) raise `DannyKeane::ConnectionError`, with the original exception on `cause`. Both inherit from `DannyKeane::Error`.
|
|
52
|
+
|
|
53
|
+
### Rate limits
|
|
54
|
+
|
|
55
|
+
On a 429 the client waits out `Retry-After` and retries, up to `max_retries:` (default 2). A `Retry-After` longer than `max_retry_delay:` (default 60 seconds) raises immediately instead, with the wait on `error.retry_after`. Set `max_retries: 0` to handle every 429 yourself.
|
|
56
|
+
|
|
57
|
+
## CLI
|
|
58
|
+
|
|
59
|
+
The gem installs a `dannykeane` command:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
dannykeane work --limit 5
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Command | Operation |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `dannykeane work [--limit N] [--cursor TOKEN] [--all]` | `listWork`; `--all` walks every page and prints one array |
|
|
68
|
+
| `dannykeane stats` | `getStats` |
|
|
69
|
+
| `dannykeane github-contributions` | `getGithubContributions` |
|
|
70
|
+
| `dannykeane npc-chat [--npc-id ID] <message>` | `npcChat` |
|
|
71
|
+
| `dannykeane npc-chat-health` | `npcChatHealth` |
|
|
72
|
+
| `dannykeane batch <operationId>...` | `runBatch` |
|
|
73
|
+
|
|
74
|
+
The API's JSON goes to stdout, indented when stdout is a terminal (`--pretty` forces it). Failures go to stderr as one line of ErrorResponse JSON, with stdout empty. `--base-url` or `DANNYKEANE_BASE_URL` points it at another deploy.
|
|
75
|
+
|
|
76
|
+
Exit codes: `0` success, `1` the API returned an error, `2` usage error, `3` the API could not be reached.
|
|
77
|
+
|
|
78
|
+
The npm package [`dannykeane`](https://www.npmjs.com/package/dannykeane) and the PyPI package [`dannykeane`](https://pypi.org/project/dannykeane/) ship the same client and CLI for TypeScript and Python.
|
|
79
|
+
|
|
80
|
+
## Links
|
|
81
|
+
|
|
82
|
+
- Developer docs: https://dannykeane.com/developers
|
|
83
|
+
- OpenAPI spec: https://dannykeane.com/openapi.json
|
|
84
|
+
- Contact: me@dannykeane.co.uk
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
data/exe/dannykeane
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
require_relative "../dannykeane"
|
|
7
|
+
|
|
8
|
+
module DannyKeane
|
|
9
|
+
# +dannykeane+ -- the official CLI for the dannykeane.com public API.
|
|
10
|
+
#
|
|
11
|
+
# A thin layer over DannyKeane::Client: one command per API operation, the
|
|
12
|
+
# API's own JSON on stdout, and any failure on stderr as one line of
|
|
13
|
+
# ErrorResponse-shaped JSON. The npm and PyPI packages ship the same command
|
|
14
|
+
# surface.
|
|
15
|
+
#
|
|
16
|
+
# Exit codes: 0 success, 1 the API returned an error, 2 usage error,
|
|
17
|
+
# 3 the API could not be reached.
|
|
18
|
+
class CLI
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_API_ERROR = 1
|
|
21
|
+
EXIT_USAGE = 2
|
|
22
|
+
EXIT_UNREACHABLE = 3
|
|
23
|
+
|
|
24
|
+
class UsageError < StandardError
|
|
25
|
+
attr_reader :command
|
|
26
|
+
|
|
27
|
+
def initialize(message, command = nil)
|
|
28
|
+
super(message)
|
|
29
|
+
@command = command
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Command = Struct.new(:summary, :usage, :examples, :flags, :accepts, :takes_args, :run, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
COMMANDS = {
|
|
36
|
+
"work" => Command.new(
|
|
37
|
+
summary: "List roles from the /work tables, one page or all of them",
|
|
38
|
+
usage: "dannykeane work [--limit <n>] [--cursor <token>] [--all]",
|
|
39
|
+
examples: ["dannykeane work --limit 5", "dannykeane work --all"],
|
|
40
|
+
flags: [
|
|
41
|
+
" --limit <n> page size, 1-20 (API default 10)",
|
|
42
|
+
" --cursor <token> next_cursor from the previous page",
|
|
43
|
+
" --all follow next_cursor to the end; prints one array of every role"
|
|
44
|
+
],
|
|
45
|
+
accepts: [:limit, :cursor, :all],
|
|
46
|
+
takes_args: false,
|
|
47
|
+
run: lambda do |client, args|
|
|
48
|
+
next client.list_work(limit: args[:limit], cursor: args[:cursor]) unless args[:all]
|
|
49
|
+
|
|
50
|
+
client.iter_work(limit: args[:limit]).to_a
|
|
51
|
+
end
|
|
52
|
+
),
|
|
53
|
+
"stats" => Command.new(
|
|
54
|
+
summary: "Live visitor count on dannykeane.com",
|
|
55
|
+
usage: "dannykeane stats",
|
|
56
|
+
examples: ["dannykeane stats"],
|
|
57
|
+
flags: [],
|
|
58
|
+
accepts: [],
|
|
59
|
+
takes_args: false,
|
|
60
|
+
run: ->(client, _args) { client.get_stats }
|
|
61
|
+
),
|
|
62
|
+
"github-contributions" => Command.new(
|
|
63
|
+
summary: "GitHub contribution calendar: 52 weeks plus aggregate stats",
|
|
64
|
+
usage: "dannykeane github-contributions",
|
|
65
|
+
examples: ["dannykeane github-contributions | jq .totalContributions"],
|
|
66
|
+
flags: [],
|
|
67
|
+
accepts: [],
|
|
68
|
+
takes_args: false,
|
|
69
|
+
run: ->(client, _args) { client.get_github_contributions }
|
|
70
|
+
),
|
|
71
|
+
"npc-chat" => Command.new(
|
|
72
|
+
summary: "Say something to Frank, the NPC in the /habbo room",
|
|
73
|
+
usage: "dannykeane npc-chat [--npc-id <id>] <message>",
|
|
74
|
+
examples: ['dannykeane npc-chat "hey frank, got any coffee?"'],
|
|
75
|
+
flags: [' --npc-id <id> the NPC to address (default "frank")'],
|
|
76
|
+
accepts: [:npc_id],
|
|
77
|
+
takes_args: true,
|
|
78
|
+
run: lambda do |client, args|
|
|
79
|
+
message = args[:positionals].join(" ")
|
|
80
|
+
raise UsageError.new("npc-chat needs a message", "npc-chat") if message.empty?
|
|
81
|
+
|
|
82
|
+
client.npc_chat(message, args[:npc_id])
|
|
83
|
+
end
|
|
84
|
+
),
|
|
85
|
+
"npc-chat-health" => Command.new(
|
|
86
|
+
summary: "Liveness probe for the NPC chat service",
|
|
87
|
+
usage: "dannykeane npc-chat-health",
|
|
88
|
+
examples: ["dannykeane npc-chat-health"],
|
|
89
|
+
flags: [],
|
|
90
|
+
accepts: [],
|
|
91
|
+
takes_args: false,
|
|
92
|
+
run: ->(client, _args) { client.npc_chat_health }
|
|
93
|
+
),
|
|
94
|
+
"batch" => Command.new(
|
|
95
|
+
summary: "Run several read operations in one request, by operationId",
|
|
96
|
+
usage: "dannykeane batch <operationId>...",
|
|
97
|
+
examples: ["dannykeane batch getStats npcChatHealth"],
|
|
98
|
+
flags: [
|
|
99
|
+
" operationIds: #{BATCH_OPERATION_IDS.join(", ")} (up to 10).",
|
|
100
|
+
" Each result carries its own status; one failed item doesn't fail the batch."
|
|
101
|
+
],
|
|
102
|
+
accepts: [],
|
|
103
|
+
takes_args: true,
|
|
104
|
+
run: lambda do |client, args|
|
|
105
|
+
if args[:positionals].empty?
|
|
106
|
+
raise UsageError.new("batch needs at least one operationId", "batch")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
client.run_batch(args[:positionals])
|
|
110
|
+
end
|
|
111
|
+
)
|
|
112
|
+
}.freeze
|
|
113
|
+
|
|
114
|
+
COMMAND_FLAGS = { limit: "limit", cursor: "cursor", all: "all", npc_id: "npc-id" }.freeze
|
|
115
|
+
|
|
116
|
+
GLOBAL_FLAGS = [
|
|
117
|
+
" --base-url <url> API origin (env DANNYKEANE_BASE_URL, default #{DEFAULT_BASE_URL})",
|
|
118
|
+
" --pretty indent JSON output (the default when stdout is a terminal)",
|
|
119
|
+
"-h, --help show help",
|
|
120
|
+
"-V, --version print the version"
|
|
121
|
+
].freeze
|
|
122
|
+
|
|
123
|
+
EXIT_CODES = [
|
|
124
|
+
" 0 success",
|
|
125
|
+
" 1 the API returned an error (its ErrorResponse JSON is on stderr)",
|
|
126
|
+
" 2 usage error",
|
|
127
|
+
" 3 the API could not be reached"
|
|
128
|
+
].freeze
|
|
129
|
+
|
|
130
|
+
def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
|
|
131
|
+
@stdout = stdout
|
|
132
|
+
@stderr = stderr
|
|
133
|
+
@env = env
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Runs one invocation and returns its exit code.
|
|
137
|
+
def run(argv)
|
|
138
|
+
begin
|
|
139
|
+
values, positionals = parse(argv)
|
|
140
|
+
rescue OptionParser::ParseError => error
|
|
141
|
+
return usage(UsageError.new(error.message, argv.find { |arg| COMMANDS.key?(arg) }))
|
|
142
|
+
end
|
|
143
|
+
name = positionals.shift
|
|
144
|
+
|
|
145
|
+
if values[:version]
|
|
146
|
+
@stdout.puts("dannykeane #{VERSION}")
|
|
147
|
+
return EXIT_OK
|
|
148
|
+
end
|
|
149
|
+
if name.nil?
|
|
150
|
+
@stdout.puts(root_help)
|
|
151
|
+
return EXIT_OK
|
|
152
|
+
end
|
|
153
|
+
command = COMMANDS[name]
|
|
154
|
+
return usage(UsageError.new("unknown command \"#{name}\"")) if command.nil?
|
|
155
|
+
|
|
156
|
+
if values[:help]
|
|
157
|
+
@stdout.puts(command_help(name))
|
|
158
|
+
return EXIT_OK
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
args = validate(name, command, values, positionals)
|
|
162
|
+
base_url = [values[:base_url], @env["DANNYKEANE_BASE_URL"]].find { |url| url && !url.empty? } ||
|
|
163
|
+
DEFAULT_BASE_URL
|
|
164
|
+
execute(command, Client.new(base_url: base_url), args, base_url, values[:pretty])
|
|
165
|
+
rescue UsageError => error
|
|
166
|
+
usage(error)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def parse(argv)
|
|
172
|
+
values = {}
|
|
173
|
+
parser = OptionParser.new do |opts|
|
|
174
|
+
opts.on("-h", "--help") { values[:help] = true }
|
|
175
|
+
opts.on("-V", "--version") { values[:version] = true }
|
|
176
|
+
opts.on("--pretty") { values[:pretty] = true }
|
|
177
|
+
opts.on("--base-url URL") { |url| values[:base_url] = url }
|
|
178
|
+
opts.on("--limit N") { |limit| values[:limit] = limit }
|
|
179
|
+
opts.on("--cursor TOKEN") { |cursor| values[:cursor] = cursor }
|
|
180
|
+
opts.on("--all") { values[:all] = true }
|
|
181
|
+
opts.on("--npc-id ID") { |npc_id| values[:npc_id] = npc_id }
|
|
182
|
+
end
|
|
183
|
+
positionals = parser.parse(argv)
|
|
184
|
+
[values, positionals]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def validate(name, command, values, positionals)
|
|
188
|
+
stray = COMMAND_FLAGS.keys.find { |flag| values.key?(flag) && !command.accepts.include?(flag) }
|
|
189
|
+
raise UsageError.new("#{name} does not take --#{COMMAND_FLAGS[stray]}", name) if stray
|
|
190
|
+
if !command.takes_args && !positionals.empty?
|
|
191
|
+
raise UsageError.new("#{name} takes no arguments, got \"#{positionals.first}\"", name)
|
|
192
|
+
end
|
|
193
|
+
if values[:all] && values.key?(:cursor)
|
|
194
|
+
raise UsageError.new("--all starts from the first page; drop --cursor", name)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
positionals: positionals,
|
|
199
|
+
limit: parse_limit(values[:limit], name),
|
|
200
|
+
cursor: values[:cursor],
|
|
201
|
+
all: values[:all] || false,
|
|
202
|
+
npc_id: values[:npc_id] || "frank"
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def parse_limit(value, name)
|
|
207
|
+
return nil if value.nil?
|
|
208
|
+
|
|
209
|
+
Integer(value, 10)
|
|
210
|
+
rescue ArgumentError
|
|
211
|
+
raise UsageError.new("--limit must be an integer, got \"#{value}\"", name)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def execute(command, client, args, base_url, pretty)
|
|
215
|
+
result = command.run.call(client, args)
|
|
216
|
+
pretty ||= @stdout.respond_to?(:tty?) && @stdout.tty?
|
|
217
|
+
@stdout.puts(pretty ? JSON.pretty_generate(result) : JSON.generate(result))
|
|
218
|
+
EXIT_OK
|
|
219
|
+
rescue ApiError => error
|
|
220
|
+
body = error.body.is_a?(Hash) ? error.body : { "code" => nil, "error" => error.message, "status" => error.status }
|
|
221
|
+
write_error(body)
|
|
222
|
+
EXIT_API_ERROR
|
|
223
|
+
rescue ConnectionError => error
|
|
224
|
+
write_error(
|
|
225
|
+
"code" => "connection_failed",
|
|
226
|
+
"error" => "Could not reach #{base_url}: #{error.message}",
|
|
227
|
+
"hint" => "Check your network connection, or --base-url / DANNYKEANE_BASE_URL."
|
|
228
|
+
)
|
|
229
|
+
EXIT_UNREACHABLE
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def usage(error)
|
|
233
|
+
help = error.command ? "dannykeane #{error.command} --help" : "dannykeane --help"
|
|
234
|
+
write_error("code" => "usage_error", "error" => error.message, "hint" => "Run '#{help}'.")
|
|
235
|
+
EXIT_USAGE
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def write_error(body)
|
|
239
|
+
@stderr.puts(JSON.generate(body))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def root_help
|
|
243
|
+
width = COMMANDS.keys.map(&:length).max + 2
|
|
244
|
+
[
|
|
245
|
+
"Query the dannykeane.com public API. JSON on stdout, errors on stderr.",
|
|
246
|
+
"",
|
|
247
|
+
"USAGE",
|
|
248
|
+
" dannykeane <command> [flags]",
|
|
249
|
+
"",
|
|
250
|
+
"EXAMPLES",
|
|
251
|
+
" $ dannykeane work --limit 5",
|
|
252
|
+
" $ dannykeane work --all | jq '.[].company'",
|
|
253
|
+
" $ dannykeane stats",
|
|
254
|
+
"",
|
|
255
|
+
"COMMANDS",
|
|
256
|
+
*COMMANDS.map { |name, command| " #{name.ljust(width)}#{command.summary}" },
|
|
257
|
+
"",
|
|
258
|
+
"GLOBAL FLAGS",
|
|
259
|
+
*GLOBAL_FLAGS.map { |line| " #{line}" },
|
|
260
|
+
"",
|
|
261
|
+
"EXIT CODES",
|
|
262
|
+
*EXIT_CODES,
|
|
263
|
+
"",
|
|
264
|
+
"Run 'dannykeane <command> --help' for a command's flags.",
|
|
265
|
+
"Docs: https://dannykeane.com/developers"
|
|
266
|
+
].join("\n")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def command_help(name)
|
|
270
|
+
command = COMMANDS[name]
|
|
271
|
+
flags = command.flags.empty? ? [] : ["FLAGS", *command.flags.map { |line| " #{line}" }, ""]
|
|
272
|
+
[
|
|
273
|
+
command.summary,
|
|
274
|
+
"",
|
|
275
|
+
"USAGE",
|
|
276
|
+
" #{command.usage}",
|
|
277
|
+
"",
|
|
278
|
+
"EXAMPLES",
|
|
279
|
+
*command.examples.map { |example| " $ #{example}" },
|
|
280
|
+
"",
|
|
281
|
+
*flags,
|
|
282
|
+
"GLOBAL FLAGS",
|
|
283
|
+
*GLOBAL_FLAGS.map { |line| " #{line}" },
|
|
284
|
+
"",
|
|
285
|
+
"EXIT CODES",
|
|
286
|
+
*EXIT_CODES,
|
|
287
|
+
"",
|
|
288
|
+
"Docs: https://dannykeane.com/developers"
|
|
289
|
+
].join("\n")
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
require_relative "version"
|
|
11
|
+
|
|
12
|
+
module DannyKeane
|
|
13
|
+
DEFAULT_BASE_URL = "https://dannykeane.com"
|
|
14
|
+
|
|
15
|
+
# The argument-free read operations run_batch accepts.
|
|
16
|
+
BATCH_OPERATION_IDS = ["getStats", "getGithubContributions", "npcChatHealth"].freeze
|
|
17
|
+
|
|
18
|
+
# Client for the dannykeane.com public API.
|
|
19
|
+
#
|
|
20
|
+
# A thin, zero-dependency wrapper over Net::HTTP, written by hand against the
|
|
21
|
+
# OpenAPI 3.1 document at https://dannykeane.com/openapi.json. The site's
|
|
22
|
+
# test suite (app/lib/sdkCoverage.test.ts) fails when the spec gains an
|
|
23
|
+
# operation this client doesn't cover, so the two can't drift apart silently.
|
|
24
|
+
#
|
|
25
|
+
# No authentication: every endpoint is public. Results are the API's own
|
|
26
|
+
# JSON, parsed into Hashes and Arrays with string keys.
|
|
27
|
+
class Client
|
|
28
|
+
# Exceptions Net::HTTP raises when the server can't be reached or talked to.
|
|
29
|
+
NETWORK_ERRORS = [
|
|
30
|
+
SocketError, SystemCallError, IOError, Timeout::Error,
|
|
31
|
+
OpenSSL::SSL::SSLError, Net::ProtocolError, Net::HTTPBadResponse
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :base_url, :max_retries, :max_retry_delay, :timeout, :user_agent
|
|
35
|
+
|
|
36
|
+
# base_url:: Defaults to https://dannykeane.com. Override for a local
|
|
37
|
+
# or preview deploy.
|
|
38
|
+
# max_retries:: Retries after a 429, honoring Retry-After. 0 disables.
|
|
39
|
+
# max_retry_delay:: Longest Retry-After, in seconds, the client will wait
|
|
40
|
+
# out. Longer waits raise instead.
|
|
41
|
+
# timeout:: Per-attempt open and read timeout in seconds.
|
|
42
|
+
# user_agent:: Appended to the SDK's own User-Agent.
|
|
43
|
+
def initialize(base_url: nil, max_retries: 2, max_retry_delay: 60.0, timeout: 30.0, user_agent: nil)
|
|
44
|
+
@base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
45
|
+
@max_retries = max_retries
|
|
46
|
+
@max_retry_delay = max_retry_delay
|
|
47
|
+
@timeout = timeout
|
|
48
|
+
@user_agent = ["dannykeane-ruby/#{VERSION}", user_agent].compact.reject(&:empty?).join(" ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Active users on the site right now (GA4, cached 60s).
|
|
52
|
+
def get_stats
|
|
53
|
+
request("GET", "/api/v1/stats")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Danny Keane's GitHub contribution calendar: 52 weeks plus aggregate stats.
|
|
57
|
+
def get_github_contributions
|
|
58
|
+
request("GET", "/api/v1/github-contributions")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Liveness probe for the NPC chat service.
|
|
62
|
+
def npc_chat_health
|
|
63
|
+
request("GET", "/api/v1/npc-chat")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Send a message (up to 500 characters) to a Habbo room NPC, e.g. "frank".
|
|
67
|
+
def npc_chat(message, npc_id, context: nil)
|
|
68
|
+
body = { "message" => message, "npcId" => npc_id }
|
|
69
|
+
body["context"] = context.to_h unless context.nil?
|
|
70
|
+
request("POST", "/api/v1/npc-chat", body: body)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Run up to 10 argument-free read operations in one request.
|
|
74
|
+
#
|
|
75
|
+
# Each request is an operationId ("getStats") or a Hash with
|
|
76
|
+
# "operationId" and an optional "id" (string or symbol keys).
|
|
77
|
+
def run_batch(requests)
|
|
78
|
+
items = requests.map do |item|
|
|
79
|
+
if item.is_a?(String) || item.is_a?(Symbol)
|
|
80
|
+
{ "operationId" => item.to_s }
|
|
81
|
+
else
|
|
82
|
+
item.to_h.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
request("POST", "/api/v1/batch", body: { "requests" => items })
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# One page of roles. Follow "next_cursor", or use iter_work.
|
|
89
|
+
def list_work(limit: nil, cursor: nil)
|
|
90
|
+
request("GET", "/api/v1/work", query: { "limit" => limit, "cursor" => cursor })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Every role, fetching pages of +limit+ as the iteration needs them.
|
|
94
|
+
# Returns an Enumerator when called without a block.
|
|
95
|
+
def iter_work(limit: nil)
|
|
96
|
+
return enum_for(:iter_work, limit: limit) unless block_given?
|
|
97
|
+
|
|
98
|
+
cursor = nil
|
|
99
|
+
loop do
|
|
100
|
+
page = list_work(limit: limit, cursor: cursor)
|
|
101
|
+
page["data"].each { |role| yield role }
|
|
102
|
+
cursor = page["next_cursor"]
|
|
103
|
+
break if cursor.nil?
|
|
104
|
+
end
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def request(method, path, query: nil, body: nil)
|
|
111
|
+
params = (query || {}).reject { |_key, value| value.nil? }
|
|
112
|
+
uri = URI(base_url + path + (params.empty? ? "" : "?" + URI.encode_www_form(params)))
|
|
113
|
+
headers = { "Accept" => "application/json", "User-Agent" => user_agent }
|
|
114
|
+
data = nil
|
|
115
|
+
unless body.nil?
|
|
116
|
+
headers["Content-Type"] = "application/json"
|
|
117
|
+
data = JSON.generate(body)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
attempt = 0
|
|
121
|
+
loop do
|
|
122
|
+
status, retry_after_header, payload = send_request(method, uri, headers, data)
|
|
123
|
+
return payload if status >= 200 && status < 300
|
|
124
|
+
|
|
125
|
+
retry_after = DannyKeane.parse_retry_after(retry_after_header, Time.now.to_f)
|
|
126
|
+
wait = retry_after.nil? ? 2**attempt : retry_after
|
|
127
|
+
if status == 429 && attempt < max_retries && wait <= max_retry_delay
|
|
128
|
+
sleep(wait)
|
|
129
|
+
attempt += 1
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
raise ApiError.new(status, payload, retry_after)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def send_request(method, uri, headers, data)
|
|
137
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
138
|
+
http.use_ssl = uri.scheme == "https"
|
|
139
|
+
http.open_timeout = timeout
|
|
140
|
+
http.read_timeout = timeout
|
|
141
|
+
request = (method == "POST" ? Net::HTTP::Post : Net::HTTP::Get).new(uri, headers)
|
|
142
|
+
request.body = data unless data.nil?
|
|
143
|
+
response = http.start { |connection| connection.request(request) }
|
|
144
|
+
[response.code.to_i, response["Retry-After"], DannyKeane.parse_body(response.body)]
|
|
145
|
+
rescue *NETWORK_ERRORS => error
|
|
146
|
+
raise ConnectionError, "#{error.message} (#{error.class})"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The response body as JSON, or the raw text when it isn't; nil when empty.
|
|
151
|
+
def self.parse_body(raw)
|
|
152
|
+
return nil if raw.nil? || raw.empty?
|
|
153
|
+
|
|
154
|
+
text = raw.dup.force_encoding(Encoding::UTF_8).scrub
|
|
155
|
+
begin
|
|
156
|
+
JSON.parse(text)
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
text
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Retry-After as seconds: delta-seconds or an HTTP-date (RFC 9110 section 10.2.3).
|
|
163
|
+
def self.parse_retry_after(value, now)
|
|
164
|
+
return nil if value.nil?
|
|
165
|
+
|
|
166
|
+
value = value.strip
|
|
167
|
+
return value.to_f if value.match?(/\A\d+\z/)
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
at = Time.httpdate(value).to_f
|
|
171
|
+
rescue ArgumentError
|
|
172
|
+
return nil
|
|
173
|
+
end
|
|
174
|
+
[0.0, (at - now).ceil.to_f].max
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DannyKeane
|
|
4
|
+
# The machine-readable +code+ of every ErrorResponse the API documents.
|
|
5
|
+
ERROR_CODES = [
|
|
6
|
+
"invalid_body",
|
|
7
|
+
"unknown_event",
|
|
8
|
+
"missing_message",
|
|
9
|
+
"empty_message",
|
|
10
|
+
"message_too_long",
|
|
11
|
+
"missing_parameters",
|
|
12
|
+
"invalid_parameter",
|
|
13
|
+
"rate_limited",
|
|
14
|
+
"not_configured",
|
|
15
|
+
"upstream_error",
|
|
16
|
+
"method_not_allowed",
|
|
17
|
+
"not_found",
|
|
18
|
+
"internal_error"
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Base class for everything this SDK raises.
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
# A non-2xx response.
|
|
25
|
+
#
|
|
26
|
+
# +code+, +message+ and +hint+ come from the API's ErrorResponse body;
|
|
27
|
+
# +code+ is nil when the body wasn't one (a proxy's HTML error page, say).
|
|
28
|
+
# A server newer than this SDK may send a code not in ERROR_CODES.
|
|
29
|
+
class ApiError < Error
|
|
30
|
+
attr_reader :status, :code, :hint
|
|
31
|
+
# Seconds to wait before retrying, from Retry-After, when the response sent one.
|
|
32
|
+
attr_reader :retry_after
|
|
33
|
+
# The parsed JSON body, or the raw text when it wasn't JSON.
|
|
34
|
+
attr_reader :body
|
|
35
|
+
|
|
36
|
+
def initialize(status, body, retry_after = nil)
|
|
37
|
+
parsed = ApiError.error_response?(body) ? body : nil
|
|
38
|
+
@status = status
|
|
39
|
+
@code = parsed ? parsed["code"] : nil
|
|
40
|
+
@hint = parsed ? parsed["hint"] : nil
|
|
41
|
+
@retry_after = retry_after
|
|
42
|
+
@body = body
|
|
43
|
+
super(parsed ? parsed["error"] : "HTTP #{status}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.error_response?(body)
|
|
47
|
+
body.is_a?(Hash) && body["code"].is_a?(String) && body["error"].is_a?(String)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The API could not be reached: DNS, refused connection, TLS, or timeout.
|
|
52
|
+
# The underlying exception is on +cause+.
|
|
53
|
+
class ConnectionError < Error; end
|
|
54
|
+
end
|
data/lib/dannykeane.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Official Ruby SDK and CLI for the dannykeane.com public API.
|
|
4
|
+
#
|
|
5
|
+
# require "dannykeane"
|
|
6
|
+
# dk = DannyKeane::Client.new
|
|
7
|
+
# dk.iter_work { |role| puts "#{role["company"]} #{role["years"]}" }
|
|
8
|
+
#
|
|
9
|
+
# Docs: https://dannykeane.com/developers
|
|
10
|
+
module DannyKeane; end
|
|
11
|
+
|
|
12
|
+
require_relative "dannykeane/version"
|
|
13
|
+
require_relative "dannykeane/errors"
|
|
14
|
+
require_relative "dannykeane/client"
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dannykeane
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Danny Keane
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: 'Official Ruby SDK and CLI for the dannykeane.com public API: work history,
|
|
14
|
+
GitHub contributions, live stats, and the Habbo NPC chat. Zero dependencies.'
|
|
15
|
+
email:
|
|
16
|
+
- me@dannykeane.co.uk
|
|
17
|
+
executables:
|
|
18
|
+
- dannykeane
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- exe/dannykeane
|
|
25
|
+
- lib/dannykeane.rb
|
|
26
|
+
- lib/dannykeane/cli.rb
|
|
27
|
+
- lib/dannykeane/client.rb
|
|
28
|
+
- lib/dannykeane/errors.rb
|
|
29
|
+
- lib/dannykeane/version.rb
|
|
30
|
+
homepage: https://dannykeane.com/developers
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
homepage_uri: https://dannykeane.com/developers
|
|
35
|
+
documentation_uri: https://dannykeane.com/developers/sdk
|
|
36
|
+
post_install_message:
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '2.6'
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 3.0.3.1
|
|
52
|
+
signing_key:
|
|
53
|
+
specification_version: 4
|
|
54
|
+
summary: Official Ruby SDK and CLI for the dannykeane.com public API.
|
|
55
|
+
test_files: []
|