sferik 0.0.1

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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +5 -0
  3. data/CHANGELOG.md +72 -0
  4. data/LICENSE.md +16 -0
  5. data/README.md +517 -0
  6. data/exe/sferik +6 -0
  7. data/lib/sferik/api/code_endpoints.rb +37 -0
  8. data/lib/sferik/api/profile_endpoints.rb +113 -0
  9. data/lib/sferik/api/resume_endpoints.rb +41 -0
  10. data/lib/sferik/api/site_endpoints.rb +247 -0
  11. data/lib/sferik/api/talk_endpoints.rb +45 -0
  12. data/lib/sferik/api.rb +23 -0
  13. data/lib/sferik/block.rb +25 -0
  14. data/lib/sferik/body.rb +54 -0
  15. data/lib/sferik/cache.rb +361 -0
  16. data/lib/sferik/cli.rb +369 -0
  17. data/lib/sferik/client.rb +453 -0
  18. data/lib/sferik/collection.rb +82 -0
  19. data/lib/sferik/configuration.rb +129 -0
  20. data/lib/sferik/connections.rb +151 -0
  21. data/lib/sferik/contributions.rb +86 -0
  22. data/lib/sferik/day.rb +35 -0
  23. data/lib/sferik/dependency.rb +26 -0
  24. data/lib/sferik/deployment.rb +36 -0
  25. data/lib/sferik/errors.rb +234 -0
  26. data/lib/sferik/figure.rb +83 -0
  27. data/lib/sferik/finger.rb +74 -0
  28. data/lib/sferik/freshness.rb +54 -0
  29. data/lib/sferik/home/pages.rb +27 -0
  30. data/lib/sferik/home/profile.rb +51 -0
  31. data/lib/sferik/home/section.rb +27 -0
  32. data/lib/sferik/home.rb +38 -0
  33. data/lib/sferik/json_parsing.rb +28 -0
  34. data/lib/sferik/name_change.rb +49 -0
  35. data/lib/sferik/place.rb +33 -0
  36. data/lib/sferik/podcast.rb +41 -0
  37. data/lib/sferik/project.rb +49 -0
  38. data/lib/sferik/projects.rb +138 -0
  39. data/lib/sferik/push.rb +33 -0
  40. data/lib/sferik/resource.rb +373 -0
  41. data/lib/sferik/resume/award.rb +43 -0
  42. data/lib/sferik/resume/basics.rb +77 -0
  43. data/lib/sferik/resume/education.rb +59 -0
  44. data/lib/sferik/resume/location.rb +35 -0
  45. data/lib/sferik/resume/meta.rb +35 -0
  46. data/lib/sferik/resume/patent.rb +43 -0
  47. data/lib/sferik/resume/profile.rb +35 -0
  48. data/lib/sferik/resume/project.rb +27 -0
  49. data/lib/sferik/resume/skill.rb +27 -0
  50. data/lib/sferik/resume/speaking.rb +19 -0
  51. data/lib/sferik/resume/volunteer.rb +59 -0
  52. data/lib/sferik/resume/work.rb +51 -0
  53. data/lib/sferik/resume.rb +125 -0
  54. data/lib/sferik/session.rb +41 -0
  55. data/lib/sferik/social_profile.rb +49 -0
  56. data/lib/sferik/status/github.rb +38 -0
  57. data/lib/sferik/status/loaded.rb +48 -0
  58. data/lib/sferik/status.rb +33 -0
  59. data/lib/sferik/talk.rb +73 -0
  60. data/lib/sferik/talks.rb +79 -0
  61. data/lib/sferik/validation.rb +119 -0
  62. data/lib/sferik/version.rb +7 -0
  63. data/lib/sferik/web_finger/link.rb +46 -0
  64. data/lib/sferik/web_finger.rb +43 -0
  65. data/lib/sferik/who.rb +53 -0
  66. data/lib/sferik/whoami.rb +36 -0
  67. data/lib/sferik/wrapping.rb +114 -0
  68. data/lib/sferik.rb +246 -0
  69. data/sig/manifest.yaml +13 -0
  70. data/sig/sferik.rbs +825 -0
  71. metadata +118 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 58e1ce25f8fec8bae5ddb70b4e0a3344e782d19f86c4521f6fb309c6d2a99e7f
