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
@@ -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
@@ -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
@@ -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