specguard-ruby 0.3.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/.github/workflows/ci.yml +49 -0
- data/.github/workflows/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +1036 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-ingest +40 -0
- data/bin/specguard-lint +34 -0
- data/lib/minitest/specguard_plugin.rb +25 -0
- data/lib/specguard/minitest/reporter.rb +244 -0
- data/lib/specguard/rspec/annotation_lookup.rb +363 -0
- data/lib/specguard/rspec/annotation_scanner.rb +190 -0
- data/lib/specguard/rspec/cli.rb +375 -0
- data/lib/specguard/rspec/configuration.rb +472 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +74 -0
- data/lib/specguard/rspec/formatter.rb +1110 -0
- data/lib/specguard/rspec/ingest_cli.rb +942 -0
- data/lib/specguard/rspec/ingest_reporter.rb +280 -0
- data/lib/specguard/rspec/json_reporter.rb +169 -0
- data/lib/specguard/rspec/linter.rb +156 -0
- data/lib/specguard/rspec/payload_normalizer.rb +143 -0
- data/lib/specguard/rspec/scanner.rb +235 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +449 -0
- data/lib/specguard/rspec/validator_backend.rb +1450 -0
- data/lib/specguard/rspec/version.rb +14 -0
- data/lib/specguard/rspec.rb +71 -0
- data/lib/specguard/version.rb +11 -0
- data/script/bump-version.sh +128 -0
- metadata +96 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "zlib"
|
|
7
|
+
|
|
8
|
+
require_relative "configuration"
|
|
9
|
+
require_relative "../version"
|
|
10
|
+
|
|
11
|
+
module SpecGuard
|
|
12
|
+
module RSpec
|
|
13
|
+
# The one HTTP call this gem makes: `POST <endpoint>/api/v1/ingest`,
|
|
14
|
+
# carrying a whole run.
|
|
15
|
+
#
|
|
16
|
+
# == Why this returns a result instead of raising
|
|
17
|
+
#
|
|
18
|
+
# {SpecGuard::RSpecFormatter}'s never-block-CI guard is a `rescue` around
|
|
19
|
+
# each hook, and a `rescue` is structurally blind to the failure that
|
|
20
|
+
# matters most here: `Net::HTTP` hands back `Net::HTTPUnauthorized` as an
|
|
21
|
+
# ordinary return value. A wrong API key therefore raises nothing, warns
|
|
22
|
+
# nothing and logs nothing — the run's telemetry disappears in complete
|
|
23
|
+
# silence, which is precisely the outcome the client-gem spec's "if the API
|
|
24
|
+
# key is wrong … it logs a warning to stderr" forbids.
|
|
25
|
+
#
|
|
26
|
+
# So the two failure families are made the same *shape* rather than left to
|
|
27
|
+
# two different mechanisms: {#deliver} answers a {Result} for a non-2xx
|
|
28
|
+
# response and a {Result} for a raised exception, and the caller has one
|
|
29
|
+
# thing to check. Nothing escapes this class except `Interrupt`,
|
|
30
|
+
# `SignalException` and `SystemExit` — Ctrl-C must stay Ctrl-C.
|
|
31
|
+
#
|
|
32
|
+
# == One request per process, gzipped once it is big enough
|
|
33
|
+
#
|
|
34
|
+
# A run goes in a single POST, and the body is compressed above a size
|
|
35
|
+
# threshold. Both halves are decisions rather than defaults, and the
|
|
36
|
+
# roadmap asked for them to be made deliberately rather than discovered in
|
|
37
|
+
# production, so they are written down here.
|
|
38
|
+
#
|
|
39
|
+
# === Batching: no, and this is settled
|
|
40
|
+
#
|
|
41
|
+
# * `Ingest::Payload` derives `total_specs_count` from the specs of *that*
|
|
42
|
+
# request. Splitting a run across N POSTs with no way to say they are
|
|
43
|
+
# one run would produce N `TestRun` rows with a split denominator,
|
|
44
|
+
# corrupting the headline annotated-ratio metric.
|
|
45
|
+
# * That is qualified rather than absolute, and the qualification is
|
|
46
|
+
# `ci_run_id` + `shard_id`. The platform folds every POST carrying the
|
|
47
|
+
# same run id onto one `TestRun`, keyed by shard so a slice that arrives
|
|
48
|
+
# twice replaces itself, which is what makes a *sharded* run — N
|
|
49
|
+
# processes, N POSTs, one run — land as one row. So the rule this class
|
|
50
|
+
# keeps is narrower than it was: **one process sends one request.**
|
|
51
|
+
# * Batching a single process's own run into several POSTs stays wrong,
|
|
52
|
+
# and not only for the denominator: every part would carry the same
|
|
53
|
+
# `shard_id`, so the parts would overwrite one another and the row would
|
|
54
|
+
# keep only the last. Fixing that means a new part-of-a-shard concept on
|
|
55
|
+
# the platform, which is a schema change bought to solve a problem that
|
|
56
|
+
# compression already solved.
|
|
57
|
+
#
|
|
58
|
+
# === Streaming: no, for the same reason, and the reason is measured
|
|
59
|
+
#
|
|
60
|
+
# The pressure that made batching and streaming look necessary was size,
|
|
61
|
+
# and size is now a number rather than a worry. Measured by running a real
|
|
62
|
+
# 200-file / 20,000-example suite through this gem's own formatter, half of
|
|
63
|
+
# the examples annotated:
|
|
64
|
+
#
|
|
65
|
+
# identity 7,354,782 bytes 7.01 MiB needs 5.9 Mbit/s to write in 10s
|
|
66
|
+
# gzip 346,206 bytes 0.33 MiB needs 0.3 Mbit/s
|
|
67
|
+
# ratio 21.2x 95.3% saved 60 ms to compress
|
|
68
|
+
#
|
|
69
|
+
# Uncompressed, that body has to be *written* inside
|
|
70
|
+
# `Configuration::DEFAULT_TIMEOUT_SECONDS` (10). At 5 Mbit/s of uplink it
|
|
71
|
+
# takes 11.8s and fails as a `write_timeout`; the run then lands in
|
|
72
|
+
# `log/test_results.jsonl` and the platform never sees it. Compressed, the
|
|
73
|
+
# same run takes 0.55s on that link and 2.8s on a 1 Mbit/s one. The 60 ms
|
|
74
|
+
# spent compressing is noise against a suite that took minutes.
|
|
75
|
+
#
|
|
76
|
+
# Two honesties about that ratio. It is *below* the 35x this change was
|
|
77
|
+
# proposed on, because SPGD-159 has since added `id` and `spec_file_path`
|
|
78
|
+
# to every row — re-measure rather than quote, is the lesson. And it is
|
|
79
|
+
# probably *above* what a real suite gets: synthetic example names repeat
|
|
80
|
+
# more than human ones do, so treat 21x as the optimistic end. Nothing here
|
|
81
|
+
# depends on the exact figure. Even a pessimistic 5x moves the 20k case
|
|
82
|
+
# from "cannot ship on a slow link" to "ships with room to spare", and
|
|
83
|
+
# 0.33 MiB is not a payload anyone needs to chunk.
|
|
84
|
+
#
|
|
85
|
+
# So: compression yes, batching and streaming no. Recorded rather than left
|
|
86
|
+
# implicit — an undocumented decision is one that gets re-opened in six
|
|
87
|
+
# months by someone who cannot tell it was ever made.
|
|
88
|
+
#
|
|
89
|
+
# === Why a threshold rather than always
|
|
90
|
+
#
|
|
91
|
+
# Below {GZIP_THRESHOLD_BYTES} the round trip buys nothing worth paying for
|
|
92
|
+
# in opacity: a small run stays identity-encoded, so it is still readable
|
|
93
|
+
# with `curl` and `tcpdump`, and the local-file and stub-server paths still
|
|
94
|
+
# show a human a JSON body. Compression is for the case that could not ship
|
|
95
|
+
# at all, not a uniform policy.
|
|
96
|
+
#
|
|
97
|
+
# Compression also sits *inside* the never-block-CI contract. A `Zlib`
|
|
98
|
+
# failure falls back to the identity body — which the platform still
|
|
99
|
+
# accepts — rather than raising: a run must not be lost to an optimisation.
|
|
100
|
+
#
|
|
101
|
+
# === The version floor this creates, which is a deploy order and not a merge order
|
|
102
|
+
#
|
|
103
|
+
# Sending `Content-Encoding: gzip` requires a platform that can inflate one,
|
|
104
|
+
# and that arrived with `GzipRequestBody` (SPGD-175). "Merge the platform PR
|
|
105
|
+
# first" is the half of this that is easy to say and is *not* sufficient:
|
|
106
|
+
# merge order only settles the source trees. What this gem actually talks to
|
|
107
|
+
# is a **deployment**.
|
|
108
|
+
#
|
|
109
|
+
# So a gem at this version pointed at an installation deployed before
|
|
110
|
+
# `GzipRequestBody` will 400 every run over {GZIP_THRESHOLD_BYTES} — the
|
|
111
|
+
# inflater is not there, the body reaches the JSON parser still gzipped, and
|
|
112
|
+
# `Api::V1::IngestsController` refuses it. {#deliver} answers
|
|
113
|
+
# `Result(outcome: :rejected, code: 400)`; there is no retry-as-identity, so
|
|
114
|
+
# the formatter falls back to `log/test_results.jsonl` and the run is lost
|
|
115
|
+
# to the platform. That is precisely the failure this change exists to
|
|
116
|
+
# close, reintroduced for precisely the large suites it targets — and it is
|
|
117
|
+
# silent apart from one stderr line, because CI still goes green.
|
|
118
|
+
#
|
|
119
|
+
# Written down rather than fixed, deliberately. A 400-triggered retry with
|
|
120
|
+
# the identity body would paper over it, but it doubles the request count on
|
|
121
|
+
# every genuinely-malformed payload and makes a client-side bug look like a
|
|
122
|
+
# flake; that is a contract decision, not a detail to slip in here. The
|
|
123
|
+
# honest statement of the constraint is a version floor: **this gem requires
|
|
124
|
+
# a platform deployment that includes `GzipRequestBody`.**
|
|
125
|
+
class Transport
|
|
126
|
+
# `config/routes.rb` mounts `post "ingest"` under the `/api/v1` scope.
|
|
127
|
+
# Part of the platform's contract, so it is not configurable — the
|
|
128
|
+
# `endpoint` setting is the installation's address and nothing more.
|
|
129
|
+
PATH = "/api/v1/ingest"
|
|
130
|
+
CONTENT_TYPE = "application/json"
|
|
131
|
+
USER_AGENT = "specguard-ruby/#{SpecGuard::VERSION}"
|
|
132
|
+
CONTENT_ENCODING = "gzip"
|
|
133
|
+
|
|
134
|
+
# Bodies at least this large are gzipped; smaller ones go identity. See
|
|
135
|
+
# the class comment for why this is a threshold and not a switch.
|
|
136
|
+
#
|
|
137
|
+
# 256 KiB is roughly where the round trip starts paying — a few thousand
|
|
138
|
+
# examples. It is a judgement call, not a measured optimum, and the only
|
|
139
|
+
# thing that depends on the exact number is how large a run has to be
|
|
140
|
+
# before `curl` stops showing you a readable body.
|
|
141
|
+
GZIP_THRESHOLD_BYTES = 256 * 1024
|
|
142
|
+
|
|
143
|
+
# What the ingest endpoint said, in the one form the caller has to handle.
|
|
144
|
+
#
|
|
145
|
+
# `outcome` is one of:
|
|
146
|
+
#
|
|
147
|
+
# :success a 2xx. `code` carries it (202 in the happy path).
|
|
148
|
+
# :rejected a non-2xx. `code` carries it; nothing was raised.
|
|
149
|
+
# :failed an exception was raised. `error` carries it.
|
|
150
|
+
#
|
|
151
|
+
# `reasons` is the refusal in the platform's own words — the strings
|
|
152
|
+
# `Api::BaseController#render_bad_request` puts on the wire, each naming
|
|
153
|
+
# one offending spec by index, file and line. It is populated only on
|
|
154
|
+
# `:rejected`, and only when the body actually said something this class
|
|
155
|
+
# could read; a proxy's HTML 502 leaves it nil and the reader gets
|
|
156
|
+
# exactly what they got before.
|
|
157
|
+
#
|
|
158
|
+
# `body` is the mirror of that on the accepting side: the 202 document
|
|
159
|
+
# `Api::V1::IngestsController` renders — `test_run_id`, `total_specs`,
|
|
160
|
+
# `annotated_specs`, `annotated_ratio`, `embedding_status` — parsed, and
|
|
161
|
+
# populated only on `:success`. It was discarded until {IngestCLI}
|
|
162
|
+
# needed to say *which run* a re-delivered line landed on, and it is
|
|
163
|
+
# added rather than substituted: nothing that read a {Result} before
|
|
164
|
+
# reads a different one now.
|
|
165
|
+
#
|
|
166
|
+
# It degrades to `nil` on exactly the terms `reasons` does, and for the
|
|
167
|
+
# same reason turned around: a 202 whose body will not parse is still an
|
|
168
|
+
# acceptance, and relabelling it would tell the operator something untrue
|
|
169
|
+
# about a run the platform has already stored. See {#test_run_id}.
|
|
170
|
+
Result = Struct.new(:outcome, :code, :error, :reasons, :body, keyword_init: true) do
|
|
171
|
+
# The status codes worth spelling out, because each implies a different
|
|
172
|
+
# thing for the reader to *do*. A 401 means "rotate or fix the key"; a
|
|
173
|
+
# 400 means "the payload this gem built was refused", which is a bug
|
|
174
|
+
# report and not a credentials problem. Printing a bare number would
|
|
175
|
+
# leave a CI operator to guess which of the two they are looking at.
|
|
176
|
+
ADVICE = {
|
|
177
|
+
400 => "the endpoint rejected the payload",
|
|
178
|
+
401 => "the API key was not accepted",
|
|
179
|
+
403 => "this API key may not write to that repository",
|
|
180
|
+
404 => "no ingest endpoint at that URL — check SPECGUARD_ENDPOINT",
|
|
181
|
+
413 => "the payload was too large for the endpoint",
|
|
182
|
+
429 => "rate limited by the endpoint"
|
|
183
|
+
}.freeze
|
|
184
|
+
|
|
185
|
+
# How many of the platform's reasons are spelled out, with an
|
|
186
|
+
# `and N more` tail standing in for the rest.
|
|
187
|
+
#
|
|
188
|
+
# A cap rather than the whole list, because the list is unbounded at
|
|
189
|
+
# its source: `Ingest::Payload` appends one error *per bad spec*, so a
|
|
190
|
+
# systemic client bug on a 20,000-example suite comes back with
|
|
191
|
+
# ~20,000 strings. Three is enough to see the shape of the failure —
|
|
192
|
+
# they are near-identical when the cause is systemic — and the count
|
|
193
|
+
# tells the reader how widespread it is.
|
|
194
|
+
MAX_RENDERED_REASONS = 3
|
|
195
|
+
|
|
196
|
+
# Ceiling on the spelled-out reasons, before the `and N more` tail,
|
|
197
|
+
# which is appended afterwards so the count can never be the thing that
|
|
198
|
+
# gets truncated away.
|
|
199
|
+
MAX_REASONS_LENGTH = 300
|
|
200
|
+
|
|
201
|
+
def success? = outcome == :success
|
|
202
|
+
|
|
203
|
+
# The id of the `TestRun` this delivery landed on, as the endpoint
|
|
204
|
+
# reported it — `nil` whenever that cannot be said honestly: a refusal,
|
|
205
|
+
# an exception, a 202 with an unreadable body, or one whose
|
|
206
|
+
# `test_run_id` is not a scalar.
|
|
207
|
+
#
|
|
208
|
+
# Returned as a String so two deliveries can be compared without
|
|
209
|
+
# caring whether the platform spells an id as a UUID or a number. Two
|
|
210
|
+
# accepted lines answering with the same value landed on the same row;
|
|
211
|
+
# that is the only claim about folding this class can support, and
|
|
212
|
+
# {IngestCLI} makes exactly that one.
|
|
213
|
+
#
|
|
214
|
+
# @return [String, nil]
|
|
215
|
+
def test_run_id
|
|
216
|
+
value = body.is_a?(Hash) ? body["test_run_id"] : nil
|
|
217
|
+
value.is_a?(String) || value.is_a?(Numeric) ? value.to_s : nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A single clause naming what went wrong, for the one stderr line a run
|
|
221
|
+
# is allowed. `nil` on success, because there is nothing to say.
|
|
222
|
+
#
|
|
223
|
+
# @return [String, nil]
|
|
224
|
+
def reason
|
|
225
|
+
case outcome
|
|
226
|
+
when :success then nil
|
|
227
|
+
when :rejected then [+"HTTP #{code}", ADVICE[code], rendered_reasons].compact.join(" — ")
|
|
228
|
+
else "#{error.class}: #{error.message}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
# The refusal, bounded and flattened to fit on the one line. `nil` when
|
|
235
|
+
# there is nothing readable to add, which is what keeps the bare
|
|
236
|
+
# `HTTP 400 — the endpoint rejected the payload` intact for every body
|
|
237
|
+
# this class could not make sense of.
|
|
238
|
+
def rendered_reasons
|
|
239
|
+
cleaned = Array(reasons).filter_map { |value| one_line(value) }
|
|
240
|
+
return nil if cleaned.empty?
|
|
241
|
+
|
|
242
|
+
rendered = cleaned.take(MAX_RENDERED_REASONS).join("; ")
|
|
243
|
+
rendered = "#{rendered[0, MAX_REASONS_LENGTH - 1]}…" if rendered.length > MAX_REASONS_LENGTH
|
|
244
|
+
|
|
245
|
+
omitted = cleaned.length - MAX_RENDERED_REASONS
|
|
246
|
+
omitted.positive? ? "#{rendered} and #{omitted} more" : rendered
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# One line, guaranteed. Newlines, tabs, ANSI escapes and every other
|
|
250
|
+
# control character collapse to a single space, so nothing arriving
|
|
251
|
+
# from the network can smear itself across a CI log or forge lines
|
|
252
|
+
# that look like they came from something else. `scrub` first because
|
|
253
|
+
# a body from a proxy is bytes, not necessarily valid UTF-8, and
|
|
254
|
+
# `gsub` raises on an invalid sequence.
|
|
255
|
+
#
|
|
256
|
+
# @return [String, nil] nil for a non-String and for anything that was
|
|
257
|
+
# only whitespace to begin with.
|
|
258
|
+
def one_line(value)
|
|
259
|
+
return nil unless value.is_a?(String)
|
|
260
|
+
|
|
261
|
+
text = value.scrub("").gsub(/[[:space:][:cntrl:]]+/, " ").strip
|
|
262
|
+
text.empty? ? nil : text
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @param endpoint [String, nil] the installation's base URL. Any trailing
|
|
267
|
+
# slashes are dropped; a path prefix is preserved, so an installation
|
|
268
|
+
# behind `https://tools.example.com/specguard` works.
|
|
269
|
+
# @param api_key [String, nil] sent verbatim as a Bearer token.
|
|
270
|
+
# @param timeout [Numeric, String, nil] seconds. Anything that is not a
|
|
271
|
+
# positive finite number falls back to the default rather than raising:
|
|
272
|
+
# a typo in `SPECGUARD_TIMEOUT` must not be able to fail a suite.
|
|
273
|
+
def initialize(endpoint:, api_key:, timeout: Configuration::DEFAULT_TIMEOUT_SECONDS)
|
|
274
|
+
@endpoint = endpoint
|
|
275
|
+
@api_key = api_key
|
|
276
|
+
@timeout = sanitize_timeout(timeout)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
attr_reader :timeout
|
|
280
|
+
|
|
281
|
+
# Where this transport would POST.
|
|
282
|
+
#
|
|
283
|
+
# @return [URI::HTTP]
|
|
284
|
+
# @raise [ArgumentError] when the endpoint is missing or is not an
|
|
285
|
+
# http(s) URL. Raised rather than returned because {#deliver} converts
|
|
286
|
+
# it into a {Result} like every other failure, and a caller asking for
|
|
287
|
+
# the URI directly wants to know.
|
|
288
|
+
def uri
|
|
289
|
+
@uri ||= build_uri
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# @param payload [Hash] the run, as {SpecGuard::RSpecFormatter#payload}
|
|
293
|
+
# assembles it. Sent as-is: its key names are already the platform's
|
|
294
|
+
# ingest contract, and reshaping it here would put the wire format two
|
|
295
|
+
# files away from the code that decides it.
|
|
296
|
+
# @return [Result] never nil, never raised through.
|
|
297
|
+
def deliver(payload)
|
|
298
|
+
response = post(JSON.generate(payload))
|
|
299
|
+
code = response.code.to_i
|
|
300
|
+
|
|
301
|
+
return Result.new(outcome: :success, code: code, body: success_body(response)) if
|
|
302
|
+
response.is_a?(Net::HTTPSuccess)
|
|
303
|
+
|
|
304
|
+
Result.new(outcome: :rejected, code: code, reasons: refusal_reasons(response))
|
|
305
|
+
rescue ScriptError, StandardError => e
|
|
306
|
+
# Connection refused, DNS failure, TLS failure, open/read timeout, a
|
|
307
|
+
# malformed endpoint — one family, one shape. `ScriptError` is in the
|
|
308
|
+
# list for the same reason the formatter's guard names it: an autoload
|
|
309
|
+
# blowing up under `net/http` is not a `StandardError`, and a bare
|
|
310
|
+
# rescue would let it escape and take the suite's exit code with it.
|
|
311
|
+
Result.new(outcome: :failed, error: e)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
# What the platform said about the run it accepted, or `nil` when the
|
|
317
|
+
# body did not say anything this class can use.
|
|
318
|
+
#
|
|
319
|
+
# The guard is {#refusal_reasons}'s, argued the other way round. There it
|
|
320
|
+
# keeps a refusal from being relabelled an exception; here it keeps an
|
|
321
|
+
# *acceptance* from being relabelled at all. The platform has stored the
|
|
322
|
+
# run by the time it writes this body, so a truncated read, an empty
|
|
323
|
+
# body, or a proxy that rewrote the 202 into HTML must cost the caller
|
|
324
|
+
# the decoration and nothing else. Every failure of reading degrades to
|
|
325
|
+
# `nil`, and `Result#success?` is what it was before this existed.
|
|
326
|
+
def success_body(response)
|
|
327
|
+
body = JSON.parse(response.body.to_s)
|
|
328
|
+
body if body.is_a?(Hash)
|
|
329
|
+
rescue ScriptError, StandardError
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# What the platform said about the refusal, or `nil` when the body did
|
|
334
|
+
# not say anything this class can use.
|
|
335
|
+
#
|
|
336
|
+
# `Api::BaseController` shapes both refusal bodies deliberately for a
|
|
337
|
+
# client to read: `render_bad_request` carries every validation failure
|
|
338
|
+
# in `details` and repeats the first in `message`, and
|
|
339
|
+
# `render_unauthorized` carries `message` alone. So `details` is
|
|
340
|
+
# preferred and `message` is the fallback, which is exactly the 400/401
|
|
341
|
+
# split rather than two special cases.
|
|
342
|
+
#
|
|
343
|
+
# == Why the guard is here and not the method-level one
|
|
344
|
+
#
|
|
345
|
+
# {#deliver}'s `rescue` turns anything raised into
|
|
346
|
+
# `Result(outcome: :failed)`, and a body that will not parse is not a
|
|
347
|
+
# delivery failure — the delivery plainly succeeded and was refused. To
|
|
348
|
+
# let a `JSON::ParserError` reach that rescue would relabel a 400 as an
|
|
349
|
+
# exception and tell the operator something untrue about what happened,
|
|
350
|
+
# for the sake of a decoration. So every failure of *reading* degrades to
|
|
351
|
+
# `nil` here and the run is told exactly what it was told before this
|
|
352
|
+
# existed. That covers the empty body, the HTML a proxy answers a 413
|
|
353
|
+
# with, a JSON scalar, and a read that dies part-way through a body whose
|
|
354
|
+
# status line had already arrived.
|
|
355
|
+
def refusal_reasons(response)
|
|
356
|
+
body = JSON.parse(response.body.to_s)
|
|
357
|
+
return nil unless body.is_a?(Hash)
|
|
358
|
+
|
|
359
|
+
details = body["details"]
|
|
360
|
+
return details if details.is_a?(Array) && details.any? && details.all?(String)
|
|
361
|
+
|
|
362
|
+
message = body["message"]
|
|
363
|
+
[message] if message.is_a?(String)
|
|
364
|
+
rescue ScriptError, StandardError
|
|
365
|
+
nil
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def post(body)
|
|
369
|
+
target = uri
|
|
370
|
+
|
|
371
|
+
http = Net::HTTP.new(target.host, target.port)
|
|
372
|
+
http.use_ssl = target.scheme == "https"
|
|
373
|
+
# All three, not just the two the spec names. Compression takes a
|
|
374
|
+
# 20k-example run from 7.01 MiB to 0.33 MiB, which is what puts the
|
|
375
|
+
# write comfortably inside the budget rather than past it — but a peer
|
|
376
|
+
# that accepts the connection and then stops reading still hangs in the
|
|
377
|
+
# *write* whatever the body's size, so the timeout is the thing that
|
|
378
|
+
# bounds it. Same unbounded wait, reached by a different door.
|
|
379
|
+
http.open_timeout = @timeout
|
|
380
|
+
http.read_timeout = @timeout
|
|
381
|
+
http.write_timeout = @timeout
|
|
382
|
+
|
|
383
|
+
http.start { |connection| connection.request(build_request(target, body)) }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def build_request(target, body)
|
|
387
|
+
request = Net::HTTP::Post.new(target.request_uri)
|
|
388
|
+
# `Api::BaseController#bearer_token` matches /\ABearer\s+(?<token>.+)\z/i.
|
|
389
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
390
|
+
# Describes the body *inside* any encoding, which is what
|
|
391
|
+
# `Content-Type` means — the platform's `GzipRequestBody` inflates and
|
|
392
|
+
# then hands an ordinary JSON request downstream.
|
|
393
|
+
request["Content-Type"] = CONTENT_TYPE
|
|
394
|
+
request["Accept"] = CONTENT_TYPE
|
|
395
|
+
request["User-Agent"] = USER_AGENT
|
|
396
|
+
|
|
397
|
+
compressed = compress(body)
|
|
398
|
+
request["Content-Encoding"] = CONTENT_ENCODING if compressed
|
|
399
|
+
# `Net::HTTP::Post#body=` sets `Content-Length` from what it is given,
|
|
400
|
+
# so the length always describes the bytes actually on the wire.
|
|
401
|
+
request.body = compressed || body
|
|
402
|
+
request
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# The compressed body, or `nil` to mean "send it as it is" — for a run
|
|
406
|
+
# under the threshold and, deliberately, for a compression that failed.
|
|
407
|
+
#
|
|
408
|
+
# Returning `nil` on failure rather than letting it out is the whole
|
|
409
|
+
# point: an identity body is something the platform accepts, so a broken
|
|
410
|
+
# `Zlib` costs a large run some bandwidth and nothing else. Letting the
|
|
411
|
+
# error reach {#deliver} would turn it into `Result(outcome: :failed)`
|
|
412
|
+
# and lose the run to a *saving*.
|
|
413
|
+
#
|
|
414
|
+
# `ScriptError, StandardError` matches {#deliver}'s own guard rather than
|
|
415
|
+
# naming `Zlib::Error`: the failure worth catching here is as likely to be
|
|
416
|
+
# the `zlib` extension missing from a stripped-down Ruby, which is a
|
|
417
|
+
# `LoadError` and so a `ScriptError`, as it is a compression fault.
|
|
418
|
+
def compress(body)
|
|
419
|
+
return nil if body.bytesize < GZIP_THRESHOLD_BYTES
|
|
420
|
+
|
|
421
|
+
Zlib.gzip(body)
|
|
422
|
+
rescue ScriptError, StandardError
|
|
423
|
+
nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def build_uri
|
|
427
|
+
base = @endpoint.to_s.strip
|
|
428
|
+
raise ArgumentError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if base.empty?
|
|
429
|
+
|
|
430
|
+
parsed = URI.parse(base.sub(%r{/+\z}, "") + PATH)
|
|
431
|
+
# `URI::HTTPS < URI::HTTP`, so this admits both and rejects the
|
|
432
|
+
# scheme-less `specguard.example.com` that `URI.parse` happily returns
|
|
433
|
+
# as a `URI::Generic` with a nil host — which `Net::HTTP` would then
|
|
434
|
+
# try to connect to.
|
|
435
|
+
raise ArgumentError, "endpoint must be an http:// or https:// URL, got #{base.inspect}" unless
|
|
436
|
+
parsed.is_a?(URI::HTTP) && !parsed.host.to_s.empty?
|
|
437
|
+
|
|
438
|
+
parsed
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def sanitize_timeout(value)
|
|
442
|
+
parsed = Float(value, exception: false)
|
|
443
|
+
return Configuration::DEFAULT_TIMEOUT_SECONDS unless parsed&.finite? && parsed.positive?
|
|
444
|
+
|
|
445
|
+
parsed
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|