4
+ data.tar.gz: 969c2fa4b3cf8924b2b5295d92c6852e29afd94a4cccd510f195c47f2e68f31d
5
+ SHA512:
6
+ metadata.gz: 1d1d7310a9723b777ca20ba7f013ccd6a097990c04b6e6ab18ddab03b9116f5528151288d494f99504a7e169b3d193cc4588cf63ce6526005af64ce3a1ec05a8
7
+ data.tar.gz: fcf823a89aa06b14cd33ffa59245321d0a739e4d6d57d9f9dd34c78d90e21bc093e91a4ac598d782ff418bb4e7fcdd86f0e6c423d2576e91d6ecdf44da56fe2c
data/.yardopts ADDED
@@ -0,0 +1,5 @@
1
+ --no-private
2
+ --hide-api private
3
+ --markup markdown
4
+ lib/**/*.rb
5
+ - README.md CHANGELOG.md LICENSE.md
data/CHANGELOG.md ADDED
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1 (2026-10-08)
4
+
5
+ - Initial release: every endpoint of the sferik.net API (`home`, `whoami`, `dependency`, `finger`, `finger_vcard`,
6
+ `name_change`, `signature`, `webfinger`, `contributions`, `projects`, `talks`, `talks_feed`, `podcasts`, `resume`,
7
+ `resume_latex`, `resume_pdf`, `who`, `deployment`, `status`, `text`, `openapi`, and the two that write, `check_in` and
8
+ `write`), on `Sferik` itself or on a client of your own (`Sferik.new`)
9
+ - Immutable response objects, typed all the way down (`Sferik::Resume::Work`, `Sferik::Home::Profile`, and so on),
10
+ with every date a `Date` or `Time`, and with equality, pattern matching, `to_h`, `to_json`, and `as_json`. Everything
11
+ inside one is built when it is, so a response that isn't what the API documents raises `InvalidResponse` from the
12
+ endpoint that got it, not from a reader later on
13
+ - `Sferik.projects`, `Sferik.talks`, and `Sferik.who` are Enumerable, over their projects, talks, and terminals, with
14
+ `size`, `length`, `empty?`, `last`, and `[]` as well, and they match array patterns (`in [newest, *]`). Their `to_h`
15
+ is of their readers, like any resource's, and with a block it's of the list, like an Array's
16
+ - `Sferik.check_in(token)` checks in a terminal, as each browser tab on the site does (on the home page, or on the
17
+ one that `page:` names), and returns who's reading with the terminal's name as `you`. With a block, it keeps the
18
+ terminal logged in for as long as the block runs, checking it in again every minute. `Sferik.write("...")` sends
19
+ Erik a message, as the shell's `write sferik` does. Each message goes with a random key (or the one `key:` gives),
20
+ which the server doesn't email twice, so `write` sends it once more, five seconds later, if it was sent and no answer
21
+ came (`Unanswered`), but not if the server couldn't be connected to. And if the server says the message is still
22
+ being sent (a 409), `write` asks after it once more, as much later as the server says to
23
+ - `Sferik.contributions` and `Sferik.projects` say when their numbers are from, as `as_of`, and whether that's
24
+ now, as `live?`: it's false for a snapshot, and for numbers the site last fetched more than two hours ago. `as_of`
25
+ is to the hour, so that numbers that haven't changed are the same response, which a cached client isn't sent again
26
+ - `Sferik.status` says whether the site gets its numbers from GitHub as it should, with its token: when GitHub was
27
+ last asked with it, when it last answered, and what went wrong if it didn't. The numbers are live either way, since
28
+ the site asks another way when the token fails. And it says when the site last loaded each of its live values, as
29
+ `loaded`: the downloads and the contributions, which say so themselves, and the stars and the latest push, which
30
+ say so nowhere else
31
+ - `Sferik.client.get` and `Sferik.client.post` send raw requests. A GET's redirects are followed, up to
32
+ `max_redirects` (10), but never from https to http. A POST is sent only once, and its redirects aren't followed. Its
33
+ body is sent as UTF-8, converted from the charset of the String it's given (a binary or US-ASCII one is taken for
34
+ UTF-8 already), with an `Idempotency-Key` header if `idempotency_key:` gives one
35
+ - A thread's requests to the site are made over one connection, left open between them, where each would otherwise
36
+ connect again: with https, most of the time a request takes. One that has sat unused for half a minute is opened
37
+ again. Each thread, fiber, and process has its own.
38
+ `Sferik.client.keep_alive { |client| ... }` makes the requests in its block over a connection of their own, which
39
+ is closed when the block ends, and `Sferik.client.close` closes the ones the thread has open
40
+ - `Sferik.client.cached` is a client that keeps the responses to its GETs for as long as each says it's good for, and
41
+ after that asks with the response's ETag, so the server sends the body only if it has changed. A response that has
42
+ been kept on its way already (`Age`) is good for that much less. Each request it makes says not to be answered from
43
+ a cache (`Cache-Control: no-cache`), so the site builds a new response where it would have sent one that's no longer
44
+ good, and sends what's still good as it is. A server error (a 5xx)
45
+ leaves what's kept as it is, to be asked after again. With `cached(stale_if_error: true)`, the client
46
+ answers with what it kept, however old, when the server can't be reached. Threads that ask it for the same thing
47
+ at once make one request between them, and what an endpoint builds of a response is kept with it, so the JSON of
48
+ one that's kept is parsed once. It keeps a hundred responses at most, the latest it asked for
49
+ - Configuration, with `Sferik.configure` or the options of `Sferik.new`: `host`, `user_agent`, `open_timeout`,
50
+ `read_timeout`, `write_timeout`, `max_redirects`, and `cache`, which makes the client one that keeps its responses,
51
+ as `cached` returns, so that `Sferik.who` and the rest do. A wrong one raises ArgumentError when the client is built
52
+ - Errors are all `Sferik::Error`: `InvalidURL`, `NetworkError` (and `Unanswered`, for a request that was sent and got
53
+ no answer), `TooManyRedirects`, `InvalidResponse`, and `HTTPError`
54
+ (`ClientError`, `NotFound`, `NotAcceptable`, `TooManyRequests`, and `ServerError`), which has the response's `code`,
55
+ `headers`, and `body`, what the server says went wrong as its message, which error it is as `error_code` (`"busy"` or
56
+ `"full"` for the `TooManyRequests` that `write` raises past its rate limit), and the seconds to wait as `retry_after`,
57
+ where the response says. One can be raised by hand with nothing but its class (`raise Sferik::NotFound`), which gives
58
+ it its code
59
+ - A `sferik` command, which prints what the shell on sferik.net prints: `sferik finger`, `sferik resume`, and so on.
60
+ With `--json` it prints JSON instead, `sferik resume --pdf` and `sferik resume --latex` print the resume as a PDF and
61
+ as LaTeX, `sferik finger --vcard` prints a contact card, and `--host` or the `SFERIK_HOST` environment variable names
62
+ a copy of the site to ask instead of sferik.net. `sferik feed`, `sferik deployment`, `sferik status`, and
63
+ `sferik openapi` print the talks as an Atom feed, the deployed commit, whether GitHub answers the site with its token,
64
+ and the API's description, and `sferik signature` and `sferik webfinger` the motto, and where sferik@sferik.net points
65
+ to. `sferik write` sends Erik the message it reads from standard input, and `sferik check-in` logs in a terminal and
66
+ prints its name, which `sferik write --tty` takes, and with `--watch` keeps it logged in until it's interrupted. It
67
+ exits 1 when a request fails, and 2 when the command line is wrong, as one that names two formats is, or a format for
68
+ what prints no resource (`sferik write`, `sferik help`, or `sferik --version`) or one that comes in one format alone
69
+ (`sferik feed`)
70
+ - The command loads the client only when it asks the site for something: `sferik --version` and `sferik --help`
71
+ don't wait for it
72
+ - RBS signatures, checked by Steep
data/LICENSE.md ADDED
@@ -0,0 +1,16 @@
1
+ # The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Erik Berlin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,517 @@
1
+ # sferik
2
+
3
+ A Ruby wrapper for the [sferik.net](https://sferik.net) API: Erik Berlin's bio, GitHub contributions, projects, talks,
4
+ and resume.
5
+
6
+ ## Installation
7
+
8
+ ```sh
9
+ bundle add sferik
10
+ ```
11
+
12
+ Or, without Bundler:
13
+
14
+ ```sh
15
+ gem install sferik
16
+ ```
17
+
18
+ It needs Ruby 3.4 or later, or JRuby 10 or later, and is tested on Linux, macOS, and Windows.
19
+
20
+ ## Documentation
21
+
22
+ [rubydoc.info/gems/sferik](https://rubydoc.info/gems/sferik/). The API itself is described by an OpenAPI 3.1 document at
23
+ [sferik.net/openapi.json](https://sferik.net/openapi.json).
24
+
25
+ ## Usage
26
+
27
+ ```ruby
28
+ require "sferik"
29
+ ```
30
+
31
+ ### The bio
32
+
33
+ ```ruby
34
+ whoami = Sferik.whoami
35
+ whoami.blocks.map(&:html) # => ["I've spent nearly two decades writing software...", ...]
36
+ whoami.multi_downloads # => 1_780_609_860
37
+ ```
38
+
39
+ ### The comic
40
+
41
+ ```ruby
42
+ figure = Sferik.dependency.figure # xkcd 2347, adapted
43
+ figure.src # => "/img/dependency.webp"
44
+ figure.alt # => "A tall, precarious tower of blocks labeled “all modern Ruby infrastructure,”..."
45
+ ```
46
+
47
+ ### Contact details
48
+
49
+ ```ruby
50
+ finger = Sferik.finger
51
+ finger.mail # => "sferik@gmail.com"
52
+ finger.profiles.map(&:url) # => ["https://github.com/sferik", "https://gitlab.com/sferik", ...]
53
+
54
+ File.write("erik-berlin.vcf", Sferik.finger_vcard) # the same, as a contact card for an address book
55
+ ```
56
+
57
+ ### The motto, and the account
58
+
59
+ ```ruby
60
+ Sferik.signature # => "I build libraries and tools software engineers depend on."
61
+ ```
62
+
63
+ sferik@sferik.net is a fediverse handle: the site answers WebFinger for it, and points to the account on Mastodon.
64
+
65
+ ```ruby
66
+ webfinger = Sferik.webfinger
67
+ webfinger.subject # => "acct:sferik@mastodon.social"
68
+ webfinger.aliases # => ["https://mastodon.social/@sferik", "https://mastodon.social/users/sferik"]
69
+ webfinger.links.map(&:rel) # => ["http://webfinger.net/rel/profile-page", "self", "http://ostatus.org/schema/1.0/subscribe"]
70
+
71
+ Sferik.webfinger("acct:sferik@sferik.org") # the same account, at sferik.com, sferik.org, and sferik.me too
72
+ ```
73
+
74
+ ### GitHub contributions
75
+
76
+ ```ruby
77
+ contributions = Sferik.contributions
78
+ contributions.total # => 7747
79
+ contributions.longest_streak # => 32
80
+ contributions.days.max_by(&:count).date # => #<Date: 2026-08-28>
81
+ contributions.last_push # => #<Sferik::Push repo="sferik/x-ruby" sha="d30399b..." at=2026-10-06 16:18:55 UTC>
82
+ contributions.as_of # => 2026-10-08 01:00:00 UTC, the hour the numbers were fetched in
83
+ ```
84
+
85
+ ### Projects
86
+
87
+ ```ruby
88
+ projects = Sferik.projects # Enumerable: most downloaded first, with related projects together
89
+ projects.first # => #<Sferik::Project name="multi_json" description="One interface to every Ruby JSON library." downloads=1173210700>
90
+ projects.map(&:name) # => ["multi_json", "multi_xml", "simplecov", ...]
91
+ projects.size # => 28; there's length, empty?, last, and [] too
92
+ projects.total_downloads # => 5_473_478_762, across every gem @sferik owns
93
+ projects.filter_map(&:downloads).sum # of those listed: downloads is nil for a project that isn't a gem
94
+ projects.as_of # => 2026-10-08 01:00:00 UTC, the hour the downloads were fetched in
95
+ ```
96
+
97
+ ### Talks
98
+
99
+ ```ruby
100
+ talks = Sferik.talks # Enumerable: newest first
101
+ talks.select(&:video).map(&:title) # => ["The Value of Being Lazy, or How I Made OpenStruct 10X Faster", ...]
102
+ talks.first.date # => #<Date: 2016-08-01>, the first day of the month it was in
103
+ talks.last.title # the oldest; there's size, length, empty?, and [] too
104
+ talks.places[talks.first.location] # => #<Sferik::Place lat=37.77 lon=-122.42 country="United States">
105
+ talks.filter_map(&:link) # => ["https://schedule.sxsw.com/2015/events/event_IAP35000"]: a talk's page on the event's site
106
+ talks.speaker_deck # => "https://speakerdeck.com/sferik"
107
+ Sferik.podcasts.first.show # => "Ruby Rogues, episode 248": the podcasts alone, which the talks have too
108
+ Sferik.talks_feed # the talks as an Atom feed, for a feed reader
109
+ ```
110
+
111
+ ### The resume
112
+
113
+ ```ruby
114
+ resume = Sferik.resume # a JSON Resume document (https://jsonresume.org)
115
+ resume.name # => "Erik Berlin"
116
+ resume.work.first.position # => "Founder"
117
+ resume.work.first.start_date # => #<Date: 2023-01-01>
118
+ resume.work.first.end_date # => nil, since it hasn't ended
119
+ resume.patents.map(&:number) # => ["US20110153423A1", "US20110153414A1"]
120
+ resume.last_modified # => #<Date: 2026-10-07>
121
+
122
+ File.binwrite("resume.pdf", Sferik.resume_pdf)
123
+ File.write("resume.tex", Sferik.resume_latex)
124
+ puts Sferik.text("/resume") # as a man page
125
+ ```
126
+
127
+ ### Who's reading the site
128
+
129
+ ```ruby
130
+ who = Sferik.who # Enumerable: a terminal per browser tab with the site open
131
+ who.size # => 2
132
+ who.map(&:page) # => ["/", "/talks"]
133
+ who.first.login # => 2026-10-06 12:00:00 UTC
134
+ ```
135
+
136
+ To be one of them, check in, as each browser tab does every minute: a terminal is logged in for three minutes after
137
+ it last checked in, and keeps its name for as long as it checks in with the same token.
138
+
139
+ ```ruby
140
+ require "securerandom"
141
+
142
+ token = SecureRandom.uuid # random, one per terminal
143
+ who = Sferik.check_in(token) # on the home page; or Sferik.check_in(token, page: "/talks")
144
+ who.you # => "ttys001", your terminal
145
+ who.size # => 3, with you
146
+ ```
147
+
148
+ With a block, the terminal stays logged in for as long as the block runs: a thread checks it in again every minute,
149
+ over a connection of its own, and stops when the block ends. A check-in that fails then is tried again a minute later.
150
+
151
+ ```ruby
152
+ Sferik.check_in(token) do |who|
153
+ Sferik.write(gets, tty: who.you) # however long the message takes to type, the terminal is still logged in
154
+ end
155
+ ```
156
+
157
+ ### Send me a message
158
+
159
+ ```ruby
160
+ Sferik.write("Hello from Ruby. Reply to me@example.com") # => "message sent to sferik"
161
+ Sferik.write("Hello again", tty: who.you) # names your terminal in the subject line
162
+ ```
163
+
164
+ The message is emailed to me, with a Reply-To if it includes an email address. It can be 5,000 bytes at most, and the
165
+ server takes one a minute from an address and twenty a day in all: past that, it raises `Sferik::TooManyRequests`,
166
+ with what the server says as its message, the seconds to wait as `retry_after`, and which limit it was as `error_code`
167
+ (`"busy"` or `"full"`). It's sent as UTF-8: a String in another charset is converted, and a binary or US-ASCII one
168
+ (what Ruby reads with no locale set) is taken for UTF-8 already.
169
+
170
+ Each message goes with a random key, and the server doesn't email one twice whose key it has taken within a day. So
171
+ if the message is sent and no answer comes (`Sferik::Unanswered`), `write` sends it once more, five seconds later. It
172
+ doesn't if the server couldn't be connected to at all (any other `Sferik::NetworkError`), when nothing was sent and
173
+ trying again at once wouldn't help. A message asked after while its first sending is still on its way gets a 409
174
+ from the server, which says how long to wait: `write` waits that long and asks once more, by when the server usually
175
+ knows the message was sent. If it's still on its way then, `write` raises the `Sferik::ClientError` (409), with
176
+ `error_code` `"sending"` and the seconds to wait as `retry_after`. To try again
177
+ yourself after a `Sferik::NetworkError`, give the key:
178
+
179
+ ```ruby
180
+ key = SecureRandom.uuid
181
+ begin
182
+ Sferik.write("Hello from Ruby", key:)
183
+ rescue Sferik::NetworkError
184
+ sleep 5
185
+ retry
186
+ end
187
+ ```
188
+
189
+ ### Which version of the site is deployed
190
+
191
+ ```ruby
192
+ deployment = Sferik.deployment
193
+ deployment.commit # => "6a34226a3f351a78339b75430055a018ac30c964"
194
+ deployment.deployed # => 2026-10-07 18:04:11 UTC
195
+ deployment.url # => "https://github.com/sferik/sferik-web/commit/6a34226a3f351a78339b75430055a018ac30c964"
196
+ ```
197
+
198
+ ### Whether the site's live numbers come as they should
199
+
200
+ The site asks GitHub for its numbers with a token, and another way when that fails, so they come all the same, and
201
+ are live. What became of asking with the token is its status:
202
+
203
+ ```ruby
204
+ github = Sferik.status.github
205
+ github.asked # => 2026-10-08 21:45:07 UTC, when GitHub was last asked with the token
206
+ github.answered # => 2026-10-08 21:45:07 UTC, when it last answered
207
+ github.error # => nil, or what went wrong the last time: "https://api.github.com/graphql: 401"
208
+ ```
209
+
210
+ And it says when the site last loaded each of its live values. The downloads and the contributions say when they're
211
+ from themselves (`as_of`), but the stars and the latest push say so only here:
212
+
213
+ ```ruby
214
+ loaded = Sferik.status.loaded
215
+ loaded.gems # => 2026-10-08 21:45:07 UTC, when the downloads were last loaded
216
+ loaded.stars # => 2026-10-08 21:45:07 UTC
217
+ loaded.contributions # => 2026-10-08 21:45:07 UTC
218
+ loaded.push # => 2026-10-08 21:45:08 UTC, or nil for one that never has been
219
+ ```
220
+
221
+ ### Anything as terminal output
222
+
223
+ Every resource also comes as text, wrapped to 80 columns, the way `curl sferik.net` shows it:
224
+
225
+ ```ruby
226
+ puts Sferik.text # the whole home page
227
+ puts Sferik.text("/talks")
228
+ ```
229
+
230
+ ### Raw requests
231
+
232
+ ```ruby
233
+ Sferik.client.get("/whoami", accept: "text/plain")
234
+ Sferik.client.post("/write", "Hello", accept: "text/plain") # the body is sent as plain text
235
+ Sferik.client.post("/write", "Hello", idempotency_key: SecureRandom.uuid)
236
+ ```
237
+
238
+ ### Connections
239
+
240
+ A thread's requests are made over one connection, which saves connecting again for each: with https, that's most of
241
+ the time a request takes. Each thread (or fiber) has its own, as a connection is for one at a time, and so does each
242
+ process, so a fork doesn't use its parent's.
243
+
244
+ ```ruby
245
+ Sferik.whoami # opens a connection
246
+ Sferik.talks # uses it
247
+ Sferik.resume # and again: three requests in about half the time
248
+ ```
249
+
250
+ One that sits unused for more than half a minute is opened again, and so is one the server has closed by then. It's
251
+ left open, and closed when its thread is collected, or the process ends. For connections that are closed when
252
+ you're done with them, make the requests in `keep_alive`:
253
+
254
+ ```ruby
255
+ Sferik.client.keep_alive do |client|
256
+ [client.whoami, client.talks, client.resume] # one connection of its own, closed when the block ends
257
+ end
258
+ ```
259
+
260
+ The client it yields has the options of the one it's called on, and is for one thread at a time.
261
+
262
+ To close the connections a thread has open, whenever you like, call `close`. The next request opens one again:
263
+
264
+ ```ruby
265
+ Sferik.whoami
266
+ Sferik.client.close # before a fork, say, though a child never uses its parent's anyway
267
+ ```
268
+
269
+ ### Asking only for what has changed
270
+
271
+ Each GET asks the server. A client from `cached` keeps the responses instead: one says how long it's good for (an hour
272
+ for what changes only when the site is deployed, five minutes for what has live numbers in it, and five seconds for
273
+ who's reading), and for that long the client answers with it, without a request. After that it asks with the response's
274
+ ETag, and the server sends the body only if it has changed.
275
+
276
+ ```ruby
277
+ client = Sferik.client.cached
278
+ client.talks # asks the server
279
+ client.talks # doesn't, for an hour; after that, asks whether the talks have changed
280
+ ```
281
+
282
+ With `Sferik.cache = true` (or `Sferik.new(cache: true)`), the client is one that keeps its responses from the start,
283
+ so the methods on `Sferik` itself do: `Sferik.who`, called each second, asks the server every five.
284
+
285
+ What it keeps is in memory, by URL and format, for as long as the client is (a hundred responses at most, the latest
286
+ it asked for), so keep the client: each call of
287
+ `cached` starts with nothing kept. It's safe to share between threads, and works in `keep_alive` too. Threads that ask
288
+ for the same thing at once make one request between them: the first asks, and the rest wait for its answer.
289
+
290
+ What an endpoint builds of a response is kept with it: for as long as the client answers with the response it kept,
291
+ `client.talks` is the same object, and the JSON isn't parsed again. Everything in it is frozen, so that's safe to share.
292
+
293
+ A response that Cloudflare's cache answered with has been kept there for a while already, which it says (`Age`), and
294
+ is good for that much less here: one that's good for five minutes, and has been kept for four, is asked for again in
295
+ one. Cloudflare's cache answers with what it has, when that's no longer good, while it builds another for whoever
296
+ asks next: so each request a cached client makes says not to be answered that way (`Cache-Control: no-cache`), and the
297
+ site has it wait for the new one. What's still good there is its answer all the same.
298
+
299
+ When the server can't be reached to say whether a response that's no longer good has changed, the request raises
300
+ `NetworkError`, as any other would, and when the server answers with an error of its own (a 5xx), `ServerError`. The
301
+ response stays kept either way, and the next request asks after it again. A script that would rather go on with what
302
+ it last knew can ask for that:
303
+
304
+ ```ruby
305
+ client = Sferik.client.cached(stale_if_error: true)
306
+ client.talks # asks the server
307
+ client.talks # an hour later, with the network down, or the server answering 503: the talks it kept
308
+ ```
309
+
310
+ ## The sferik command
311
+
312
+ The gem comes with a `sferik` command, which prints what the shell on sferik.net prints, in your terminal:
313
+
314
+ ```sh
315
+ gem install sferik
316
+ sferik finger # how to reach me
317
+ sferik resume # my resume, as a man page
318
+ sferik --help # every command and option
319
+ ```
320
+
321
+ The commands that print are `finger`, `whoami`, `talks`, `podcasts`, `resume`, `contributions`, `src`, `name`,
322
+ `dependency`, and `who`; with none, it prints the home page. With `--json`, a command prints JSON instead of text:
323
+
324
+ ```sh
325
+ sferik talks --json | jq -r '.talks[].title'
326
+ ```
327
+
328
+ Six more print what comes in one format alone, and take no format: `signature` (my motto), `webfinger` (where
329
+ sferik@sferik.net points to, as JSON), `feed` (my talks, as an Atom feed), `deployment` (which commit of the site is
330
+ deployed, and when, as JSON), `status` (whether GitHub answers the site with its token, as JSON), and `openapi` (the
331
+ description of the API, as JSON):
332
+
333
+ ```sh
334
+ sferik deployment | jq -r .commit
335
+ ```
336
+
337
+ The resume also comes as a PDF with `--pdf`, and as LaTeX with `--latex`, and `finger` as a contact card with
338
+ `--vcard`:
339
+
340
+ ```sh
341
+ sferik resume --pdf > resume.pdf
342
+ sferik resume --latex > resume.tex
343
+ sferik finger --vcard > erik-berlin.vcf
344
+ ```
345
+
346
+ `sferik write` sends me a message, as `write sferik` does in the shell on the site. It reads the message from
347
+ standard input: type it and press Ctrl-D, or pipe it in.
348
+
349
+ ```sh
350
+ echo "Hello from my terminal. Reply to me@example.com" | sferik write
351
+ ```
352
+
353
+ `sferik check-in` logs in a terminal, as each browser tab on the site does, and prints its name, which `write` takes
354
+ as `--tty`, to say which terminal a message is from. A terminal is logged in for three minutes after it last checked
355
+ in, and keeps its name if it checks in again with the same `--token` (16 to 64 letters, digits, hyphens, and
356
+ underscores); without one, each check-in is a new terminal's.
357
+
358
+ ```sh
359
+ tty=$(sferik check-in) # => ttys003, say
360
+ echo "Hello from $tty" | sferik write --tty "$tty"
361
+ ```
362
+
363
+ With `--watch`, it prints the name and stays logged in, checking in again every minute, until it's interrupted
364
+ (Ctrl-C), when it exits 130:
365
+
366
+ ```sh
367
+ sferik check-in --watch # => ttys003, and `sferik who` lists it for as long as this runs
368
+ ```
369
+
370
+ It exits 0 when it has printed what it was asked for, 1 when a request fails (the site can't be reached, or says no),
371
+ and 2 when the command line is wrong (an unknown command or option, more than one format, a format for what prints no
372
+ resource, or one that comes in one format alone (`write`, `check-in`, `help`, `--version`, `signature`, `webfinger`,
373
+ `feed`, `deployment`, `status`, or `openapi`), `--tty`, `--token`, or `--watch` for another command, or a host that
374
+ isn't an http or https URL, from `--host` or `SFERIK_HOST`), so a script can tell the two apart.
375
+
376
+ To ask a local copy of the site instead of sferik.net, name it with `--host` or the `SFERIK_HOST` environment
377
+ variable (one that's set but empty counts as not set):
378
+
379
+ ```sh
380
+ sferik finger --host http://localhost:3745
381
+ SFERIK_HOST=http://localhost:3745 sferik finger
382
+ ```
383
+
384
+ ## Response objects
385
+
386
+ Endpoints return immutable objects, nested where the response is: a resume's jobs are `Sferik::Resume::Work` objects,
387
+ for example. Two with the same attributes are equal, and they work with pattern matching. Dates the API gives to the
388
+ month or year, like a talk's, are the first day of that month or year. A list the response leaves out is empty, not nil.
389
+ In a pattern and in `to_h`, a predicate goes by its name without the question mark: `live?` is `live:`. Projects, talks,
390
+ and who's reading match array patterns too, and with a block their `to_h` makes a Hash of the list, as an Array's does
391
+ (`Sferik.projects.to_h { |project| [project.name, project.stars] }`). One built by hand
392
+ (`Sferik::Talk.new("title" => "...")`) keeps a frozen copy of what it's given, and leaves the original as it was. What
393
+ it's given must be a Hash with the keys of the API's JSON, which are strings (`"startDate"`, not `start_date:`):
394
+ anything else raises `ArgumentError`. In Rails, a resource inside something rendered as JSON is the JSON it came from,
395
+ since it has `as_json`.
396
+
397
+ Everything inside a response object is built when it is, so a response that isn't what the API documents raises
398
+ `Sferik::InvalidResponse` from the endpoint that got it, never from a reader later on, and a reader returns the same
399
+ frozen object each time it's called. Dates and times are frozen too: to see a time in your zone, use `getlocal`, since
400
+ `localtime` would change it.
401
+
402
+ ```ruby
403
+ case Sferik.contributions
404
+ in {total:, longest_streak:, live:}
405
+ puts "#{total} contributions, longest streak #{longest_streak} days#{" (not live)" unless live}"
406
+ end
407
+
408
+ case Sferik.talks
409
+ in [newest, *, oldest]
410
+ puts "From #{oldest.title} to #{newest.title}"
411
+ end
412
+
413
+ Sferik.talks.first.to_h # => {title: "...", event: "...", date: #<Date: 2015-11-01>, ..., featured: true}
414
+ Sferik.talks.first.attributes # => the raw JSON, frozen
415
+ Sferik.talks.to_json # => the JSON it came from
416
+ ```
417
+
418
+ ## Configuration
419
+
420
+ ```ruby
421
+ Sferik.configure do |config|
422
+ config.host = "http://localhost:3745" # a local copy of the site
423
+ config.read_timeout = 30
424
+ end
425
+
426
+ # Or build a client of your own
427
+ client = Sferik.new(host: "http://localhost:3745")
428
+ client.whoami
429
+ ```
430
+
431
+ | Setting | Default | Description |
432
+ | --------------- | --------------------------- | ------------------------------------------------ |
433
+ | `host` | `"https://sferik.net"` | The host for API requests, with scheme |
434
+ | `user_agent` | `"sferik/VERSION (ruby …)"` | The `User-Agent` header |
435
+ | `open_timeout` | `5` | Seconds to wait for a connection to open |
436
+ | `read_timeout` | `10` | Seconds to wait for a response (see below) |
437
+ | `write_timeout` | `10` | Seconds to wait for a request to be sent |
438
+ | `max_redirects` | `10` | Redirects to follow (never from https to http) |
439
+ | `cache` | `false` | Keep the responses to GETs (see above) |
440
+
441
+ The host must be an http or https URL with no credentials, query, or fragment, the user agent must be on one line, a
442
+ timeout must be positive and finite, `max_redirects` can't be negative (0 follows none), and `cache` must be true or
443
+ false: anything else raises
444
+ `ArgumentError` when the client is built.
445
+
446
+ A request that times out waiting for a response is sent once more, as Net::HTTP does with any GET, so a response that
447
+ never comes takes twice `read_timeout` to raise `Sferik::NetworkError`. A POST is sent only once, and its redirects
448
+ aren't followed, except that `write` sends its message once more if no answer comes, since its key makes that safe.
449
+
450
+ ## Errors
451
+
452
+ Every error is a `Sferik::Error`:
453
+
454
+ ```
455
+ Sferik::Error
456
+ ├── Sferik::InvalidURL the path can't be in a URL
457
+ ├── Sferik::NetworkError the server couldn't be reached, or its response couldn't be read
458
+ │ └── Sferik::Unanswered the request was sent, or may have been, and no answer came that could be read
459
+ ├── Sferik::TooManyRedirects redirected more than max_redirects times
460
+ ├── Sferik::InvalidResponse the response wasn't what the API documents
461
+ └── Sferik::HTTPError any other response that isn't a success, or a redirect that isn't followed (#code, #headers, #body, #error_code, #retry_after)
462
+ ├── Sferik::ClientError 4xx
463
+ │ ├── Sferik::NotFound 404
464
+ │ ├── Sferik::NotAcceptable 406: no such format for that resource
465
+ │ └── Sferik::TooManyRequests 429: too many messages
466
+ └── Sferik::ServerError 5xx
467
+ ```
468
+
469
+ The message of an HTTP error is always UTF-8, whatever charset the response was in. `error_code` is which error it is,
470
+ where the API says (`"busy"`, `"too_long"`, `"bad_token"`, `"not_found"`, `"no_account"`, and so on), and `retry_after`
471
+ is the seconds to wait before trying again, where the response has a Retry-After header: each is nil otherwise.
472
+
473
+ One can be raised by hand, as a spec that stubs a request does, with nothing but its class, which gives it its code:
474
+
475
+ ```ruby
476
+ allow(Sferik).to receive(:whoami).and_raise(Sferik::NotFound) # code 404, message "404 Not Found"
477
+ raise Sferik::ServerError, "Down for maintenance" # code 500
478
+ ```
479
+
480
+ ## Development
481
+
482
+ ```sh
483
+ bin/setup # install dependencies
484
+ bundle exec rake # everything below
485
+ bundle exec rake spec # specs, with 100% line, branch, and method coverage
486
+ bundle exec rake lint # RuboCop and Standard
487
+ bundle exec rake mutant # mutation tests: every mutant must be killed
488
+ bundle exec rake steep # type-check lib/ against the signatures in sig/
489
+ bundle exec rake rbs # validate the signatures
490
+ bundle exec rake yardstick # 100% documentation coverage
491
+ bin/console # an IRB session with the library loaded
492
+ ```
493
+
494
+ The specs stub requests with responses saved from the API, in `spec/fixtures/`, alongside the API's OpenAPI description.
495
+ A contract spec checks every fixture against its schema there, what a cached client goes by against what it says of each
496
+ GET (`ETag`, `Cache-Control`, `Age`, `If-None-Match`, and the 304), and every key the library reads against what its
497
+ schema documents. To refresh them all from the live site (or a local copy):
498
+
499
+ ```sh
500
+ bundle exec rake fixtures
501
+ HOST=http://localhost:3745 bundle exec rake fixtures
502
+ ```
503
+
504
+ `bundle exec rake drift` checks the saved description against the live one, and fails if the API has changed since.
505
+ It runs daily in `.github/workflows/drift.yml`, and whenever the site is deployed (its deploy says so, with a
506
+ `repository_dispatch`, if sferik-web has a `DRIFT_TOKEN` secret: a fine-grained token with read and write access to
507
+ this repository's contents), and opens an issue when it fails. So does `bundle exec rake live`,
508
+ which asks the live site for its status, and fails if it hasn't loaded one of its live values for two hours (the
509
+ downloads, the stars, the contributions, or the latest push), or GitHub hasn't answered it with its token for as long.
510
+
511
+ ## Supported Ruby versions
512
+
513
+ Ruby 3.4 and 4.0, and JRuby.
514
+
515
+ ## License
516
+
517
+ MIT. See [LICENSE.md](LICENSE.md).
data/exe/sferik ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sferik/cli"
5
+
6
+ exit Sferik::CLI.new.run(ARGV)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../contributions"
4
+ require_relative "../projects"
5
+
6
+ module Sferik
7
+ module API
8
+ # The endpoints about code: GitHub contributions, and projects with their downloads and stars
9
+ # @api public
10
+ module CodeEndpoints
11
+ # Returns a year of GitHub contributions, the longest streak, and the latest push
12
+ #
13
+ # The server fetches these from GitHub and caches them; if GitHub can't be reached, the response is a snapshot,
14
+ # and {Contributions#live?} is false.
15
+ #
16
+ # @api public
17
+ # @return [Contributions]
18
+ # @example
19
+ # Sferik.contributions.days.max_by(&:count).date
20
+ def contributions
21
+ json("/contributions") { |attributes| Contributions.new(attributes) }
22
+ end
23
+
24
+ # Returns projects with their RubyGems downloads and GitHub stars
25
+ #
26
+ # Most downloaded first, except that related projects are listed together.
27
+ #
28
+ # @api public
29
+ # @return [Projects]
30
+ # @example
31
+ # Sferik.projects.total_downloads # => 5_460_234_129
32
+ def projects
33
+ json("/src") { |attributes| Projects.new(attributes) }
34
+ end
35
+ end
36
+ end
37
+ end