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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../dependency"
|
|
5
|
+
require_relative "../finger"
|
|
6
|
+
require_relative "../home"
|
|
7
|
+
require_relative "../name_change"
|
|
8
|
+
require_relative "../validation"
|
|
9
|
+
require_relative "../web_finger"
|
|
10
|
+
require_relative "../whoami"
|
|
11
|
+
|
|
12
|
+
module Sferik
|
|
13
|
+
module API
|
|
14
|
+
# The endpoints about Erik: the bio, the comic, contact details, the name change, the motto, and the account
|
|
15
|
+
# @api public
|
|
16
|
+
module ProfileEndpoints
|
|
17
|
+
include Validation
|
|
18
|
+
|
|
19
|
+
# Returns the profile and the home page's modules
|
|
20
|
+
#
|
|
21
|
+
# The home page builds itself from these.
|
|
22
|
+
#
|
|
23
|
+
# @api public
|
|
24
|
+
# @return [Home]
|
|
25
|
+
# @example
|
|
26
|
+
# Sferik.home.profile.tagline
|
|
27
|
+
def home
|
|
28
|
+
json("") { |attributes| Home.new(attributes) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the bio: paragraphs of HTML
|
|
32
|
+
#
|
|
33
|
+
# @api public
|
|
34
|
+
# @return [Whoami]
|
|
35
|
+
# @example
|
|
36
|
+
# Sferik.whoami.blocks.map(&:html)
|
|
37
|
+
def whoami
|
|
38
|
+
json("/whoami") { |attributes| Whoami.new(attributes) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the xkcd comic on the home page: xkcd 2347, adapted
|
|
42
|
+
#
|
|
43
|
+
# @api public
|
|
44
|
+
# @return [Dependency]
|
|
45
|
+
# @example
|
|
46
|
+
# Sferik.dependency.figure.alt
|
|
47
|
+
def dependency
|
|
48
|
+
json("/dependency") { |attributes| Dependency.new(attributes) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns contact details and profiles elsewhere
|
|
52
|
+
#
|
|
53
|
+
# @api public
|
|
54
|
+
# @return [Finger]
|
|
55
|
+
# @example
|
|
56
|
+
# Sferik.finger.profiles.map(&:url)
|
|
57
|
+
def finger
|
|
58
|
+
json("/finger") { |attributes| Finger.new(attributes) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns contact details and profiles as a contact card (a vCard)
|
|
62
|
+
#
|
|
63
|
+
# It's what an address book imports: the name, the email address, and each profile elsewhere.
|
|
64
|
+
#
|
|
65
|
+
# @api public
|
|
66
|
+
# @return [String] the vCard
|
|
67
|
+
# @example
|
|
68
|
+
# File.write("erik-berlin.vcf", Sferik.finger_vcard)
|
|
69
|
+
def finger_vcard
|
|
70
|
+
get("/finger", accept: "text/vcard")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Returns the name change, from Erik Michaels-Ober to Erik Berlin, as a git commit
|
|
74
|
+
#
|
|
75
|
+
# @api public
|
|
76
|
+
# @return [NameChange]
|
|
77
|
+
# @example
|
|
78
|
+
# Sferik.name_change.year # => 2017
|
|
79
|
+
def name_change
|
|
80
|
+
json("/name") { |attributes| NameChange.new(attributes) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the motto: ~/.signature, which the home page shows as cat .signature
|
|
84
|
+
#
|
|
85
|
+
# @api public
|
|
86
|
+
# @return [String] the motto, without the newline the site ends it with
|
|
87
|
+
# @example
|
|
88
|
+
# Sferik.signature # => "I build libraries and tools software engineers depend on."
|
|
89
|
+
def signature
|
|
90
|
+
get("/.signature", accept: "text/plain").chomp
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns where an account at sferik.net points to, as WebFinger answers
|
|
94
|
+
#
|
|
95
|
+
# WebFinger is RFC 7033. That's the account on Mastodon, which makes the domain a fediverse handle. The same
|
|
96
|
+
# account is at sferik.com, sferik.org, and sferik.me.
|
|
97
|
+
#
|
|
98
|
+
# @api public
|
|
99
|
+
# @param resource [String] the account, as an acct: URI
|
|
100
|
+
# @return [WebFinger]
|
|
101
|
+
# @raise [ArgumentError] if the account isn't a String
|
|
102
|
+
# @raise [NotFound] if there's no such account: {HTTPError#error_code} is "no_account"
|
|
103
|
+
# @example
|
|
104
|
+
# Sferik.webfinger.subject # => "acct:sferik@mastodon.social"
|
|
105
|
+
# @example Ask after the account at another of the domains
|
|
106
|
+
# Sferik.webfinger("acct:sferik@sferik.org")
|
|
107
|
+
def webfinger(resource = "acct:sferik@sferik.net")
|
|
108
|
+
query = URI.encode_www_form(resource: check(:resource, resource, String))
|
|
109
|
+
json("/.well-known/webfinger?#{query}", accept: "application/jrd+json") { |attributes| WebFinger.new(attributes) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../resume"
|
|
4
|
+
|
|
5
|
+
module Sferik
|
|
6
|
+
module API
|
|
7
|
+
# The endpoints for the resume, as data, LaTeX, or a PDF
|
|
8
|
+
# @api public
|
|
9
|
+
module ResumeEndpoints
|
|
10
|
+
# Returns the resume as a JSON Resume document
|
|
11
|
+
#
|
|
12
|
+
# @api public
|
|
13
|
+
# @return [Resume]
|
|
14
|
+
# @example
|
|
15
|
+
# Sferik.resume.work.first.position
|
|
16
|
+
def resume
|
|
17
|
+
json("/resume") { |attributes| Resume.new(attributes) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the resume as a LaTeX document, ready for pdflatex or tectonic
|
|
21
|
+
#
|
|
22
|
+
# @api public
|
|
23
|
+
# @return [String] the LaTeX source
|
|
24
|
+
# @example
|
|
25
|
+
# File.write("resume.tex", Sferik.resume_latex)
|
|
26
|
+
def resume_latex
|
|
27
|
+
get("/resume", accept: "application/x-latex")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the resume as a two-page PDF
|
|
31
|
+
#
|
|
32
|
+
# @api public
|
|
33
|
+
# @return [String] the PDF, as binary
|
|
34
|
+
# @example
|
|
35
|
+
# File.binwrite("resume.pdf", Sferik.resume_pdf)
|
|
36
|
+
def resume_pdf
|
|
37
|
+
get("/resume", accept: "application/pdf")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../deployment"
|
|
6
|
+
require_relative "../json_parsing"
|
|
7
|
+
require_relative "../status"
|
|
8
|
+
require_relative "../validation"
|
|
9
|
+
require_relative "../who"
|
|
10
|
+
|
|
11
|
+
module Sferik
|
|
12
|
+
module API
|
|
13
|
+
# The endpoints about the site itself: who's reading it, checking in as one of them, sending Erik a message, any
|
|
14
|
+
# page as terminal output, the API's description, which commit is deployed, and whether its live numbers come as
|
|
15
|
+
# they should
|
|
16
|
+
# @api public
|
|
17
|
+
module SiteEndpoints
|
|
18
|
+
include JSONParsing
|
|
19
|
+
include Validation
|
|
20
|
+
|
|
21
|
+
# The seconds to wait before sending a message again that got no answer: as long as the server says to wait
|
|
22
|
+
# before asking after one that's still being sent
|
|
23
|
+
PAUSE = 5
|
|
24
|
+
private_constant :PAUSE
|
|
25
|
+
|
|
26
|
+
# The seconds between the check-ins of a terminal that a block keeps logged in: as often as a browser tab
|
|
27
|
+
# checks in, and a third of how long the server keeps one logged in that doesn't
|
|
28
|
+
BEAT = 60
|
|
29
|
+
private_constant :BEAT
|
|
30
|
+
|
|
31
|
+
# Returns everyone reading the site right now
|
|
32
|
+
#
|
|
33
|
+
# There's a terminal per browser tab, as the shell's who lists them, and they're Enumerable: `Sferik.who.size`
|
|
34
|
+
# is how many tabs have the site open.
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @return [Who]
|
|
38
|
+
# @example
|
|
39
|
+
# Sferik.who.map(&:page) # => ["/", "/talks"]
|
|
40
|
+
def who
|
|
41
|
+
json("/who") { |attributes| Who.new(attributes) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Checks in a terminal, and returns everyone reading the site
|
|
45
|
+
#
|
|
46
|
+
# It's what each browser tab does when it opens, and every minute it's in view. A terminal is logged in for
|
|
47
|
+
# three minutes after it last checked in, and keeps its name for as long as it checks in with the same token.
|
|
48
|
+
#
|
|
49
|
+
# With a block, the terminal stays logged in for as long as the block runs: it's checked in again every minute,
|
|
50
|
+
# as a tab in view is, by a thread of its own, over a connection of its own. A check-in that fails then is
|
|
51
|
+
# tried again a minute later, and raises nothing: the terminal is logged out only when three have failed in a
|
|
52
|
+
# row. When the block ends, the check-ins do, and the server logs the terminal out three minutes later.
|
|
53
|
+
#
|
|
54
|
+
# @api public
|
|
55
|
+
# @param token [String] a random token, one per terminal, of 16 to 64 letters, digits, underscores, and hyphens
|
|
56
|
+
# @param page [String] the page the terminal is on: "/", "/talks", or "/resume"
|
|
57
|
+
# @yield [who] what to do while the terminal is logged in
|
|
58
|
+
# @yieldparam who [Who] everyone reading the site when the terminal checked in, with the terminal as {Who#you}
|
|
59
|
+
# @yieldreturn [Object] anything
|
|
60
|
+
# @return [Who, Object] everyone reading the site, with the terminal that checked in as {Who#you}, or what the
|
|
61
|
+
# block returns, if there is one
|
|
62
|
+
# @raise [ArgumentError] if the token or the page isn't a String
|
|
63
|
+
# @raise [ClientError] if the token isn't one, or there's no such page: {HTTPError#error_code} is "bad_token" or
|
|
64
|
+
# "bad_page"
|
|
65
|
+
# @example
|
|
66
|
+
# Sferik.check_in(SecureRandom.uuid).you # => "ttys001"
|
|
67
|
+
# @example Check in on another page
|
|
68
|
+
# Sferik.check_in(SecureRandom.uuid, page: "/talks")
|
|
69
|
+
# @example Stay logged in for as long as it takes to write a message, which says which terminal it's from
|
|
70
|
+
# Sferik.check_in(SecureRandom.uuid) { |who| Sferik.write(gets, tty: who.you) }
|
|
71
|
+
def check_in(token, page: "/", &)
|
|
72
|
+
query = URI.encode_www_form(token: check(:token, token, String), page: check(:page, page, String))
|
|
73
|
+
who = Who.new(parse_json(post("/who?#{query}")))
|
|
74
|
+
block_given? ? logged_in(token, page, who, &) : who
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Sends Erik a message, as the shell's write sferik does
|
|
78
|
+
#
|
|
79
|
+
# The message is emailed on, with a Reply-To if it includes an email address. The server takes one message a
|
|
80
|
+
# minute from an address, and twenty a day in all.
|
|
81
|
+
#
|
|
82
|
+
# If it's sent and no answer comes ({Unanswered}), the message is sent once more, five seconds later: it goes
|
|
83
|
+
# with a key, and the server doesn't email a message twice whose key it has taken within a day. It isn't sent
|
|
84
|
+
# again if the server couldn't be connected to, when trying again at once wouldn't help. To try again yourself
|
|
85
|
+
# after a {NetworkError}, give the same key. The server answers a message whose first sending is still on its
|
|
86
|
+
# way with 409, and says how long to wait ({HTTPError#retry_after}): the message is asked after once more, that
|
|
87
|
+
# much later, by when the server usually knows that it was sent. If it's still on its way then, the 409 is
|
|
88
|
+
# raised, and {HTTPError#error_code} is "sending".
|
|
89
|
+
#
|
|
90
|
+
# @api public
|
|
91
|
+
# @param message [String] the message, as plain text: 5,000 bytes at most, sent as UTF-8 (see {Client#post})
|
|
92
|
+
# @param tty [String, nil] the sender's terminal, for the subject line: {Who#you}, from {#check_in}
|
|
93
|
+
# @param key [String] a random key, one per message, of 16 to 64 letters, digits, underscores, and hyphens
|
|
94
|
+
# @return [String] what the server says: "message sent to sferik"
|
|
95
|
+
# @raise [ArgumentError] if the message or the key isn't a String, the message is in a charset that doesn't convert
|
|
96
|
+
# to UTF-8, or the terminal is neither a String nor nil
|
|
97
|
+
# @raise [ClientError] if there's nothing to send or the key isn't one (400), the message is still being sent
|
|
98
|
+
# when it's asked after a second time (409), or it's too long (413)
|
|
99
|
+
# @raise [TooManyRequests] if there have been too many (429): {HTTPError#retry_after} is how long to wait, and
|
|
100
|
+
# {HTTPError#error_code} is "busy" for one a minute, and "full" for twenty a day
|
|
101
|
+
# @raise [ServerError] if the email didn't go through (502), or the server doesn't send email (503)
|
|
102
|
+
# @raise [Unanswered] if no answer comes, or one that can't be read, twice
|
|
103
|
+
# @raise [NetworkError] if the server can't be connected to
|
|
104
|
+
# @raise [InvalidResponse] if the response isn't what the API documents
|
|
105
|
+
# @example
|
|
106
|
+
# Sferik.write("Hello from Ruby. Reply to me@example.com")
|
|
107
|
+
def write(message, tty: nil, key: SecureRandom.uuid)
|
|
108
|
+
check(:tty, tty, String) unless tty.nil?
|
|
109
|
+
said = parse_json(deliver("/write?#{URI.encode_www_form({tty:}.compact)}".chomp("?"), message, check(:key, key, String)))["message"]
|
|
110
|
+
raise InvalidResponse, "Expected a message, got #{said.inspect}" unless said.instance_of?(String)
|
|
111
|
+
|
|
112
|
+
said
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns a resource as terminal output, wrapped to 80 columns
|
|
116
|
+
#
|
|
117
|
+
# It's what `curl sferik.net` shows.
|
|
118
|
+
#
|
|
119
|
+
# @api public
|
|
120
|
+
# @param path [String] the resource's path: "/whoami", "/resume" (as a man page), etc. (defaults to the home page)
|
|
121
|
+
# @return [String] the text
|
|
122
|
+
# @raise [NotFound] if there is no resource at that path
|
|
123
|
+
# @raise [InvalidURL] if the path can't be in a URL
|
|
124
|
+
# @example Print the resume as a man page
|
|
125
|
+
# puts Sferik.text("/resume")
|
|
126
|
+
def text(path = "")
|
|
127
|
+
get(path, accept: "text/plain")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Returns the API's OpenAPI 3.1 description
|
|
131
|
+
#
|
|
132
|
+
# @api public
|
|
133
|
+
# @return [Hash{String => Object}] the parsed document, deep-frozen
|
|
134
|
+
# @example
|
|
135
|
+
# Sferik.openapi["paths"].keys
|
|
136
|
+
def openapi
|
|
137
|
+
json("/openapi.json", &:itself)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Returns which commit of the site is deployed, and when it was
|
|
141
|
+
#
|
|
142
|
+
# @api public
|
|
143
|
+
# @return [Deployment]
|
|
144
|
+
# @example
|
|
145
|
+
# Sferik.deployment.commit # => "6a34226a3f351a78339b75430055a018ac30c964"
|
|
146
|
+
def deployment
|
|
147
|
+
json("/version") { |attributes| Deployment.new(attributes) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Returns whether the site's live numbers come as they should
|
|
151
|
+
#
|
|
152
|
+
# The site asks GitHub for its numbers with a token, and another way if that fails, so they come all the same:
|
|
153
|
+
# this says when GitHub last answered with the token, and what went wrong if it didn't.
|
|
154
|
+
#
|
|
155
|
+
# @api public
|
|
156
|
+
# @return [Status]
|
|
157
|
+
# @example
|
|
158
|
+
# Sferik.status.github.error # => nil
|
|
159
|
+
def status
|
|
160
|
+
json("/status") { |attributes| Status.new(attributes) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Keep a terminal logged in while a block runs
|
|
166
|
+
#
|
|
167
|
+
# A thread checks it in again every minute, over a connection of its own: the client's is for one thread at a
|
|
168
|
+
# time, and the block may be using it. The thread ends when the block does, and its connection is closed.
|
|
169
|
+
#
|
|
170
|
+
# @api private
|
|
171
|
+
# @param token [String] the terminal's token
|
|
172
|
+
# @param page [String] the page the terminal is on
|
|
173
|
+
# @param who [Who] everyone reading the site when the terminal checked in
|
|
174
|
+
# @yield [who] what to do while the terminal is logged in
|
|
175
|
+
# @yieldparam who [Who] everyone reading the site when the terminal checked in
|
|
176
|
+
# @yieldreturn [Object] anything
|
|
177
|
+
# @return [Object] what the block returns
|
|
178
|
+
def logged_in(token, page, who)
|
|
179
|
+
beating = Thread.new { keep_alive { |client| client.__send__(:beat, token, page) } }
|
|
180
|
+
begin
|
|
181
|
+
yield who
|
|
182
|
+
ensure
|
|
183
|
+
beating.kill
|
|
184
|
+
beating.join
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Check a terminal in every minute, for as long as the thread lives
|
|
189
|
+
#
|
|
190
|
+
# A check-in that fails is one the server never had, and the next is a minute later all the same: a terminal
|
|
191
|
+
# is logged in for three minutes after its last, so it takes three failing in a row to log it out.
|
|
192
|
+
#
|
|
193
|
+
# @api private
|
|
194
|
+
# @param token [String] the terminal's token
|
|
195
|
+
# @param page [String] the page the terminal is on
|
|
196
|
+
# @return [void] never: only the thread ending stops it
|
|
197
|
+
def beat(token, page)
|
|
198
|
+
loop do
|
|
199
|
+
Kernel.sleep(BEAT)
|
|
200
|
+
check_in(token, page:)
|
|
201
|
+
rescue Error
|
|
202
|
+
# tried again in a minute
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Send a message, and once more if no answer comes: its key makes that safe
|
|
207
|
+
#
|
|
208
|
+
# The second goes after a pause, to give the first time to arrive. One the server couldn't be connected to for
|
|
209
|
+
# wasn't sent at all, and isn't sent again.
|
|
210
|
+
#
|
|
211
|
+
# @api private
|
|
212
|
+
# @param path [String] the path, with any query
|
|
213
|
+
# @param message [String] the message
|
|
214
|
+
# @param key [String] the message's idempotency key
|
|
215
|
+
# @return [String] the response body
|
|
216
|
+
# @raise [Unanswered] if no answer comes, or one that can't be read, twice
|
|
217
|
+
# @raise [NetworkError] if the server can't be connected to
|
|
218
|
+
def deliver(path, message, key)
|
|
219
|
+
settle(path, message, key)
|
|
220
|
+
rescue Unanswered
|
|
221
|
+
Kernel.sleep(PAUSE)
|
|
222
|
+
settle(path, message, key)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Send a message, and ask after it once more if it's still being sent
|
|
226
|
+
#
|
|
227
|
+
# Still being sent is what the server says of a key it has taken, whose email hasn't gone yet: the message sent
|
|
228
|
+
# again while its first sending is on its way. The server says how long to wait, too, and after that the same
|
|
229
|
+
# key is answered with what became of the message.
|
|
230
|
+
#
|
|
231
|
+
# @api private
|
|
232
|
+
# @param path [String] the path, with any query
|
|
233
|
+
# @param message [String] the message
|
|
234
|
+
# @param key [String] the message's idempotency key
|
|
235
|
+
# @return [String] the response body
|
|
236
|
+
# @raise [ClientError] if the message is still being sent the second time, or was turned away for anything else
|
|
237
|
+
def settle(path, message, key)
|
|
238
|
+
post(path, message, idempotency_key: key)
|
|
239
|
+
rescue ClientError => e
|
|
240
|
+
raise unless e.error_code.eql?("sending")
|
|
241
|
+
|
|
242
|
+
Kernel.sleep(e.retry_after || PAUSE)
|
|
243
|
+
post(path, message, idempotency_key: key)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../talks"
|
|
4
|
+
|
|
5
|
+
module Sferik
|
|
6
|
+
module API
|
|
7
|
+
# The endpoints about speaking
|
|
8
|
+
# @api public
|
|
9
|
+
module TalkEndpoints
|
|
10
|
+
# Returns conference talks, newest first, and podcast appearances
|
|
11
|
+
#
|
|
12
|
+
# The talks are Enumerable: `Sferik.talks.first` is the newest. {Talks#places} is where each one's location is.
|
|
13
|
+
#
|
|
14
|
+
# @api public
|
|
15
|
+
# @return [Talks]
|
|
16
|
+
# @example
|
|
17
|
+
# Sferik.talks.select(&:video).map(&:title)
|
|
18
|
+
def talks
|
|
19
|
+
json("/talks") { |attributes| Talks.new(attributes) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns the talks as an Atom feed, newest first
|
|
23
|
+
#
|
|
24
|
+
# @api public
|
|
25
|
+
# @return [String] the feed, as XML
|
|
26
|
+
# @example
|
|
27
|
+
# File.write("talks.atom", Sferik.talks_feed)
|
|
28
|
+
def talks_feed
|
|
29
|
+
get("/talks.atom", accept: "application/atom+xml")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns podcast appearances
|
|
33
|
+
#
|
|
34
|
+
# They come with the talks too, as {Talks#podcasts}: this asks for them alone.
|
|
35
|
+
#
|
|
36
|
+
# @api public
|
|
37
|
+
# @return [Array<Podcast>]
|
|
38
|
+
# @example
|
|
39
|
+
# Sferik.podcasts.first.show # => "Ruby Rogues, episode 248"
|
|
40
|
+
def podcasts
|
|
41
|
+
json("/podcasts") { |attributes| Talks.new(attributes).podcasts } # the response is the talks' with only the podcasts
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/sferik/api.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "api/code_endpoints"
|
|
4
|
+
require_relative "api/profile_endpoints"
|
|
5
|
+
require_relative "api/resume_endpoints"
|
|
6
|
+
require_relative "api/site_endpoints"
|
|
7
|
+
require_relative "api/talk_endpoints"
|
|
8
|
+
|
|
9
|
+
module Sferik
|
|
10
|
+
# The endpoints of the sferik.net API, grouped into one mixin per topic
|
|
11
|
+
#
|
|
12
|
+
# {Client} includes them all, and {Sferik} delegates each to its client, so every method documented here can be
|
|
13
|
+
# called on either: `Sferik.whoami` or `Sferik::Client.new.whoami`.
|
|
14
|
+
#
|
|
15
|
+
# @api public
|
|
16
|
+
module API
|
|
17
|
+
include CodeEndpoints
|
|
18
|
+
include ProfileEndpoints
|
|
19
|
+
include ResumeEndpoints
|
|
20
|
+
include SiteEndpoints
|
|
21
|
+
include TalkEndpoints
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/sferik/block.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Sferik
|
|
6
|
+
# A paragraph of the bio, as returned in {Whoami#blocks}
|
|
7
|
+
# @api public
|
|
8
|
+
class Block < Resource
|
|
9
|
+
# @!method type
|
|
10
|
+
# The kind of block: "p" for a paragraph
|
|
11
|
+
# @api public
|
|
12
|
+
# @return [String] the kind of block: "p" for a paragraph
|
|
13
|
+
# @example
|
|
14
|
+
# block.type # => "p"
|
|
15
|
+
attribute :type
|
|
16
|
+
|
|
17
|
+
# @!method html
|
|
18
|
+
# The paragraph, as HTML
|
|
19
|
+
# @api public
|
|
20
|
+
# @return [String] the paragraph, as HTML
|
|
21
|
+
# @example
|
|
22
|
+
# block.html # => "I've spent nearly two decades..."
|
|
23
|
+
attribute :html
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/sferik/body.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sferik
|
|
4
|
+
# Reads the body of a response in the charset its Content-Type names
|
|
5
|
+
#
|
|
6
|
+
# Net::HTTP leaves the encoding of a body to chance: binary or UTF-8, by how the response happened to be sent. Text
|
|
7
|
+
# and JSON that name no charset are UTF-8, as a proxy that strips the charset leaves the site's, and as JSON always is.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Body
|
|
11
|
+
# The charset a Content-Type names, with or without quotes around it
|
|
12
|
+
CHARSET = /;\s*charset="?([^";\s]+)/i
|
|
13
|
+
private_constant :CHARSET
|
|
14
|
+
|
|
15
|
+
# A media type that is text or JSON, which is UTF-8 when it names no charset
|
|
16
|
+
TEXT = %r{\A(?:text/|application/json\b)}i
|
|
17
|
+
private_constant :TEXT
|
|
18
|
+
|
|
19
|
+
# The body of a response, in the charset its Content-Type names
|
|
20
|
+
#
|
|
21
|
+
# @api private
|
|
22
|
+
# @param response [Net::HTTPResponse] the response
|
|
23
|
+
# @return [String] the body: UTF-8 if it's text or JSON that names no charset, and binary if it's anything else
|
|
24
|
+
# that names none, or names one Ruby doesn't know
|
|
25
|
+
# @example
|
|
26
|
+
# Sferik::Body.of(response).encoding # => #<Encoding:UTF-8>
|
|
27
|
+
def self.of(response)
|
|
28
|
+
String.new(response.body.to_s, encoding: encoding_of(response))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The encoding the Content-Type of a response names
|
|
32
|
+
#
|
|
33
|
+
# The Content-Type is read here, not by Net::HTTP, which raises NoMethodError for a parameter without a value
|
|
34
|
+
# and takes the quotes around a charset for part of its name.
|
|
35
|
+
#
|
|
36
|
+
# @api private
|
|
37
|
+
# @param response [Net::HTTPResponse] the response
|
|
38
|
+
# @return [Encoding] the encoding: UTF-8 for text or JSON that names no charset, and binary for anything else that
|
|
39
|
+
# names none, or names one Ruby doesn't know
|
|
40
|
+
# @example
|
|
41
|
+
# Sferik::Body.encoding_of(response) # => #<Encoding:UTF-8>
|
|
42
|
+
def self.encoding_of(response)
|
|
43
|
+
type = response["content-type"].to_s
|
|
44
|
+
charset = type[CHARSET, 1]
|
|
45
|
+
return Encoding.find(charset) || Encoding::BINARY if charset # "internal" is a name, but may be no encoding
|
|
46
|
+
|
|
47
|
+
TEXT.match?(type) ? Encoding::UTF_8 : Encoding::BINARY
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
Encoding::BINARY
|
|
50
|
+
end
|
|
51
|
+
private_class_method :encoding_of
|
|
52
|
+
end
|
|
53
|
+
private_constant :Body
|
|
54
|
+
end
|