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 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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "dannykeane/cli"
5
+
6
+ exit DannyKeane::CLI.new.run(ARGV)
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DannyKeane
4
+ VERSION = "0.1.0"
5
+ 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: []