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,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Sferik
|
|
9
|
+
# The connections a client makes its requests over
|
|
10
|
+
#
|
|
11
|
+
# Each one opened is kept, and the next request to the same scheme, host, and port is made over it, which saves
|
|
12
|
+
# connecting again: with https, most of the time a request takes. A client's own are kept by the thread that makes
|
|
13
|
+
# the request (or the fiber, which is as far as Thread.current goes), since a connection is for one at a time, and
|
|
14
|
+
# by the process, since one that a fork inherits is its parent's. They're never closed here: Net::HTTP opens one
|
|
15
|
+
# again that has sat unused for more than half a minute, or that the server has closed by then, and what a thread
|
|
16
|
+
# leaves behind is closed when it's collected, unless {Client#close} closes them first. Those of
|
|
17
|
+
# {Client#keep_alive} are kept for its block, and closed after.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
class Connections
|
|
21
|
+
# The errors Net::HTTP raises when the server can't be reached, or its response can't be read
|
|
22
|
+
NETWORK_ERRORS = [IOError, SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, Net::HTTPBadResponse,
|
|
23
|
+
Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error].freeze
|
|
24
|
+
private_constant :NETWORK_ERRORS
|
|
25
|
+
|
|
26
|
+
# The seconds a connection may sit unused, and still be the one the next request is made over
|
|
27
|
+
#
|
|
28
|
+
# Net::HTTP's own two would open one again for anything that asks every few seconds, as a {Client#cached} client
|
|
29
|
+
# asking who's reading does, every five. Cloudflare leaves one open for minutes, and Net::HTTP looks whether the
|
|
30
|
+
# server has closed one before it uses it, whatever this says.
|
|
31
|
+
KEEP_ALIVE = 30
|
|
32
|
+
private_constant :KEEP_ALIVE
|
|
33
|
+
|
|
34
|
+
# Where a thread keeps the connections it has opened
|
|
35
|
+
OPENED = :sferik_connections
|
|
36
|
+
private_constant :OPENED
|
|
37
|
+
|
|
38
|
+
# Initialize the connections of a client
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
# @param timeouts [Hash{Symbol => Numeric}] the seconds to wait: open_timeout, read_timeout, and write_timeout
|
|
42
|
+
# @param kept [Hash{Array => Net::HTTP}, nil] where to keep the connections opened, or nil for the thread to
|
|
43
|
+
# @return [Connections] the connections
|
|
44
|
+
def initialize(timeouts, kept = nil)
|
|
45
|
+
@timeouts = timeouts
|
|
46
|
+
@kept = kept
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Yield connections of their own, and close them afterwards
|
|
50
|
+
#
|
|
51
|
+
# They have the same timeouts as these.
|
|
52
|
+
#
|
|
53
|
+
# @api private
|
|
54
|
+
# @param kept [Hash{Array => Net::HTTP}] where to keep the connections opened
|
|
55
|
+
# @yield [connections] the requests to make
|
|
56
|
+
# @yieldparam connections [Connections] connections that are closed after the block
|
|
57
|
+
# @yieldreturn [Object] anything
|
|
58
|
+
# @return [Object] what the block returns
|
|
59
|
+
def keeping(kept = {})
|
|
60
|
+
yield self.class.new(@timeouts, kept)
|
|
61
|
+
ensure
|
|
62
|
+
kept.each_value(&:finish)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Send a request
|
|
66
|
+
#
|
|
67
|
+
# @api private
|
|
68
|
+
# @param request [Net::HTTPRequest] the request, to a URL
|
|
69
|
+
# @return [Net::HTTPResponse] the response
|
|
70
|
+
# @raise [Unanswered] if the server was connected to, and its response didn't come, or can't be read
|
|
71
|
+
# @raise [NetworkError] if the server can't be connected to
|
|
72
|
+
def request(request)
|
|
73
|
+
http = connection(request.uri)
|
|
74
|
+
http.request(request)
|
|
75
|
+
rescue *NETWORK_ERRORS => e
|
|
76
|
+
raise (http ? Unanswered : NetworkError), "#{e.class}: #{e} (#{request.method} #{request.uri})"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# What a block makes of a response, made each time
|
|
80
|
+
#
|
|
81
|
+
# Only a {Cache} keeps what's made.
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
# @param _uri [URI::HTTP] the URL that was asked for
|
|
85
|
+
# @param _accept [String] the media type it was asked for as
|
|
86
|
+
# @param _response [Net::HTTPResponse] the response that came
|
|
87
|
+
# @yield what to make of the response
|
|
88
|
+
# @yieldreturn [Object] what's made of it
|
|
89
|
+
# @return [Object] what the block returned
|
|
90
|
+
def made(_uri, _accept, _response)
|
|
91
|
+
yield
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Close the connections that are kept, and keep none of them
|
|
95
|
+
#
|
|
96
|
+
# One that another process opened, which a fork inherits, is only let go of: closing it here would close it for
|
|
97
|
+
# the process whose it is.
|
|
98
|
+
#
|
|
99
|
+
# @api private
|
|
100
|
+
# @return [nil]
|
|
101
|
+
def close
|
|
102
|
+
kept.each { |key, http| http.finish if key.first.eql?(Process.pid) }
|
|
103
|
+
kept.clear
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The connections themselves, which keep no responses
|
|
108
|
+
#
|
|
109
|
+
# A {Cache} has the ones it's over instead, for another cache to be built over.
|
|
110
|
+
#
|
|
111
|
+
# @api private
|
|
112
|
+
# @return [Connections] the connections
|
|
113
|
+
def uncached = self
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# The connection to the host of a URL
|
|
118
|
+
#
|
|
119
|
+
# It's the one kept for that scheme, host, and port, by this process, with these timeouts, opened if there's none
|
|
120
|
+
# yet, and left open.
|
|
121
|
+
#
|
|
122
|
+
# @api private
|
|
123
|
+
# @param uri [URI::HTTP] the URL
|
|
124
|
+
# @return [Net::HTTP] the connection
|
|
125
|
+
def connection(uri)
|
|
126
|
+
kept[[Process.pid, uri.scheme, uri.hostname, uri.port, @timeouts]] ||= start(uri)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Where the connections opened are kept
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
# @return [Hash{Array => Net::HTTP}] where these were given to keep theirs, or else where the thread keeps its own
|
|
133
|
+
def kept
|
|
134
|
+
given = @kept
|
|
135
|
+
return given if given
|
|
136
|
+
|
|
137
|
+
Thread.current[OPENED] ||= {}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Open a connection to the host of a URL
|
|
141
|
+
#
|
|
142
|
+
# @api private
|
|
143
|
+
# @param uri [URI::HTTP] the URL
|
|
144
|
+
# @return [Net::HTTP] the connection, left open
|
|
145
|
+
def start(uri)
|
|
146
|
+
hostname = uri.hostname #: String
|
|
147
|
+
Net::HTTP.start(hostname, uri.port, use_ssl: uri.scheme.eql?("https"), keep_alive_timeout: KEEP_ALIVE, **@timeouts)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
private_constant :Connections
|
|
151
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
require_relative "day"
|
|
5
|
+
require_relative "push"
|
|
6
|
+
|
|
7
|
+
module Sferik
|
|
8
|
+
# A year of GitHub contributions, as returned by {API::CodeEndpoints#contributions}
|
|
9
|
+
# @api public
|
|
10
|
+
class Contributions < Resource
|
|
11
|
+
# @!method total
|
|
12
|
+
# The contributions in the last year
|
|
13
|
+
# @api public
|
|
14
|
+
# @return [Integer] the contributions in the last year
|
|
15
|
+
# @example
|
|
16
|
+
# contributions.total # => 5242
|
|
17
|
+
attribute :total
|
|
18
|
+
|
|
19
|
+
# @!method longest_streak
|
|
20
|
+
# The longest run of days with contributions, in days
|
|
21
|
+
# @api public
|
|
22
|
+
# @return [Integer] the longest run of days with contributions, in days
|
|
23
|
+
# @example
|
|
24
|
+
# contributions.longest_streak # => 30
|
|
25
|
+
attribute :longest_streak
|
|
26
|
+
|
|
27
|
+
# @!method days
|
|
28
|
+
# One entry per day, oldest first
|
|
29
|
+
# @api public
|
|
30
|
+
# @return [Array<Day>] one entry per day, oldest first
|
|
31
|
+
# @example
|
|
32
|
+
# contributions.days # => [#<Sferik::Day ...>, ...]
|
|
33
|
+
list :days, "contributions", type: Day
|
|
34
|
+
|
|
35
|
+
# @!method since
|
|
36
|
+
# The year the GitHub account was created
|
|
37
|
+
# @api public
|
|
38
|
+
# @return [Integer] the year the GitHub account was created
|
|
39
|
+
# @example
|
|
40
|
+
# contributions.since # => 2008
|
|
41
|
+
attribute :since
|
|
42
|
+
|
|
43
|
+
# @!method last_push
|
|
44
|
+
# The latest public push, or nil if none is recent
|
|
45
|
+
# @api public
|
|
46
|
+
# @return [Push, nil] the latest public push, or nil if none is recent
|
|
47
|
+
# @example
|
|
48
|
+
# contributions.last_push # => #<Sferik::Push ...>
|
|
49
|
+
attribute :last_push, "lastPush", type: Push
|
|
50
|
+
|
|
51
|
+
# @!method live?
|
|
52
|
+
# Whether the numbers are what GitHub says now
|
|
53
|
+
#
|
|
54
|
+
# They aren't when they're the snapshot, which the site falls back to, or the last that were fetched, more than
|
|
55
|
+
# two hours ago, which it goes on with when it can't fetch them again: {#as_of} says when they're from.
|
|
56
|
+
#
|
|
57
|
+
# @api public
|
|
58
|
+
# @return [Boolean] false when the numbers are a snapshot, or were last fetched more than two hours ago
|
|
59
|
+
# @example
|
|
60
|
+
# contributions.live? # => true
|
|
61
|
+
predicate :live
|
|
62
|
+
|
|
63
|
+
# @!method as_of
|
|
64
|
+
# When the numbers are from
|
|
65
|
+
#
|
|
66
|
+
# That's the hour they were fetched in, or the last day of the snapshot, if they're that. It's to the hour so
|
|
67
|
+
# that numbers that are fetched again, and haven't changed, are the same response, which a {Client#cached}
|
|
68
|
+
# client isn't sent again: {Status::Loaded#contributions} is when they were fetched, to the second.
|
|
69
|
+
#
|
|
70
|
+
# @api public
|
|
71
|
+
# @return [Time] when the numbers are from
|
|
72
|
+
# @example
|
|
73
|
+
# contributions.as_of # => 2026-10-08 01:00:00 UTC
|
|
74
|
+
timestamp :as_of, Time
|
|
75
|
+
|
|
76
|
+
# @!method command
|
|
77
|
+
# The shell command the home page shows it as
|
|
78
|
+
# @api public
|
|
79
|
+
# @return [String] the shell command the home page shows it as
|
|
80
|
+
# @example
|
|
81
|
+
# contributions.command # => "git log --author=sferik --since=1.year --graph"
|
|
82
|
+
attribute :command
|
|
83
|
+
|
|
84
|
+
inspect_with :total, :longest_streak, :since
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/sferik/day.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Sferik
|
|
6
|
+
# A day of GitHub contributions, as returned in {Contributions#days}
|
|
7
|
+
# @api public
|
|
8
|
+
class Day < Resource
|
|
9
|
+
# @!method date
|
|
10
|
+
# The day
|
|
11
|
+
# @api public
|
|
12
|
+
# @return [Date] the day
|
|
13
|
+
# @example
|
|
14
|
+
# day.date # => #<Date: 2026-10-01>
|
|
15
|
+
timestamp :date, Date
|
|
16
|
+
|
|
17
|
+
# @!method count
|
|
18
|
+
# The contributions that day
|
|
19
|
+
# @api public
|
|
20
|
+
# @return [Integer] the contributions that day
|
|
21
|
+
# @example
|
|
22
|
+
# day.count # => 12
|
|
23
|
+
attribute :count
|
|
24
|
+
|
|
25
|
+
# @!method level
|
|
26
|
+
# The shade in the graph, from 0 (none) to 4 (most)
|
|
27
|
+
#
|
|
28
|
+
# 1 to 4 are the quartiles of the year's days with any contributions.
|
|
29
|
+
# @api public
|
|
30
|
+
# @return [Integer] the shade in the graph, from 0 (none) to 4 (most)
|
|
31
|
+
# @example
|
|
32
|
+
# day.level # => 3
|
|
33
|
+
attribute :level
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
require_relative "figure"
|
|
5
|
+
|
|
6
|
+
module Sferik
|
|
7
|
+
# The xkcd comic on the home page, as returned by {API::ProfileEndpoints#dependency}
|
|
8
|
+
# @api public
|
|
9
|
+
class Dependency < Resource
|
|
10
|
+
# @!method command
|
|
11
|
+
# The shell command the home page shows it as
|
|
12
|
+
# @api public
|
|
13
|
+
# @return [String] the shell command the home page shows it as
|
|
14
|
+
# @example
|
|
15
|
+
# dependency.command # => "imgcat ~/dependency.webp"
|
|
16
|
+
attribute :command
|
|
17
|
+
|
|
18
|
+
# @!method figure
|
|
19
|
+
# The comic, and what it shows in words
|
|
20
|
+
# @api public
|
|
21
|
+
# @return [Figure] the comic, and what it shows in words
|
|
22
|
+
# @example
|
|
23
|
+
# dependency.figure # => #<Sferik::Figure href="https://xkcd.com/2347/" src="/img/dependency.webp">
|
|
24
|
+
attribute :figure, "figure", type: Figure
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Sferik
|
|
6
|
+
# The commit of the site that's deployed, as returned by {API::SiteEndpoints#deployment}
|
|
7
|
+
#
|
|
8
|
+
# A copy of the site that wasn't deployed, like one run by hand, has nil for each.
|
|
9
|
+
#
|
|
10
|
+
# @api public
|
|
11
|
+
class Deployment < Resource
|
|
12
|
+
# @!method commit
|
|
13
|
+
# The SHA of the commit
|
|
14
|
+
# @api public
|
|
15
|
+
# @return [String, nil] the SHA of the commit
|
|
16
|
+
# @example
|
|
17
|
+
# deployment.commit # => "6a34226a3f351a78339b75430055a018ac30c964"
|
|
18
|
+
attribute :commit
|
|
19
|
+
|
|
20
|
+
# @!method deployed
|
|
21
|
+
# When it was deployed
|
|
22
|
+
# @api public
|
|
23
|
+
# @return [Time, nil] when it was deployed
|
|
24
|
+
# @example
|
|
25
|
+
# deployment.deployed # => 2026-10-07 18:04:11 UTC
|
|
26
|
+
timestamp :deployed, Time
|
|
27
|
+
|
|
28
|
+
# @!method url
|
|
29
|
+
# The commit, on GitHub
|
|
30
|
+
# @api public
|
|
31
|
+
# @return [String, nil] the commit, on GitHub
|
|
32
|
+
# @example
|
|
33
|
+
# deployment.url # => "https://github.com/sferik/sferik-web/commit/6a34226a3f351a78339b75430055a018ac30c964"
|
|
34
|
+
attribute :url
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "net/http/status" # the reason phrases, which net/http itself doesn't load
|
|
6
|
+
|
|
7
|
+
module Sferik
|
|
8
|
+
# Base error class for all Sferik errors
|
|
9
|
+
# @api public
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Raised when a path can't be in a URL: one with a space, say
|
|
13
|
+
# @api public
|
|
14
|
+
class InvalidURL < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when the server can't be reached, or its response can't be read: a timeout, a refused connection, a failed
|
|
17
|
+
# DNS lookup, a TLS error, or a body that doesn't decompress
|
|
18
|
+
# @api public
|
|
19
|
+
class NetworkError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when a request was sent, or may have been, and no answer came that could be read: the connection was
|
|
22
|
+
# open, and then it timed out, or closed, or what came back wasn't a response. Any other {NetworkError} is for a
|
|
23
|
+
# server that couldn't be connected to, so nothing was sent.
|
|
24
|
+
# @api public
|
|
25
|
+
class Unanswered < NetworkError; end
|
|
26
|
+
|
|
27
|
+
# Raised when a request is redirected more than {Client#max_redirects} times
|
|
28
|
+
# @api public
|
|
29
|
+
class TooManyRedirects < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when a response isn't what the API documents, such as JSON that doesn't parse
|
|
32
|
+
# @api public
|
|
33
|
+
class InvalidResponse < Error; end
|
|
34
|
+
|
|
35
|
+
# Base class for HTTP errors from the sferik.net API
|
|
36
|
+
# @api public
|
|
37
|
+
class HTTPError < Error
|
|
38
|
+
# The media type of a response that is plain text, whatever its parameters
|
|
39
|
+
PLAIN_TEXT = %r{\Atext/plain\s*(;|\z)}i
|
|
40
|
+
private_constant :PLAIN_TEXT
|
|
41
|
+
|
|
42
|
+
# A Retry-After header that gives a number of seconds
|
|
43
|
+
SECONDS = /\A\d+\z/
|
|
44
|
+
private_constant :SECONDS
|
|
45
|
+
|
|
46
|
+
# The status code of an error that is built without one: none, since this class is for no status in particular
|
|
47
|
+
# @api public
|
|
48
|
+
CODE = nil
|
|
49
|
+
|
|
50
|
+
# The HTTP status code
|
|
51
|
+
# @api public
|
|
52
|
+
# @return [Integer, nil] the HTTP status code: nil only for an error built by hand, of a class with no {CODE}
|
|
53
|
+
# @example
|
|
54
|
+
# error.code # => 404
|
|
55
|
+
attr_reader :code
|
|
56
|
+
|
|
57
|
+
# The headers of the response
|
|
58
|
+
# @api public
|
|
59
|
+
# @return [Hash{String => String}] the headers by name, in lowercase, frozen
|
|
60
|
+
# @example
|
|
61
|
+
# error.headers["content-type"] # => "application/json; charset=utf-8"
|
|
62
|
+
attr_reader :headers
|
|
63
|
+
|
|
64
|
+
# The body of the response
|
|
65
|
+
# @api public
|
|
66
|
+
# @return [String] the body, in the charset its Content-Type names (without one, UTF-8 if it's text or JSON, and binary
|
|
67
|
+
# if not), frozen
|
|
68
|
+
# @example
|
|
69
|
+
# error.body # => "{\"error\":\"Not Found\"}"
|
|
70
|
+
attr_reader :body
|
|
71
|
+
|
|
72
|
+
# Initialize a new HTTPError
|
|
73
|
+
#
|
|
74
|
+
# The message is the error a JSON response body names, or else the body if it's plain text, or else the status
|
|
75
|
+
# line: a page of HTML, as a proxy in front of the API may answer with, is no message. The message is in UTF-8,
|
|
76
|
+
# whatever the charset of the body: bytes that aren't valid in that charset are replaced, and a body in a charset
|
|
77
|
+
# that can't be read as text at all is no message either.
|
|
78
|
+
#
|
|
79
|
+
# Nothing is required, so that an error can be raised by hand, as a spec that stubs a request does: the code is
|
|
80
|
+
# then the one the class is for (404 for {NotFound}), and the message its status line, or the class's name for a
|
|
81
|
+
# class with no code.
|
|
82
|
+
#
|
|
83
|
+
# @api public
|
|
84
|
+
# @param message [String, nil] a message to use instead: why a redirect wasn't followed, say
|
|
85
|
+
# @param code [Integer, nil] the HTTP status code (defaults to the CODE of the class)
|
|
86
|
+
# @param reason [String, nil] the reason phrase of the status line: "Not Found", say (defaults to the code's)
|
|
87
|
+
# @param headers [Hash{String => String}] the headers of the response by name, in lowercase
|
|
88
|
+
# @param body [String] the body of the response
|
|
89
|
+
# @return [HTTPError] a new instance
|
|
90
|
+
# @example
|
|
91
|
+
# Sferik::HTTPError.new(code: 404, reason: "Not Found", headers: {"content-type" => "text/plain"}, body: "")
|
|
92
|
+
# @example Raise one by hand, with the code of its class
|
|
93
|
+
# raise Sferik::NotFound # code 404, message "404 Not Found"
|
|
94
|
+
# raise Sferik::NotFound, "No such page"
|
|
95
|
+
def initialize(message = nil, code: self.class::CODE, reason: Net::HTTP::STATUS_CODES[code], headers: {}, body: "")
|
|
96
|
+
@code = code
|
|
97
|
+
@headers = headers.dup.freeze
|
|
98
|
+
@body = body.dup.freeze
|
|
99
|
+
super(message || detail(reason))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Which error it is, as the API names it in a JSON response body
|
|
103
|
+
#
|
|
104
|
+
# The two endpoints that write name theirs: "busy" and "full" for a 429 from {API::SiteEndpoints#write} (one message
|
|
105
|
+
# a minute from an address, and twenty a day in all), "too_long", "empty", "undelivered", and so on. And any
|
|
106
|
+
# request may be told "not_found", "bad_path" (a path that isn't properly percent-encoded), "method_not_allowed",
|
|
107
|
+
# or "internal" (the server's own failure), and {API::ProfileEndpoints#webfinger} "no_account".
|
|
108
|
+
#
|
|
109
|
+
# @api public
|
|
110
|
+
# @return [String, nil] the code: nil if the body isn't JSON that names one
|
|
111
|
+
# @example
|
|
112
|
+
# error.error_code # => "busy"
|
|
113
|
+
def error_code
|
|
114
|
+
named(utf8.scrub, "code")
|
|
115
|
+
rescue Encoding::ConverterNotFoundError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The seconds to wait before trying again
|
|
120
|
+
#
|
|
121
|
+
# They are what the Retry-After header of the response gives, as a 429 has, and a 502 from {API::SiteEndpoints#write}.
|
|
122
|
+
#
|
|
123
|
+
# @api public
|
|
124
|
+
# @return [Integer, nil] the seconds: nil if the response doesn't say, or names a date rather than a number of seconds
|
|
125
|
+
# @example
|
|
126
|
+
# error.retry_after # => 60
|
|
127
|
+
def retry_after
|
|
128
|
+
seconds = headers["retry-after"]
|
|
129
|
+
seconds.to_i if SECONDS.match?(seconds)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# What the response says went wrong
|
|
135
|
+
#
|
|
136
|
+
# @api private
|
|
137
|
+
# @param reason [String, nil] the reason phrase of the status line
|
|
138
|
+
# @return [String, nil] the error the body names, or else the body if that's plain text, or else the status line,
|
|
139
|
+
# if there is one
|
|
140
|
+
def detail(reason)
|
|
141
|
+
text = utf8.scrub.strip
|
|
142
|
+
named(text, "error") || plain(text) || status_line(reason)
|
|
143
|
+
rescue Encoding::ConverterNotFoundError
|
|
144
|
+
status_line(reason)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The status line of the response, without the version of HTTP
|
|
148
|
+
#
|
|
149
|
+
# @api private
|
|
150
|
+
# @param reason [String, nil] the reason phrase
|
|
151
|
+
# @return [String, nil] the code and the reason, or whichever there is, or nil if there's neither
|
|
152
|
+
def status_line(reason)
|
|
153
|
+
line = "#{code} #{reason}".strip
|
|
154
|
+
line unless line.empty?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The body in UTF-8, so that the message can be put in any other text
|
|
158
|
+
#
|
|
159
|
+
# A binary body, which is one that isn't text or JSON and whose response named no charset, is taken for UTF-8.
|
|
160
|
+
#
|
|
161
|
+
# @api private
|
|
162
|
+
# @return [String] the body, in UTF-8
|
|
163
|
+
# @raise [Encoding::ConverterNotFoundError] if the body's charset is one nothing can be converted from, like UTF-7
|
|
164
|
+
def utf8
|
|
165
|
+
return String.new(body, encoding: Encoding::UTF_8) if body.encoding.equal?(Encoding::BINARY)
|
|
166
|
+
|
|
167
|
+
body.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# What a JSON response body names
|
|
171
|
+
#
|
|
172
|
+
# @api private
|
|
173
|
+
# @param text [String] the response body
|
|
174
|
+
# @param key [String] the key: "error" or "code" (the API's errors are {"error": "Not Found", "code": "not_found"})
|
|
175
|
+
# @return [String, nil] the String a JSON object has under the key, if it does
|
|
176
|
+
def named(text, key)
|
|
177
|
+
parsed = JSON.parse(text)
|
|
178
|
+
value = parsed.fetch(key, nil) if parsed.instance_of?(Hash)
|
|
179
|
+
value if value.instance_of?(String)
|
|
180
|
+
rescue JSON::ParserError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The body of a response that is plain text
|
|
185
|
+
#
|
|
186
|
+
# @api private
|
|
187
|
+
# @param text [String] the response body
|
|
188
|
+
# @return [String, nil] the body, if the response is plain text and has one
|
|
189
|
+
def plain(text)
|
|
190
|
+
text if PLAIN_TEXT.match?(headers["content-type"]) && !text.empty?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Raised for a 4xx response
|
|
195
|
+
# @api public
|
|
196
|
+
class ClientError < HTTPError
|
|
197
|
+
# The status code of an error that is built without one
|
|
198
|
+
# @api public
|
|
199
|
+
CODE = 400
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Raised for a 404 Not Found response
|
|
203
|
+
# @api public
|
|
204
|
+
class NotFound < ClientError
|
|
205
|
+
# The status code of an error that is built without one
|
|
206
|
+
# @api public
|
|
207
|
+
CODE = 404
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Raised for a 406 Not Acceptable response: the resource has no representation in the format asked for
|
|
211
|
+
# @api public
|
|
212
|
+
class NotAcceptable < ClientError
|
|
213
|
+
# The status code of an error that is built without one
|
|
214
|
+
# @api public
|
|
215
|
+
CODE = 406
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Raised for a 429 Too Many Requests response: the server takes one message a minute from an address, and twenty a
|
|
219
|
+
# day in all (see {API::SiteEndpoints#write}). {#retry_after} is how long to wait, and {#error_code} which limit it was.
|
|
220
|
+
# @api public
|
|
221
|
+
class TooManyRequests < ClientError
|
|
222
|
+
# The status code of an error that is built without one
|
|
223
|
+
# @api public
|
|
224
|
+
CODE = 429
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Raised for a 5xx response
|
|
228
|
+
# @api public
|
|
229
|
+
class ServerError < HTTPError
|
|
230
|
+
# The status code of an error that is built without one
|
|
231
|
+
# @api public
|
|
232
|
+
CODE = 500
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "resource"
|
|
4
|
+
|
|
5
|
+
module Sferik
|
|
6
|
+
# An image, and what it is in words, as returned by {Dependency#figure}
|
|
7
|
+
# @api public
|
|
8
|
+
class Figure < Resource
|
|
9
|
+
# @!method type
|
|
10
|
+
# "figure"
|
|
11
|
+
# @api public
|
|
12
|
+
# @return [String] "figure"
|
|
13
|
+
# @example
|
|
14
|
+
# figure.type # => "figure"
|
|
15
|
+
attribute :type
|
|
16
|
+
|
|
17
|
+
# @!method href
|
|
18
|
+
# Where the image is from
|
|
19
|
+
# @api public
|
|
20
|
+
# @return [String] where the image is from
|
|
21
|
+
# @example
|
|
22
|
+
# figure.href # => "https://xkcd.com/2347/"
|
|
23
|
+
attribute :href
|
|
24
|
+
|
|
25
|
+
# @!method src
|
|
26
|
+
# The image's path
|
|
27
|
+
# @api public
|
|
28
|
+
# @return [String] the image's path
|
|
29
|
+
# @example
|
|
30
|
+
# figure.src # => "/img/dependency.webp"
|
|
31
|
+
attribute :src
|
|
32
|
+
|
|
33
|
+
# @!method srcset
|
|
34
|
+
# The image's paths by pixel density
|
|
35
|
+
# @api public
|
|
36
|
+
# @return [String] the image's paths by pixel density
|
|
37
|
+
# @example
|
|
38
|
+
# figure.srcset # => "/img/dependency.webp 1x, /img/dependency-2x.webp 2x"
|
|
39
|
+
attribute :srcset
|
|
40
|
+
|
|
41
|
+
# @!method width
|
|
42
|
+
# The image's width in pixels
|
|
43
|
+
# @api public
|
|
44
|
+
# @return [Integer] the image's width in pixels
|
|
45
|
+
# @example
|
|
46
|
+
# figure.width # => 385
|
|
47
|
+
attribute :width
|
|
48
|
+
|
|
49
|
+
# @!method height
|
|
50
|
+
# The image's height in pixels
|
|
51
|
+
# @api public
|
|
52
|
+
# @return [Integer] the image's height in pixels
|
|
53
|
+
# @example
|
|
54
|
+
# figure.height # => 489
|
|
55
|
+
attribute :height
|
|
56
|
+
|
|
57
|
+
# @!method alt
|
|
58
|
+
# What the image shows, in words
|
|
59
|
+
# @api public
|
|
60
|
+
# @return [String] what the image shows, in words
|
|
61
|
+
# @example
|
|
62
|
+
# figure.alt # => "A tall, precarious tower of blocks..."
|
|
63
|
+
attribute :alt
|
|
64
|
+
|
|
65
|
+
# @!method title
|
|
66
|
+
# The image's title text
|
|
67
|
+
# @api public
|
|
68
|
+
# @return [String] the image's title text
|
|
69
|
+
# @example
|
|
70
|
+
# figure.title # => "Someday ImageMagick will finally break for good..."
|
|
71
|
+
attribute :title
|
|
72
|
+
|
|
73
|
+
# @!method caption
|
|
74
|
+
# The image's caption, as HTML
|
|
75
|
+
# @api public
|
|
76
|
+
# @return [String] the image's caption, as HTML
|
|
77
|
+
# @example
|
|
78
|
+
# figure.caption # => "Adapted from ..."
|
|
79
|
+
attribute :caption
|
|
80
|
+
|
|
81
|
+
inspect_with :href, :src
|
|
82
|
+
end
|
|
83
|
+
end
|