hook0-client 2.0.0

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.
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Hook0
8
+ # What the generated half of this gem reads and writes values through.
9
+ #
10
+ # Everything here is hand-written and never regenerated. It is the one seam between what the API
11
+ # declares — the classes, the problems and the methods the generator writes under `generated/` —
12
+ # and what it does not: how a JSON document is turned into a value, and what happens to a document
13
+ # that does not say what it was declared to say.
14
+ #
15
+ # Reading is deliberately strict. A member the document declares as a string and the API answered
16
+ # as a number stops the read with the name of that member, rather than yielding an object whose
17
+ # documentation lies about what it holds. Every failure of that kind is a {DecodeError}, so a
18
+ # caller has one thing to rescue whatever the shape of the answer was.
19
+ #
20
+ # A reader is anything answering to `call`. The scalar ones are constants, since there is exactly
21
+ # one of each; the ones built around another reader are methods, since there is one per shape.
22
+ module Runtime
23
+ # What the API answered is not what it declares it answers.
24
+ class DecodeError < StandardError; end
25
+
26
+ # Longest fragment of a response body an error message carries. Bodies are answered by a server
27
+ # this gem does not control, so they are cut at a fixed budget rather than echoed whole into
28
+ # whatever the caller logs.
29
+ MAX_PREVIEW_BYTES = 256
30
+
31
+ # Largest JSON document read out of a response body, in bytes. The transport caps what it reads
32
+ # off a socket; this caps what is handed to the parser whichever way the bytes arrived.
33
+ MAX_PAYLOAD_BYTES = 8 * 1024 * 1024
34
+
35
+ # Deepest a JSON document may nest before the parser gives up, which is what keeps a document
36
+ # that is nothing but brackets from growing the stack.
37
+ MAX_PAYLOAD_NESTING = 64
38
+
39
+ # The characters a path segment carries as themselves; everything else travels percent-encoded.
40
+ UNRESERVED = /[^A-Za-z0-9\-._~]/
41
+
42
+ # The shape a UUID is written in, whichever version it carries.
43
+ UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
44
+
45
+ # A string, refusing what merely spells like one.
46
+ TEXT = lambda { |value|
47
+ raise DecodeError, "expected a string, got #{value.class}" unless value.is_a?(String)
48
+
49
+ value
50
+ }
51
+
52
+ # A UUID, as the document spells one. It travels as the text the API answered, since that text
53
+ # is what has to go back out unchanged.
54
+ UUID = lambda { |value|
55
+ text = TEXT.call(value)
56
+ raise DecodeError, "expected a UUID, got `#{text}`" unless UUID_PATTERN.match?(text)
57
+
58
+ text
59
+ }
60
+
61
+ # A whole number. `true` is not one, here or on the wire.
62
+ INTEGER = lambda { |value|
63
+ raise DecodeError, "expected a whole number, got #{value.class}" unless value.is_a?(Integer)
64
+
65
+ value
66
+ }
67
+
68
+ # A number, whether the document wrote it with a fractional part or not.
69
+ FLOAT = lambda { |value|
70
+ raise DecodeError, "expected a number, got #{value.class}" unless value.is_a?(Integer) || value.is_a?(Float)
71
+
72
+ value.to_f
73
+ }
74
+
75
+ # A boolean, refusing the numbers that stand in for one elsewhere.
76
+ BOOLEAN = lambda { |value|
77
+ raise DecodeError, "expected a boolean, got #{value.class}" unless [true, false].include?(value)
78
+
79
+ value
80
+ }
81
+
82
+ # A moment, as RFC 3339 spells one.
83
+ DATE_TIME = lambda { |value|
84
+ text = TEXT.call(value)
85
+ begin
86
+ Time.iso8601(text)
87
+ rescue ArgumentError => e
88
+ raise DecodeError, "expected a date and a time, got `#{text}`: #{e.message}"
89
+ end
90
+ }
91
+
92
+ # A day, as ISO 8601 spells one.
93
+ DATE = lambda { |value|
94
+ text = TEXT.call(value)
95
+ begin
96
+ Date.iso8601(text)
97
+ rescue ArgumentError => e
98
+ # `Date::Error` is an `ArgumentError`, so rescuing the one covers the other.
99
+ raise DecodeError, "expected a date, got `#{text}`: #{e.message}"
100
+ end
101
+ }
102
+
103
+ # A value the document does not describe, which is therefore kept as it arrived.
104
+ JSON_VALUE = ->(value) { value }
105
+
106
+ # As much of a response body as a message may carry.
107
+ #
108
+ # @param payload [String]
109
+ # @return [String]
110
+ def self.preview(payload)
111
+ bytes = payload.to_s.b
112
+ kept = bytes.byteslice(0, MAX_PREVIEW_BYTES).force_encoding(Encoding::UTF_8)
113
+ rendered = kept.scrub("�")
114
+ bytes.bytesize > MAX_PREVIEW_BYTES ? "#{rendered}…" : rendered
115
+ end
116
+
117
+ # What to say about an answer the API document does not describe.
118
+ #
119
+ # @param status [Integer]
120
+ # @param payload [String]
121
+ # @return [String]
122
+ def self.unreadable(status, payload)
123
+ "the API answered #{status} with a body this client cannot read: #{preview(payload)}"
124
+ end
125
+
126
+ # What to say about a problem the API reported.
127
+ #
128
+ # @param status [Integer]
129
+ # @param problem [#to_h] the problem document the API answered
130
+ # @return [String]
131
+ def self.reported(status, problem)
132
+ "the API answered #{status}: #{problem.to_h}"
133
+ end
134
+
135
+ # The JSON document a response body carries.
136
+ #
137
+ # @param payload [String]
138
+ # @return [Object]
139
+ # @raise [DecodeError] when the body is larger than this gem reads, or is not JSON
140
+ def self.decode_payload(payload)
141
+ bytes = payload.to_s
142
+ if bytes.bytesize > MAX_PAYLOAD_BYTES
143
+ raise DecodeError, "the response is #{bytes.bytesize} bytes, above the #{MAX_PAYLOAD_BYTES} accepted"
144
+ end
145
+
146
+ begin
147
+ JSON.parse(bytes, max_nesting: MAX_PAYLOAD_NESTING)
148
+ rescue JSON::ParserError, EncodingError => e
149
+ raise DecodeError, "the response is not JSON: #{preview(bytes)} (#{e.message})"
150
+ end
151
+ end
152
+
153
+ # The members of an object the document declares, under the name it declares it with.
154
+ #
155
+ # @param value [Object]
156
+ # @param owner [String] what the document calls the object being read
157
+ # @return [Hash]
158
+ # @raise [DecodeError] when the API answered something that is not an object
159
+ def self.as_fields(value, owner)
160
+ raise DecodeError, "#{owner} is not a JSON object" unless value.is_a?(Hash)
161
+
162
+ value
163
+ end
164
+
165
+ # A member the document requires, which is therefore missing when it is absent.
166
+ #
167
+ # @param fields [Hash]
168
+ # @param key [String] the name the member travels under
169
+ # @param reader [#call]
170
+ # @return [Object]
171
+ # @raise [DecodeError]
172
+ def self.read(fields, key, reader)
173
+ raise DecodeError, "`#{key}` is required and was not answered" unless fields.key?(key)
174
+
175
+ named(key) { reader.call(fields[key]) }
176
+ end
177
+
178
+ # A member the document does not require, absent as readily as answered as null.
179
+ #
180
+ # @param fields [Hash]
181
+ # @param key [String] the name the member travels under
182
+ # @param reader [#call]
183
+ # @return [Object, nil]
184
+ # @raise [DecodeError]
185
+ def self.maybe(fields, key, reader)
186
+ return nil if fields[key].nil?
187
+
188
+ named(key) { reader.call(fields[key]) }
189
+ end
190
+
191
+ # Every item of an array, each one read the same way.
192
+ #
193
+ # @param reader [#call]
194
+ # @return [Proc]
195
+ def self.list(reader)
196
+ lambda { |value|
197
+ raise DecodeError, "expected an array, got #{value.class}" unless value.is_a?(Array)
198
+
199
+ value.map { |item| reader.call(item) }
200
+ }
201
+ end
202
+
203
+ # Every value of an object whose keys the document leaves open.
204
+ #
205
+ # @param reader [#call]
206
+ # @return [Proc]
207
+ def self.map(reader)
208
+ lambda { |value|
209
+ raise DecodeError, "expected an object, got #{value.class}" unless value.is_a?(Hash)
210
+
211
+ value.to_h { |key, item| [TEXT.call(key), reader.call(item)] }
212
+ }
213
+ end
214
+
215
+ # One of the values a closed list declares, refusing anything the list does not carry.
216
+ #
217
+ # @param declared [#member?] the module the generator wrote for that list
218
+ # @return [Proc]
219
+ def self.member_of(declared)
220
+ lambda { |value|
221
+ text = TEXT.call(value)
222
+ raise DecodeError, "`#{text}` is not one of the values #{declared} declares" unless declared.member?(text)
223
+
224
+ text
225
+ }
226
+ end
227
+
228
+ # A moment, written the way the API reads one.
229
+ #
230
+ # A moment carrying no fraction of a second is written without one, and one that does keeps
231
+ # every digit it has, so that what was read comes back out unchanged either way.
232
+ #
233
+ # @param moment [Time]
234
+ # @return [String]
235
+ def self.moment(moment)
236
+ moment.nsec.zero? ? moment.iso8601 : moment.iso8601(9)
237
+ end
238
+
239
+ # A day, written the way the API reads one.
240
+ #
241
+ # @param day [Date]
242
+ # @return [String]
243
+ def self.day(day)
244
+ day.iso8601
245
+ end
246
+
247
+ # Where a request lands, with each placeholder of the template filled in.
248
+ #
249
+ # @param template [String] the path as the document writes it, placeholders included
250
+ # @param filled [Hash{String => Object}] the value each placeholder carries
251
+ # @return [String]
252
+ def self.path(template, filled = {})
253
+ filled.reduce(template) do |written, (name, value)|
254
+ written.gsub("{#{name}}", path_segment(value))
255
+ end
256
+ end
257
+
258
+ # A value as one segment of a path, with nothing left in it that could name another one.
259
+ #
260
+ # @param value [Object]
261
+ # @return [String]
262
+ def self.path_segment(value)
263
+ written(value).b.gsub(UNRESERVED) { |byte| format("%%%02X", byte.ord) }
264
+ end
265
+
266
+ # What travels in the query string: everything the document requires, and everything it does not
267
+ # that the caller actually passed.
268
+ #
269
+ # @param required [Array<Array>] name and value pairs the operation always sends
270
+ # @param optional [Array<Array>] name and value pairs it sends only when they carry something
271
+ # @return [Array<Array<String>>]
272
+ def self.query(required, optional = [])
273
+ asked = required.map { |name, value| [name, written(value)] }
274
+ asked + optional.filter_map { |name, value| [name, written(value)] unless value.nil? }
275
+ end
276
+
277
+ # How a value travels in a request line, which is not always how Ruby prints it.
278
+ #
279
+ # @param value [Object]
280
+ # @return [String]
281
+ def self.written(value)
282
+ case value
283
+ when true then "true"
284
+ when false then "false"
285
+ when Time then moment(value)
286
+ when Date then day(value)
287
+ else value.to_s
288
+ end
289
+ end
290
+
291
+ # Reads a member, saying which member it was that could not be read.
292
+ def self.named(key)
293
+ yield
294
+ rescue DecodeError => e
295
+ raise DecodeError, "`#{key}`: #{e.message}"
296
+ end
297
+ private_class_method :named
298
+ end
299
+ end
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ require_relative "errors"
6
+
7
+ # Verifying that a webhook came from Hook0, and that nothing in it changed on the way.
8
+ module Hook0
9
+ # A signature header, read into the pieces a verification needs.
10
+ #
11
+ # A signature names the moment it was signed and one or two message authentication codes over the
12
+ # body. The `v1` scheme also covers a list of request headers, so a receiver can tell apart two
13
+ # deliveries that carry the same body but not the same context; `v0` covers the body alone and is
14
+ # what an older sender still produces. When both are offered, `v1` is the one verified: accepting
15
+ # the weaker of two schemes on the strength of the sender offering it is how a downgrade works.
16
+ #
17
+ # Two things are refused before any code is computed. A header the signature says it covers but
18
+ # the request did not carry is refused outright, because signing over an absent value would let a
19
+ # sender drop a header and keep the signature valid. And a signature whose codes are not whole
20
+ # hexadecimal is refused rather than decoded as far as it goes: a decoder that stops at the first
21
+ # bad character compares a prefix, and a prefix of the right code is not the right code.
22
+ class Signature
23
+ # Longest signature header read. The header is written by whoever reached the endpoint, so its
24
+ # size is bounded before any of it is split, decoded or compared.
25
+ MAX_SIGNATURE_BYTES = 8 * 1024
26
+
27
+ # Most `key=value` parts one signature header is split into.
28
+ MAX_SIGNATURE_PARTS = 32
29
+
30
+ # Most header names one signature covers.
31
+ MAX_COVERED_HEADERS = 64
32
+
33
+ # Furthest from the epoch, in either direction, a signature's moment may sit. Ruby's integers
34
+ # grow without bound, so a header carrying thousands of digits would otherwise reach the
35
+ # arithmetic that holds it against the current time and cost more than reading it did.
36
+ MAX_TIMESTAMP = 10**12
37
+
38
+ # What separates one part of the signature header from the next.
39
+ PART_SEPARATOR = ","
40
+
41
+ # What separates the name of a part from its value. Only the first one counts: a value may hold
42
+ # further ones, and splitting on all of them would silently drop everything past the second.
43
+ PART_ASSIGNATOR = "="
44
+
45
+ # What separates two header names inside the `h` part, and what they are joined back with.
46
+ HEADER_NAME_SEPARATOR = " "
47
+
48
+ # What separates the pieces of the message a code is computed over.
49
+ MESSAGE_SEPARATOR = "."
50
+
51
+ # Part naming the moment the delivery was signed, in whole seconds since the Unix epoch.
52
+ TIMESTAMP_PART = "t"
53
+
54
+ # Part carrying the code covering the body alone.
55
+ BODY_SCHEME_PART = "v0"
56
+
57
+ # Part carrying the code covering the covered headers and the body.
58
+ HEADERS_SCHEME_PART = "v1"
59
+
60
+ # Part listing the headers the `v1` code covers, in the order it covers them.
61
+ COVERED_HEADERS_PART = "h"
62
+
63
+ # What a whole number of seconds reads as. `Integer()` would accept `1_0` as ten, which is a
64
+ # spelling no sender produces and no receiver should invent a meaning for.
65
+ WHOLE_SECONDS = /\A-?\d+\z/
66
+
67
+ # What a code reads as: whole pairs of hexadecimal digits, and nothing else.
68
+ WHOLE_HEXADECIMAL = /\A(?:\h\h)+\z/
69
+
70
+ # What a header name is written with, as RFC 9110 spells a token.
71
+ HEADER_NAME = /\A[A-Za-z0-9!\#$%&'*+\-.^_`|~]+\z/
72
+
73
+ # What the codes are computed with.
74
+ DIGEST = "SHA256"
75
+
76
+ # @return [Integer] the moment the delivery was signed, in whole seconds since the epoch
77
+ attr_reader :timestamp
78
+
79
+ # @return [Array<String>] the headers the stronger scheme covers, lowercased and in order
80
+ attr_reader :covered_headers
81
+
82
+ # @return [String, nil] the `v0` code, decoded
83
+ attr_reader :body_code
84
+
85
+ # @return [String, nil] the `v1` code, decoded
86
+ attr_reader :headers_code
87
+
88
+ # @param timestamp [Integer]
89
+ # @param covered_headers [Array<String>]
90
+ # @param body_code [String, nil]
91
+ # @param headers_code [String, nil]
92
+ def initialize(timestamp, covered_headers, body_code, headers_code)
93
+ @timestamp = timestamp
94
+ @covered_headers = covered_headers
95
+ @body_code = body_code
96
+ @headers_code = headers_code
97
+ freeze
98
+ end
99
+
100
+ # Reads a signature header, refusing anything it cannot read whole.
101
+ #
102
+ # @param signature [String] the value of the `X-Hook0-Signature` header
103
+ # @return [Signature]
104
+ # @raise [ClientError] for every way a header can fail to be one
105
+ def self.parse(signature)
106
+ raise ClientError, "the signature is #{signature.class}, not a header value" unless signature.is_a?(String)
107
+
108
+ if signature.length > MAX_SIGNATURE_BYTES
109
+ raise ClientError,
110
+ "the signature is #{signature.length} characters long, above the #{MAX_SIGNATURE_BYTES} accepted"
111
+ end
112
+
113
+ read = parts_of(signature)
114
+ raise ClientError, "the signature carries neither a timestamp nor a code" if read.size < 2
115
+
116
+ body_code = code_of(read, BODY_SCHEME_PART)
117
+ headers_code = code_of(read, HEADERS_SCHEME_PART)
118
+ if body_code.nil? && headers_code.nil?
119
+ raise ClientError, "the signature carries neither a `#{BODY_SCHEME_PART}` nor a `#{HEADERS_SCHEME_PART}` code"
120
+ end
121
+
122
+ new(timestamp_of(read), covered_headers_of(read), body_code, headers_code)
123
+ end
124
+
125
+ # The `key=value` parts of a header, split on the first assignator of each and trimmed.
126
+ def self.parts_of(signature)
127
+ parts = signature.split(PART_SEPARATOR, -1)
128
+ if parts.size > MAX_SIGNATURE_PARTS
129
+ raise ClientError, "the signature carries more than the #{MAX_SIGNATURE_PARTS} parts accepted"
130
+ end
131
+
132
+ parts.each_with_object({}) do |part, read|
133
+ name, assigned, value = part.partition(PART_ASSIGNATOR)
134
+ read[name.strip] = value.strip unless assigned.empty?
135
+ end
136
+ end
137
+ private_class_method :parts_of
138
+
139
+ # The moment the signature names, which it is not a signature without.
140
+ def self.timestamp_of(read)
141
+ written = read[TIMESTAMP_PART]
142
+ raise ClientError, "the signature carries no `#{TIMESTAMP_PART}` part" if written.nil?
143
+ raise ClientError, "`#{written}` is not a number of seconds" unless WHOLE_SECONDS.match?(written)
144
+
145
+ seconds = Integer(written, 10)
146
+ if seconds.abs > MAX_TIMESTAMP
147
+ raise ClientError, "the signature's moment is further than #{MAX_TIMESTAMP} seconds from the epoch"
148
+ end
149
+
150
+ seconds
151
+ end
152
+ private_class_method :timestamp_of
153
+
154
+ # One of the codes a signature offers, decoded whole or not at all.
155
+ def self.code_of(read, part)
156
+ written = read[part]
157
+ return nil if written.nil?
158
+ raise ClientError, "the `#{part}` code is not hexadecimal" unless WHOLE_HEXADECIMAL.match?(written)
159
+
160
+ [written].pack("H*")
161
+ end
162
+ private_class_method :code_of
163
+
164
+ # The headers the stronger scheme covers, in the order it covers them.
165
+ def self.covered_headers_of(read)
166
+ written = read[COVERED_HEADERS_PART]
167
+ return [] if written.nil? || written.empty?
168
+
169
+ names = written.split(HEADER_NAME_SEPARATOR, -1)
170
+ if names.size > MAX_COVERED_HEADERS
171
+ raise ClientError, "the signature covers more than the #{MAX_COVERED_HEADERS} headers accepted"
172
+ end
173
+
174
+ names.map do |name|
175
+ raise ClientError, "`#{name}` is not a header name" unless HEADER_NAME.match?(name)
176
+
177
+ name.downcase
178
+ end
179
+ end
180
+ private_class_method :covered_headers_of
181
+
182
+ # Whether the code this signature carries is the one the secret produces.
183
+ #
184
+ # The stronger scheme wins when both are offered, and the comparison is made in constant time:
185
+ # one that gave up at the first differing byte would say, by how long it took, how much of a
186
+ # guess was right.
187
+ #
188
+ # @param payload [String] the raw body of the webhook request
189
+ # @param covered_values [Array<String>] the values of the covered headers, in order
190
+ # @param subscription_secret [String]
191
+ # @return [Boolean]
192
+ def matches?(payload, covered_values, subscription_secret)
193
+ code = OpenSSL::HMAC.new(subscription_secret.to_s, DIGEST)
194
+ code << @timestamp.to_s
195
+ code << MESSAGE_SEPARATOR
196
+
197
+ unless @headers_code.nil?
198
+ code << @covered_headers.join(HEADER_NAME_SEPARATOR)
199
+ code << MESSAGE_SEPARATOR
200
+ code << covered_values.join(MESSAGE_SEPARATOR)
201
+ code << MESSAGE_SEPARATOR
202
+ code << payload
203
+ return Signature.same_code?(code.digest, @headers_code)
204
+ end
205
+
206
+ # A signature carrying neither code is refused while it is being read, so what is left here
207
+ # is the body-only scheme.
208
+ code << payload
209
+ Signature.same_code?(code.digest, @body_code)
210
+ end
211
+
212
+ # Whether two codes are the same, without saying by how long it took how much of one was right.
213
+ #
214
+ # @param left [String]
215
+ # @param right [String]
216
+ # @return [Boolean]
217
+ def self.same_code?(left, right)
218
+ left.bytesize == right.bytesize && OpenSSL.fixed_length_secure_compare(left, right)
219
+ end
220
+ end
221
+
222
+ # Verifies a webhook against a moment the caller names.
223
+ #
224
+ # The clock window is bilateral. A moment too far in the future is refused exactly like one too
225
+ # far in the past, so the window a given delivery is accepted in stays the width the caller asked
226
+ # for, whichever way a clock drifted.
227
+ #
228
+ # @param signature [String] the value of the `X-Hook0-Signature` header
229
+ # @param payload [String] the raw body of the webhook request
230
+ # @param headers [Hash, Array<Array>] the headers of the webhook request
231
+ # @param subscription_secret [String] the signing secret of the subscription it was delivered for
232
+ # @param tolerance [Numeric] how far, in seconds and in either direction, the moment the signature
233
+ # names may sit from `current_time`. Five minutes is a reasonable trade-off between tolerating
234
+ # clock drift and bounding how long a captured delivery can be replayed.
235
+ # @param current_time [Time] what to hold the signature's moment against
236
+ # @return [void]
237
+ # @raise [ClientError] for every reason a webhook may be refused
238
+ def self.verify_webhook_signature_with_current_time(
239
+ signature, payload, headers, subscription_secret, tolerance, current_time
240
+ )
241
+ parsed = Signature.parse(signature)
242
+
243
+ delivered = delivered_headers(headers)
244
+ covered_values = parsed.covered_headers.map do |name|
245
+ raise ClientError, "the `#{name}` header the signature covers was not delivered" unless delivered.key?(name)
246
+
247
+ delivered[name]
248
+ end
249
+
250
+ unless parsed.matches?(payload.to_s, covered_values, subscription_secret)
251
+ raise ClientError, "the signature does not match what the subscription secret produces"
252
+ end
253
+
254
+ drift = current_time.to_f - parsed.timestamp
255
+ if drift.abs > tolerance
256
+ raise ClientError,
257
+ "the signature was made #{format("%.0f", drift)} seconds from now, outside the #{tolerance} accepted"
258
+ end
259
+
260
+ nil
261
+ end
262
+
263
+ # Verifies a webhook against the current moment.
264
+ #
265
+ # See {verify_webhook_signature_with_current_time} for what each argument is.
266
+ #
267
+ # @return [void]
268
+ # @raise [ClientError]
269
+ def self.verify_webhook_signature(signature, payload, headers, subscription_secret, tolerance)
270
+ verify_webhook_signature_with_current_time(
271
+ signature, payload, headers, subscription_secret, tolerance, Time.now
272
+ )
273
+ end
274
+
275
+ # The headers of the request, under the names a signature refers to them by.
276
+ #
277
+ # A later value wins over an earlier one under the same name, which is what a hash built by the
278
+ # caller would have done.
279
+ def self.delivered_headers(headers)
280
+ headers.to_a.each_with_object({}) do |(name, value), delivered|
281
+ delivered[header_text(name).downcase] = header_text(value)
282
+ end
283
+ end
284
+ private_class_method :delivered_headers
285
+
286
+ # A header name or value as text, whichever way the caller holds it.
287
+ def self.header_text(value)
288
+ raise ClientError, "a header is #{value.class}, not a header value" unless value.is_a?(String)
289
+ raise ClientError, "a header is not UTF-8" unless value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
290
+
291
+ value
292
+ end
293
+ private_class_method :header_text
294
+ end