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