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.
- checksums.yaml +7 -0
- data/.yardopts +5 -0
- data/CHANGELOG.md +72 -0
- data/LICENSE.md +16 -0
- data/README.md +517 -0
- data/exe/sferik +6 -0
- data/lib/sferik/api/code_endpoints.rb +37 -0
- data/lib/sferik/api/profile_endpoints.rb +113 -0
- data/lib/sferik/api/resume_endpoints.rb +41 -0
- data/lib/sferik/api/site_endpoints.rb +247 -0
- data/lib/sferik/api/talk_endpoints.rb +45 -0
- data/lib/sferik/api.rb +23 -0
- data/lib/sferik/block.rb +25 -0
- data/lib/sferik/body.rb +54 -0
- data/lib/sferik/cache.rb +361 -0
- data/lib/sferik/cli.rb +369 -0
- data/lib/sferik/client.rb +453 -0
- data/lib/sferik/collection.rb +82 -0
- data/lib/sferik/configuration.rb +129 -0
- data/lib/sferik/connections.rb +151 -0
- data/lib/sferik/contributions.rb +86 -0
- data/lib/sferik/day.rb +35 -0
- data/lib/sferik/dependency.rb +26 -0
- data/lib/sferik/deployment.rb +36 -0
- data/lib/sferik/errors.rb +234 -0
- data/lib/sferik/figure.rb +83 -0
- data/lib/sferik/finger.rb +74 -0
- data/lib/sferik/freshness.rb +54 -0
- data/lib/sferik/home/pages.rb +27 -0
- data/lib/sferik/home/profile.rb +51 -0
- data/lib/sferik/home/section.rb +27 -0
- data/lib/sferik/home.rb +38 -0
- data/lib/sferik/json_parsing.rb +28 -0
- data/lib/sferik/name_change.rb +49 -0
- data/lib/sferik/place.rb +33 -0
- data/lib/sferik/podcast.rb +41 -0
- data/lib/sferik/project.rb +49 -0
- data/lib/sferik/projects.rb +138 -0
- data/lib/sferik/push.rb +33 -0
- data/lib/sferik/resource.rb +373 -0
- data/lib/sferik/resume/award.rb +43 -0
- data/lib/sferik/resume/basics.rb +77 -0
- data/lib/sferik/resume/education.rb +59 -0
- data/lib/sferik/resume/location.rb +35 -0
- data/lib/sferik/resume/meta.rb +35 -0
- data/lib/sferik/resume/patent.rb +43 -0
- data/lib/sferik/resume/profile.rb +35 -0
- data/lib/sferik/resume/project.rb +27 -0
- data/lib/sferik/resume/skill.rb +27 -0
- data/lib/sferik/resume/speaking.rb +19 -0
- data/lib/sferik/resume/volunteer.rb +59 -0
- data/lib/sferik/resume/work.rb +51 -0
- data/lib/sferik/resume.rb +125 -0
- data/lib/sferik/session.rb +41 -0
- data/lib/sferik/social_profile.rb +49 -0
- data/lib/sferik/status/github.rb +38 -0
- data/lib/sferik/status/loaded.rb +48 -0
- data/lib/sferik/status.rb +33 -0
- data/lib/sferik/talk.rb +73 -0
- data/lib/sferik/talks.rb +79 -0
- data/lib/sferik/validation.rb +119 -0
- data/lib/sferik/version.rb +7 -0
- data/lib/sferik/web_finger/link.rb +46 -0
- data/lib/sferik/web_finger.rb +43 -0
- data/lib/sferik/who.rb +53 -0
- data/lib/sferik/whoami.rb +36 -0
- data/lib/sferik/wrapping.rb +114 -0
- data/lib/sferik.rb +246 -0
- data/sig/manifest.yaml +13 -0
- data/sig/sferik.rbs +825 -0
- 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
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,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
|