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