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,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require_relative "api"
6
+ require_relative "body"
7
+ require_relative "cache"
8
+ require_relative "configuration"
9
+ require_relative "connections"
10
+ require_relative "errors"
11
+ require_relative "json_parsing"
12
+ require_relative "validation"
13
+
14
+ module Sferik
15
+ # A client for the sferik.net API
16
+ #
17
+ # Each resource is one URL, and the Accept header picks its format: the endpoints of {API} ask for JSON (or, for
18
+ # the resume, LaTeX and PDF), and {#get} asks for whatever you like. {#post} sends what the two endpoints that
19
+ # write take: a terminal checking in, and a message.
20
+ #
21
+ # A thread's requests to a host are made over one connection, which is left open between them, and opened again
22
+ # if it has sat unused: {#keep_alive} is for connections that are closed when its block ends. And each GET asks the
23
+ # server: {#cached} is a client that keeps the responses, and asks again only for what may have changed, and so is
24
+ # one built with cache: true.
25
+ #
26
+ # @api public
27
+ class Client
28
+ include API
29
+ include JSONParsing
30
+ include Validation
31
+
32
+ # The encodings of a body that names no charset of its own, which is sent as it is: binary, and US-ASCII
33
+ UNLABELED = [Encoding::BINARY, Encoding::US_ASCII].freeze
34
+ private_constant :UNLABELED
35
+
36
+ # The media type of the body of a POST request
37
+ PLAIN_TEXT = "text/plain; charset=utf-8"
38
+ private_constant :PLAIN_TEXT
39
+
40
+ # The host for API requests
41
+ # @api public
42
+ # @return [String] the host, including the scheme, frozen
43
+ # @example
44
+ # client.host # => "https://sferik.net"
45
+ attr_reader :host
46
+
47
+ # The 'User-Agent' HTTP header sent with requests
48
+ # @api public
49
+ # @return [String] the user agent, frozen
50
+ # @example
51
+ # client.user_agent
52
+ attr_reader :user_agent
53
+
54
+ # The seconds to wait for a connection to open
55
+ # @api public
56
+ # @return [Numeric] the timeout
57
+ # @example
58
+ # client.open_timeout # => 5
59
+ attr_reader :open_timeout
60
+
61
+ # The seconds to wait for a response
62
+ #
63
+ # Net::HTTP sends a request that times out once more, so a response that never comes takes twice this long to fail.
64
+ #
65
+ # @api public
66
+ # @return [Numeric] the timeout
67
+ # @example
68
+ # client.read_timeout # => 10
69
+ attr_reader :read_timeout
70
+
71
+ # The seconds to wait for a request to be sent
72
+ #
73
+ # Net::HTTP has no such timeout on Windows.
74
+ #
75
+ # @api public
76
+ # @return [Numeric] the timeout
77
+ # @example
78
+ # client.write_timeout # => 10
79
+ attr_reader :write_timeout
80
+
81
+ # The most redirects to follow for one request
82
+ # @api public
83
+ # @return [Integer] the limit, which is 0 to follow none
84
+ # @example
85
+ # client.max_redirects # => 10
86
+ attr_reader :max_redirects
87
+
88
+ # Initialize a new client
89
+ #
90
+ # Every option defaults to the global configuration (see {Configuration#configure}).
91
+ #
92
+ # @api public
93
+ # @param host [String] the host for API requests, including the scheme
94
+ # @param user_agent [String] the 'User-Agent' HTTP header sent with requests
95
+ # @param open_timeout [Numeric] the seconds to wait for a connection to open
96
+ # @param read_timeout [Numeric] the seconds to wait for a response
97
+ # @param write_timeout [Numeric] the seconds to wait for a request to be sent
98
+ # @param max_redirects [Integer] the most redirects to follow for one request
99
+ # @param cache [Boolean] whether to keep the responses to GET requests, as the client {#cached} returns does
100
+ # @return [Client] a new client
101
+ # @raise [ArgumentError] if an option isn't of the type it should be, the host isn't an http or https URL (or has
102
+ # credentials, a query, or a fragment), the user agent has a line break, a timeout isn't positive and finite,
103
+ # max_redirects is negative, or cache is neither true nor false
104
+ # @example Create a client for a local copy of the site
105
+ # client = Sferik::Client.new(host: "http://localhost:3745")
106
+ # @example Create a client that keeps the responses it gets
107
+ # client = Sferik::Client.new(cache: true)
108
+ def initialize(host: Sferik.host, user_agent: Sferik.user_agent, open_timeout: Sferik.open_timeout, read_timeout: Sferik.read_timeout,
109
+ write_timeout: Sferik.write_timeout, max_redirects: Sferik.max_redirects, cache: Sferik.cache)
110
+ @host = http_url(check(:host, host, String)).delete_suffix("/").freeze
111
+ @user_agent = one_line(:user_agent, check(:user_agent, user_agent, String)).dup.freeze
112
+ @open_timeout = seconds(:open_timeout, open_timeout)
113
+ @read_timeout = seconds(:read_timeout, read_timeout)
114
+ @write_timeout = seconds(:write_timeout, write_timeout)
115
+ @max_redirects = not_negative(:max_redirects, check(:max_redirects, max_redirects, Integer))
116
+ @connections = caching(cache)
117
+ freeze
118
+ end
119
+
120
+ # Perform a GET request and return the response body
121
+ #
122
+ # The path is always one on {#host}, even if it looks like a URL of its own. Redirects are followed, up to
123
+ # {#max_redirects}, to http and https URLs, but never from https to http.
124
+ #
125
+ # @api public
126
+ # @param path [String] the path, starting with a slash
127
+ # @param accept [String] the media type to ask for: application/json, text/plain, text/html, or, for the resume,
128
+ # application/x-latex or application/pdf
129
+ # @return [String] the response body, in the charset its Content-Type names: without one, text and JSON are UTF-8, and
130
+ # anything else is binary, as a PDF is
131
+ # @raise [ArgumentError] if the path or the media type isn't a String, or the media type has a line break
132
+ # @raise [InvalidURL] if the path can't be in a URL
133
+ # @raise [TooManyRedirects] if the request is redirected more than {#max_redirects} times
134
+ # @raise [NotAcceptable] if the resource has no representation of that type
135
+ # @raise [NotFound] if there is no resource at that path
136
+ # @raise [HTTPError] for any other response that isn't a success, or a redirect that isn't followed
137
+ # @raise [Unanswered] if the server was connected to, and its response didn't come, or can't be read
138
+ # @raise [NetworkError] if the server can't be connected to
139
+ # @example Get the bio as terminal output
140
+ # Sferik.client.get("/whoami", accept: "text/plain")
141
+ def get(path, accept: "application/json")
142
+ Body.of(got(uri_for(check(:path, path, String)), one_line(:accept, check(:accept, accept, String))))
143
+ end
144
+
145
+ # Perform a POST request and return the response body
146
+ #
147
+ # The path is always one on {#host}, as for {#get}, and may have a query. The body is sent as plain text, in
148
+ # UTF-8: one in another charset is converted, with a replacement character for anything that doesn't convert, and a
149
+ # binary or US-ASCII one is taken for UTF-8 already. A redirect isn't followed, and a request that times out isn't
150
+ # sent again, since it may have been taken: with an idempotency key, it's safe to send again yourself.
151
+ #
152
+ # @api public
153
+ # @param path [String] the path, starting with a slash, with any query
154
+ # @param body [String] the body of the request, as plain text (defaults to none)
155
+ # @param accept [String] the media type to ask for: application/json or text/plain
156
+ # @param idempotency_key [String, nil] a random key, one per message, of 16 to 64 letters, digits, underscores, and
157
+ # hyphens: /write takes the same key again for the same message, and doesn't send it twice
158
+ # @return [String] the response body, in the charset its Content-Type names: without one, text and JSON are UTF-8, and
159
+ # anything else is binary
160
+ # @raise [ArgumentError] if the path, the body, the media type, or the key isn't a String, the media type or the key
161
+ # has a line break, or the body is in a charset that doesn't convert to UTF-8, like UTF-7
162
+ # @raise [InvalidURL] if the path can't be in a URL
163
+ # @raise [NotFound] if there is no resource at that path
164
+ # @raise [TooManyRequests] if the server has taken too many requests
165
+ # @raise [HTTPError] for any other response that isn't a success, including a redirect
166
+ # @raise [Unanswered] if the server was connected to, and its response didn't come, or can't be read
167
+ # @raise [NetworkError] if the server can't be connected to
168
+ # @example Check in a terminal, and get the response as it is
169
+ # Sferik.client.post("/who?token=0123456789abcdef&page=/")
170
+ def post(path, body = "", accept: "application/json", idempotency_key: nil)
171
+ request = Net::HTTP::Post.new(uri_for(check(:path, path, String)), post_headers(accept, idempotency_key))
172
+ request.body = utf8(check(:body, body, String))
173
+ Body.of(success(connections.request(request)))
174
+ end
175
+
176
+ # Make the requests in a block over connections that are closed when it ends
177
+ #
178
+ # A client makes each thread's requests to a host over one connection, which saves connecting again (with https,
179
+ # most of the time a request takes), and leaves it open for the thread's next: it's closed when the thread is
180
+ # collected, or the process ends, or by {#close}. The client this yields opens connections of its own instead, one per host, and
181
+ # closes them when the block ends, for when one mustn't be left open. Either way, Net::HTTP opens one again that
182
+ # has sat unused for more than half a minute, or that the server has closed by then. The client yielded is for
183
+ # one thread at a time, as a connection is, and after the block it's a client like any other.
184
+ #
185
+ # @api public
186
+ # @yield [client] the requests to make
187
+ # @yieldparam client [Client] a client with the same options, and connections of its own
188
+ # @yieldreturn [Object] anything
189
+ # @return [Object] what the block returns
190
+ # @raise [ArgumentError] if no block is given
191
+ # @example Get the bio, the talks, and the resume over one connection, and close it
192
+ # Sferik.client.keep_alive { |client| [client.whoami, client.talks, client.resume] }
193
+ def keep_alive
194
+ raise ArgumentError, "keep_alive must be given a block" unless block_given?
195
+
196
+ connections.keeping { |kept| yield dup.keep(kept) }
197
+ end
198
+
199
+ # Close the connections this thread has open
200
+ #
201
+ # A thread's requests are made over connections that are left open for its next, and closed when the thread is
202
+ # collected, or the process ends. This closes them now: before a fork, say, or when a long-lived process is done
203
+ # with the site for a while. The next request opens one again. They're the thread's, not this client's alone: any
204
+ # client's requests on this thread were made over them. On the client {#keep_alive} yields, it's that block's
205
+ # connections that are closed. What a {#cached} client has kept of the responses stays kept.
206
+ #
207
+ # @api public
208
+ # @return [nil]
209
+ # @example Close the connection before forking, so the child opens its own
210
+ # Sferik.whoami
211
+ # Sferik.client.close
212
+ # fork { Sferik.talks }
213
+ def close = connections.close
214
+
215
+ # A client that keeps the responses to its GET requests
216
+ #
217
+ # It asks again only for what may have changed. A response says how long it's good for (most of the API's, an
218
+ # hour, and five minutes for what has live numbers in it), and for that long the client answers with it, without
219
+ # a request. After that it asks with the response's ETag, and the server sends the body only if it has changed.
220
+ # What's kept is by URL and media type, in memory, for as long as the client is (a hundred responses at most, the
221
+ # latest it asked for), and is safe to share between threads: keep the client, since each call of this starts
222
+ # with nothing kept. Threads that ask for the same thing at once, when it isn't kept or is no longer good, make
223
+ # one request between them: the first asks, and the rest wait for its answer.
224
+ #
225
+ # What an endpoint builds of a response is kept with it, so for as long as a response is answered with, the
226
+ # endpoint returns the same object, and the JSON isn't parsed again.
227
+ #
228
+ # A response that Cloudflare's cache answered with has been kept there for a while already, which it says (Age),
229
+ # and is good for that much less here. That cache answers with what it has, when that's no longer good, while
230
+ # it builds another: so each request this client makes says not to be answered that way (Cache-Control:
231
+ # no-cache), and the site has it wait for the new one. What's still good there is its answer all the same.
232
+ #
233
+ # When the server can't be reached, or doesn't answer, to say whether a response that's no longer good has
234
+ # changed, the request fails with a {NetworkError}, as any other would, and when it answers with an error of its
235
+ # own (a 5xx), with a {ServerError}. The response stays kept either way, to be asked after again. With
236
+ # stale_if_error, the client answers with the response it kept instead, however old: for a script that would
237
+ # rather go on with what it last knew.
238
+ #
239
+ # On a client that keeps its responses already, this is a cache of its own all the same, with nothing kept, over
240
+ # that client's connections: not one over the other's, which would take a response the other had kept a while
241
+ # for one that had just come.
242
+ #
243
+ # @api public
244
+ # @param stale_if_error [Boolean] whether to answer with a response that's no longer good when the server can't
245
+ # be reached, doesn't answer, or answers with an error of its own
246
+ # @return [Client] a client with the same options, and a cache of its own
247
+ # @raise [ArgumentError] if stale_if_error is neither true nor false
248
+ # @example Ask who's reading the site every second, which asks the server every five
249
+ # client = Sferik.client.cached
250
+ # loop { puts client.who.size; sleep 1 }
251
+ # @example Go on with the last answer when the network is down, or the server is
252
+ # client = Sferik.client.cached(stale_if_error: true)
253
+ def cached(stale_if_error: false) = dup.keep(Cache.new(connections.uncached, stale: boolean(:stale_if_error, stale_if_error)))
254
+
255
+ # Whether the client keeps the responses to its GET requests
256
+ #
257
+ # @api public
258
+ # @return [Boolean] true for a client built with cache: true, or returned by {#cached}
259
+ # @example
260
+ # Sferik.client.cached.cache? # => true
261
+ def cache? = @connections.instance_of?(Cache)
262
+
263
+ # A short description of the client, without the user agent
264
+ #
265
+ # @api public
266
+ # @return [String] the description
267
+ # @example
268
+ # client.inspect # => "#<Sferik::Client https://sferik.net>"
269
+ def inspect = "#<#{self.class} #{host}>"
270
+
271
+ # Perform a GET request, and return what a block makes of the JSON that comes back
272
+ #
273
+ # It's what the endpoints of {API} build their resources with. A {#cached} client answers with the response it
274
+ # kept for as long as it's good, and with what was made of it too: the JSON isn't parsed again, nor the resource
275
+ # built, until the response is another. Everything an endpoint returns is frozen all the way down, so it's safe
276
+ # for every caller to have the same one.
277
+ #
278
+ # @api private
279
+ # @param path [String] the path, starting with a slash, with any query
280
+ # @param accept [String] the media type to ask for, which is JSON of some kind
281
+ # @yield [attributes] what to make of the response
282
+ # @yieldparam attributes [Hash{String => Object}] the parsed JSON, deep-frozen
283
+ # @yieldreturn [Object] what's made of it, which can't be changed
284
+ # @return [Object] what the block returned: this time, or the first time for this response
285
+ # @raise [InvalidResponse] if the response isn't a JSON object
286
+ # @raise [Error] if the request fails, as {#get} raises
287
+ def json(path, accept: "application/json")
288
+ uri = uri_for(path)
289
+ response = got(uri, accept)
290
+ connections.made(uri, accept, response) { yield parse_json(Body.of(response)) }
291
+ end
292
+
293
+ protected
294
+
295
+ # Make this client's requests over other connections, and freeze it again
296
+ #
297
+ # @api private
298
+ # @param connections [Connections, Cache] the connections, or a cache over them
299
+ # @return [Client] the client itself
300
+ def keep(connections) = tap { @connections = connections }.freeze
301
+
302
+ private
303
+
304
+ # Send a GET request, following redirects, for a response that's a success
305
+ #
306
+ # @api private
307
+ # @param uri [URI::HTTP] the URL
308
+ # @param accept [String] the media type to ask for
309
+ # @return [Net::HTTPResponse] the response
310
+ # @raise [HTTPError] if the response isn't a success
311
+ def got(uri, accept) = success(fetch(uri, accept, max_redirects))
312
+
313
+ # A response that's a success, or else its error
314
+ #
315
+ # @api private
316
+ # @param response [Net::HTTPResponse] the response
317
+ # @return [Net::HTTPResponse] the response
318
+ # @raise [HTTPError] if the response isn't a success
319
+ def success(response) = response.is_a?(Net::HTTPSuccess) ? response : raise(error_for(response))
320
+
321
+ # What keeps the responses of a client that's built to keep them
322
+ #
323
+ # @api private
324
+ # @param cache [Object] whether to keep them
325
+ # @return [Cache, nil] a cache over the connections a thread keeps open, or nil for a client that asks each time
326
+ # @raise [ArgumentError] if cache is neither true nor false
327
+ def caching(cache) = (Cache.new(connections) if boolean(:cache, cache))
328
+
329
+ # The connections requests are made over
330
+ #
331
+ # Those {#keep_alive} gave this client, which it closes, or else ones the thread keeps open.
332
+ #
333
+ # @api private
334
+ # @return [Connections] the connections
335
+ def connections = @connections || Connections.new({open_timeout:, read_timeout:, write_timeout:})
336
+
337
+ # The URL of a path on the host
338
+ #
339
+ # @api private
340
+ # @param path [String] the path
341
+ # @return [URI::HTTP] the URL
342
+ # @raise [InvalidURL] if the path can't be in a URL
343
+ def uri_for(path)
344
+ url = "#{host}/#{path.delete_prefix("/")}"
345
+ URI.parse(url)
346
+ rescue URI::InvalidURIError
347
+ raise InvalidURL, "#{url.inspect} isn't a valid URL"
348
+ end
349
+
350
+ # Send a GET request, following redirects
351
+ #
352
+ # @api private
353
+ # @param uri [URI::HTTP] the URL
354
+ # @param accept [String] the media type to ask for
355
+ # @param redirects [Integer] the redirects left to follow
356
+ # @return [Net::HTTPResponse] the last response
357
+ # @raise [TooManyRedirects] if there's another redirect when none are left
358
+ def fetch(uri, accept, redirects)
359
+ response = connections.request(Net::HTTP::Get.new(uri, headers(accept)))
360
+ location = redirect(response, uri)
361
+ return response unless location
362
+ raise TooManyRedirects, "More than #{max_redirects} redirects (GET #{uri})" unless redirects.positive?
363
+
364
+ fetch(location, accept, redirects - 1)
365
+ end
366
+
367
+ # Where a response redirects to
368
+ #
369
+ # An http or https URL, resolved against the request's: only an https one, if the request's is. One without a
370
+ # host, like "https:/whoami", is nowhere to go, as one that isn't a URL is.
371
+ #
372
+ # @api private
373
+ # @param response [Net::HTTPResponse] the response
374
+ # @param uri [URI::HTTP] the request's URL
375
+ # @return [URI::HTTP, nil] the URL, or nil if the response isn't a redirect, or doesn't say where to
376
+ # @raise [HTTPError] if the redirect is to a URL that isn't followed: from https to http, or to another scheme
377
+ def redirect(response, uri)
378
+ return unless response.is_a?(Net::HTTPRedirection)
379
+
380
+ location = response["location"]
381
+ return unless location
382
+
383
+ target = URI.join(uri, location)
384
+ # URI::HTTPS is a URI::HTTP, so http may go to https, but not https to http
385
+ raise error_for(response, "Refused to follow a redirect from #{uri} to #{target}") unless target.is_a?(uri.class)
386
+
387
+ target unless target.host.to_s.empty?
388
+ rescue URI::InvalidURIError
389
+ nil
390
+ end
391
+
392
+ # The body of a request in UTF-8, which is the charset it's sent as
393
+ #
394
+ # A binary body is taken for UTF-8 already, and so is a US-ASCII one: what Ruby reads with no locale set, as in
395
+ # cron or a container, is labeled US-ASCII whatever its bytes are, and US-ASCII that is valid is UTF-8 as it is.
396
+ # Bytes that aren't valid in the body's charset, and characters UTF-8 doesn't have, are replaced.
397
+ #
398
+ # @api private
399
+ # @param body [String] the body
400
+ # @return [String] the body, in UTF-8, or as it is if it's binary or US-ASCII
401
+ # @raise [ArgumentError] if the body's charset is one nothing can be converted from, like UTF-7
402
+ def utf8(body)
403
+ return body if UNLABELED.include?(body.encoding)
404
+
405
+ body.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
406
+ rescue Encoding::ConverterNotFoundError
407
+ raise ArgumentError, "body must be in a charset that converts to UTF-8, not #{body.encoding}"
408
+ end
409
+
410
+ # The headers of every request
411
+ #
412
+ # @api private
413
+ # @param accept [String] the media type to ask for
414
+ # @return [Hash{String => String}] the headers
415
+ def headers(accept) = {"Accept" => accept, "User-Agent" => user_agent}
416
+
417
+ # The headers of a POST request
418
+ #
419
+ # @api private
420
+ # @param accept [Object] the media type to ask for
421
+ # @param key [Object] the idempotency key, or nil for none
422
+ # @return [Hash{String => String}] the headers
423
+ # @raise [ArgumentError] if the media type or the key isn't a String, or has a line break
424
+ def post_headers(accept, key)
425
+ one_line(:idempotency_key, check(:idempotency_key, key, String)) unless key.nil?
426
+ headers(one_line(:accept, check(:accept, accept, String))).merge({"Content-Type" => PLAIN_TEXT, "Idempotency-Key" => key}.compact)
427
+ end
428
+
429
+ # The error for a response that isn't a success
430
+ #
431
+ # @api private
432
+ # @param response [Net::HTTPResponse] the response
433
+ # @param message [String, nil] a message to use instead of what the response says
434
+ # @return [HTTPError] the error
435
+ def error_for(response, message = nil) = error_class(response).new(message, code: Integer(response.code), reason: response.message, headers: response.each_header.to_h, body: Body.of(response))
436
+
437
+ # The class of error for a response that isn't a success
438
+ #
439
+ # @api private
440
+ # @param response [Net::HTTPResponse] the response
441
+ # @return [Class] the class
442
+ def error_class(response)
443
+ case response
444
+ when Net::HTTPNotFound then NotFound
445
+ when Net::HTTPNotAcceptable then NotAcceptable
446
+ when Net::HTTPTooManyRequests then TooManyRequests
447
+ when Net::HTTPClientError then ClientError
448
+ when Net::HTTPServerError then ServerError
449
+ else HTTPError
450
+ end
451
+ end
452
+ end
453
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sferik
4
+ # What an Array has that Enumerable doesn't, for a resource that's Enumerable over a list: {Projects}, {Talks}, and {Who}
5
+ #
6
+ # @api public
7
+ module Collection
8
+ # How many there are
9
+ #
10
+ # @api public
11
+ # @return [Integer] how many there are
12
+ # @example
13
+ # Sferik.talks.size # => 18
14
+ def size
15
+ to_a.size
16
+ end
17
+ alias_method :length, :size
18
+
19
+ # Whether there are none
20
+ #
21
+ # @api public
22
+ # @return [Boolean]
23
+ # @example
24
+ # Sferik.talks.empty? # => false
25
+ def empty?
26
+ to_a.empty?
27
+ end
28
+
29
+ # The last one, or the last few
30
+ #
31
+ # @api public
32
+ # @param count [Array<Integer>] how many, for more than one
33
+ # @return [Object, Array<Object>, nil] the last one (nil if there are none), or as many as asked for
34
+ # @example
35
+ # Sferik.talks.last # => #<Sferik::Talk ...>, the oldest
36
+ # Sferik.talks.last(3) # => [#<Sferik::Talk ...>, ...]
37
+ def last(*count) # steep:ignore DifferentMethodParameterKind
38
+ to_a.last(*count) # steep:ignore UnresolvedOverloading
39
+ end
40
+
41
+ # The one at an index, or those in a range, as Array#[] gives them
42
+ #
43
+ # @api public
44
+ # @param index [Array<Integer, Range>] an index, a start and a length, or a range
45
+ # @return [Object, Array<Object>, nil] the one at the index, or those in the range: nil for one out of range
46
+ # @example
47
+ # Sferik.talks[0] # => #<Sferik::Talk ...>, the newest
48
+ # Sferik.talks[0, 3] # => [#<Sferik::Talk ...>, ...]
49
+ def [](*index) # steep:ignore DifferentMethodParameterKind
50
+ to_a[*index] # steep:ignore UnresolvedOverloading
51
+ end
52
+
53
+ # What the readers return, by name, or with a block, a Hash made of each one
54
+ #
55
+ # Enumerable's #to_h makes a Hash of the list, which a resource's has no use for: its own is of its readers. But
56
+ # with a block it's the list that's meant, as it is for anything else that's Enumerable.
57
+ #
58
+ # @api public
59
+ # @yield [item] each one, to make a key and a value of
60
+ # @yieldparam item [Object] one of them
61
+ # @yieldreturn [Array<(Object, Object)>] its key and its value
62
+ # @return [Hash] the values of the readers by name, or with a block, the values it makes by the keys it makes
63
+ # @example
64
+ # Sferik.projects.to_h # => {projects: [...], total_downloads: 5_460_234_129, ...}
65
+ # Sferik.projects.to_h { |project| [project.name, project.stars] } # => {"multi_json" => 27, ...}
66
+ def to_h(&)
67
+ block_given? ? to_a.to_h(&) : deconstruct_keys(nil)
68
+ end
69
+
70
+ # All of them, for pattern matching against an array pattern
71
+ #
72
+ # @api public
73
+ # @return [Array<Object>] all of them, in order
74
+ # @example
75
+ # case Sferik.talks
76
+ # in [newest, *, oldest] then "#{oldest.title} to #{newest.title}"
77
+ # end
78
+ def deconstruct
79
+ to_a
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module Sferik
6
+ # The global configuration, which {Sferik.client} is built from
7
+ #
8
+ # @api public
9
+ # @example Configure the library
10
+ # Sferik.configure do |config|
11
+ # config.host = "http://localhost:3745"
12
+ # config.read_timeout = 30
13
+ # end
14
+ module Configuration
15
+ # The settings, and what each is until it is assigned
16
+ DEFAULTS = {
17
+ host: "https://sferik.net",
18
+ user_agent: "sferik/#{VERSION} (#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}; +https://rubygems.org/gems/sferik)".freeze,
19
+ open_timeout: 5,
20
+ read_timeout: 10,
21
+ write_timeout: 10,
22
+ max_redirects: 10,
23
+ cache: false
24
+ }.freeze
25
+ private_constant :DEFAULTS
26
+
27
+ # @!attribute host
28
+ # The host for API requests, including the scheme
29
+ # @api public
30
+ # @return [String] the host (defaults to "https://sferik.net")
31
+ # @example
32
+ # Sferik.host = "http://localhost:3745"
33
+ # @!attribute user_agent
34
+ # The 'User-Agent' HTTP header sent with requests
35
+ # @api public
36
+ # @return [String] the user agent
37
+ # @example
38
+ # Sferik.user_agent = "my-app/1.0"
39
+ # @!attribute open_timeout
40
+ # The seconds to wait for a connection to open
41
+ # @api public
42
+ # @return [Numeric] the timeout, which must be positive and finite (defaults to 5)
43
+ # @example
44
+ # Sferik.open_timeout = 2
45
+ # @!attribute read_timeout
46
+ # The seconds to wait for a response
47
+ #
48
+ # Net::HTTP sends a request that times out once more, so a response that never comes takes twice this long to fail.
49
+ #
50
+ # @api public
51
+ # @return [Numeric] the timeout, which must be positive and finite (defaults to 10)
52
+ # @example
53
+ # Sferik.read_timeout = 30
54
+ # @!attribute write_timeout
55
+ # The seconds to wait for a request to be sent
56
+ #
57
+ # It matters for a POST, whose body is the message of {API::SiteEndpoints#write}. Net::HTTP has no such timeout on Windows.
58
+ #
59
+ # @api public
60
+ # @return [Numeric] the timeout, which must be positive and finite (defaults to 10)
61
+ # @example
62
+ # Sferik.write_timeout = 30
63
+ # @!attribute max_redirects
64
+ # The most redirects to follow for one request
65
+ # @api public
66
+ # @return [Integer] the limit, which must not be negative (defaults to 10)
67
+ # @example
68
+ # Sferik.max_redirects = 0 # don't follow any
69
+ # @!attribute cache
70
+ # Whether to keep the responses to GET requests
71
+ #
72
+ # With it, {Sferik.client} is a client that keeps what it gets, as {Client#cached} returns: Sferik.who asks the
73
+ # server once in five seconds, however often it's called. What's kept is forgotten when a setting changes, since
74
+ # the client is built again.
75
+ #
76
+ # @api public
77
+ # @return [Boolean] whether to (defaults to false)
78
+ # @example
79
+ # Sferik.cache = true
80
+ DEFAULTS.each_key { |setting| attr_accessor setting } # one at a time: YARD can't read the names of a splat
81
+
82
+ # Start what this module extends at the defaults
83
+ #
84
+ # @api private
85
+ # @param base [Module] the module being extended
86
+ # @return [Module] the module, reset
87
+ # @example
88
+ # Sferik.extend(Sferik::Configuration)
89
+ def self.extended(base)
90
+ base.reset
91
+ end
92
+
93
+ # Change the configuration in a block
94
+ #
95
+ # @api public
96
+ # @yield [config] the configuration
97
+ # @return [self]
98
+ # @raise [ArgumentError] if no block is given
99
+ # @example
100
+ # Sferik.configure { |config| config.host = "http://localhost:3745" }
101
+ def configure
102
+ raise ArgumentError, "configure must be given a block" unless block_given?
103
+
104
+ yield self
105
+ self
106
+ end
107
+
108
+ # Every setting, as the options of {Client#initialize}
109
+ #
110
+ # @api public
111
+ # @return [Hash{Symbol => Object}] the settings
112
+ # @example
113
+ # Sferik.options # => {host: "https://sferik.net", ...}
114
+ def options
115
+ DEFAULTS.to_h { |setting, _| [setting, public_send(setting)] }
116
+ end
117
+
118
+ # Put every setting back to its default
119
+ #
120
+ # @api public
121
+ # @return [self]
122
+ # @example
123
+ # Sferik.reset
124
+ def reset
125
+ DEFAULTS.each { |setting, value| instance_variable_set(:"@#{setting}", value) }
126
+ self
127
+ end
128
+ end
129
+ end