specguard-rspec 0.2.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/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +735 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-lint +34 -0
- data/lib/specguard/rspec/annotation_lookup.rb +286 -0
- data/lib/specguard/rspec/annotation_scanner.rb +144 -0
- data/lib/specguard/rspec/cli.rb +466 -0
- data/lib/specguard/rspec/configuration.rb +459 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +63 -0
- data/lib/specguard/rspec/formatter.rb +919 -0
- data/lib/specguard/rspec/json_reporter.rb +163 -0
- data/lib/specguard/rspec/linter.rb +134 -0
- data/lib/specguard/rspec/payload_normalizer.rb +139 -0
- data/lib/specguard/rspec/scanner.rb +217 -0
- data/lib/specguard/rspec/schema.rb +122 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +310 -0
- data/lib/specguard/rspec/validator_backend.rb +1071 -0
- data/lib/specguard/rspec/version.rb +7 -0
- data/lib/specguard/rspec/violation_renderer.rb +372 -0
- data/lib/specguard/rspec.rb +70 -0
- data/script/bump-version.sh +115 -0
- metadata +88 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "json_schemer"
|
|
5
|
+
|
|
6
|
+
module SpecGuard
|
|
7
|
+
module RSpec
|
|
8
|
+
# The vendored OpenTestIntent schema could not be loaded.
|
|
9
|
+
#
|
|
10
|
+
# Deliberately *not* a {UsageError} even though both map to exit 2: this is
|
|
11
|
+
# the linter being broken, not the caller misusing it, and the message
|
|
12
|
+
# wording follows the reference tool rather than the CLI's own. Keeping the
|
|
13
|
+
# two distinct is what lets the CLI print
|
|
14
|
+
# `error: could not load schema <path>: ...` — byte-parity with
|
|
15
|
+
# `bin/validate-intent:858-862` — for one and `specguard-lint: error: ...`
|
|
16
|
+
# for the other.
|
|
17
|
+
class SchemaError < Error; end
|
|
18
|
+
|
|
19
|
+
# The OpenTestIntent v1 schema, loaded and applied.
|
|
20
|
+
#
|
|
21
|
+
# == Why loading failure is exit 2 and not exit 1
|
|
22
|
+
#
|
|
23
|
+
# The exit contract (SPGD-12 §1) spends `1` on "an annotation is
|
|
24
|
+
# malformed". If a packaging accident leaves the vendored schema out of the
|
|
25
|
+
# gem, the obvious Ruby implementation lets the exception escape and Ruby
|
|
26
|
+
# exits 1 — so CI reports a malformed annotation and a developer goes
|
|
27
|
+
# hunting for a bad annotation that does not exist. The reference tool
|
|
28
|
+
# already ruled on this:
|
|
29
|
+
#
|
|
30
|
+
# except (OSError, json.JSONDecodeError) as exc:
|
|
31
|
+
# print("error: could not load schema %s: %s" % (SCHEMA_PATH, exc), file=sys.stderr)
|
|
32
|
+
# return 2
|
|
33
|
+
# # open-test-intent, bin/validate-intent:858-862
|
|
34
|
+
#
|
|
35
|
+
# Hence {SchemaError}: every way loading can fail is caught here and
|
|
36
|
+
# retyped, so the CLI can map the whole class of them to 2 without
|
|
37
|
+
# inspecting exception classes from three different libraries.
|
|
38
|
+
#
|
|
39
|
+
# The CLI loads this **before** it scans anything, so a broken schema can
|
|
40
|
+
# never produce a run that reports "0 malformed" having validated nothing.
|
|
41
|
+
class Schema
|
|
42
|
+
# @param path [String] the vendored schema
|
|
43
|
+
# @return [Schema]
|
|
44
|
+
# @raise [SchemaError] if it cannot be read, parsed, or compiled
|
|
45
|
+
def self.load(path = SCHEMA_PATH)
|
|
46
|
+
document = ::JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
47
|
+
new(document: document, path: path)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
# Intentionally broad. Reading can raise SystemCallError/IOError,
|
|
50
|
+
# parsing JSON::ParserError, and compiling or meta-validating whatever
|
|
51
|
+
# json_schemer decides an unusable schema document deserves. All of
|
|
52
|
+
# them mean the same thing to the caller, and all of them must be 2
|
|
53
|
+
# rather than an uncaught exception's 1.
|
|
54
|
+
raise SchemaError, "could not load schema #{path}: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :document, :path
|
|
58
|
+
|
|
59
|
+
def initialize(document:, path: SCHEMA_PATH, renderer: ViolationRenderer.new)
|
|
60
|
+
@document = document
|
|
61
|
+
@path = path
|
|
62
|
+
@renderer = renderer
|
|
63
|
+
@schemer = JSONSchemer.schema(document)
|
|
64
|
+
reject_unusable_schema!
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param intent [Object] one parsed annotation
|
|
68
|
+
# @return [Array<String>] reason lines in the reference tool's grammar
|
|
69
|
+
# and order; empty means the annotation is valid
|
|
70
|
+
#
|
|
71
|
+
# The **validator** decides the verdict; the renderer only decides the
|
|
72
|
+
# wording. Deriving "is this annotation valid?" from "could we phrase a
|
|
73
|
+
# sentence about it?" would make an unphrasable violation and a clean
|
|
74
|
+
# annotation the same state — and under {Linter} that state is a green
|
|
75
|
+
# run, which is this project's signature vacuous green (KB SPGD-78)
|
|
76
|
+
# arriving through the one door the whole rendering strategy exists to
|
|
77
|
+
# close. The gemspec pins `~> 2.5`, which admits 2.6 and beyond; the
|
|
78
|
+
# renderer is built from structured fields precisely so a bump degrades
|
|
79
|
+
# to *wrong-looking text* rather than to silence, and this is what keeps
|
|
80
|
+
# that promise true for error shapes it has never seen.
|
|
81
|
+
def violations(intent)
|
|
82
|
+
errors = @schemer.validate(intent).to_a
|
|
83
|
+
return [] if errors.empty?
|
|
84
|
+
|
|
85
|
+
rendered = @renderer.render(errors, intent)
|
|
86
|
+
rendered.empty? ? errors.map { |error| unrenderable_reason(error) } : rendered
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Last resort: json_schemer's own sentence, and failing even that, the
|
|
92
|
+
# error's structure. Both are worse output than the reference grammar.
|
|
93
|
+
# Both are enormously better than reporting the annotation as clean.
|
|
94
|
+
def unrenderable_reason(error)
|
|
95
|
+
error["error"] || "does not match the schema at #{error['schema_pointer']}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Checks the vendored document against draft-07's own meta-schema before
|
|
99
|
+
# anything is validated against it.
|
|
100
|
+
#
|
|
101
|
+
# This is not belt-and-braces, it closes a real hole. json_schemer is
|
|
102
|
+
# permissive about nonsense in a *schema*: given `{"type": 42}` it
|
|
103
|
+
# compiles happily and then treats the keyword as absent — so a schema
|
|
104
|
+
# file corrupted in a way that still parses as JSON produces a linter
|
|
105
|
+
# that **accepts every annotation** and reports a clean run. That is this
|
|
106
|
+
# project's signature vacuous-green defect (KB SPGD-78) arriving through
|
|
107
|
+
# the back door, and it is worse than a crash because it looks like
|
|
108
|
+
# success.
|
|
109
|
+
#
|
|
110
|
+
# Some malformations are lazier still — `{"required": "nope"}` compiles,
|
|
111
|
+
# passes nothing, and raises NoMethodError from inside the first
|
|
112
|
+
# `#validate`. Meta-validating here converts that whole family into a
|
|
113
|
+
# SchemaError at load time, which the CLI reports as
|
|
114
|
+
# `error: could not load schema ...` and exits 2 on.
|
|
115
|
+
def reject_unusable_schema!
|
|
116
|
+
return if @schemer.valid_schema?
|
|
117
|
+
|
|
118
|
+
raise Error, "not a valid draft-07 schema (#{@schemer.validate_schema.first&.fetch('error')})"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://specguard.dev/schemas/open-test-intent.v1.json",
|
|
4
|
+
"title": "OpenTestIntent v1",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"entity": { "type": "string", "minLength": 2 },
|
|
9
|
+
"action": { "type": "string", "minLength": 2 },
|
|
10
|
+
"behavior": { "type": "string", "minLength": 15 },
|
|
11
|
+
"layer": { "type": "string", "enum": ["unit", "integration", "request", "system"] },
|
|
12
|
+
"preconditions": { "type": "array", "items": { "type": "string" } }
|
|
13
|
+
},
|
|
14
|
+
"required": ["entity", "action", "behavior", "layer"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
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-rspec/#{SpecGuard::RSpec::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
|
+
Result = Struct.new(:outcome, :code, :error, keyword_init: true) do
|
|
151
|
+
# The status codes worth spelling out, because each implies a different
|
|
152
|
+
# thing for the reader to *do*. A 401 means "rotate or fix the key"; a
|
|
153
|
+
# 400 means "the payload this gem built was refused", which is a bug
|
|
154
|
+
# report and not a credentials problem. Printing a bare number would
|
|
155
|
+
# leave a CI operator to guess which of the two they are looking at.
|
|
156
|
+
ADVICE = {
|
|
157
|
+
400 => "the endpoint rejected the payload",
|
|
158
|
+
401 => "the API key was not accepted",
|
|
159
|
+
403 => "this API key may not write to that repository",
|
|
160
|
+
404 => "no ingest endpoint at that URL — check SPECGUARD_ENDPOINT",
|
|
161
|
+
413 => "the payload was too large for the endpoint",
|
|
162
|
+
429 => "rate limited by the endpoint"
|
|
163
|
+
}.freeze
|
|
164
|
+
|
|
165
|
+
def success? = outcome == :success
|
|
166
|
+
|
|
167
|
+
# A single clause naming what went wrong, for the one stderr line a run
|
|
168
|
+
# is allowed. `nil` on success, because there is nothing to say.
|
|
169
|
+
#
|
|
170
|
+
# @return [String, nil]
|
|
171
|
+
def reason
|
|
172
|
+
case outcome
|
|
173
|
+
when :success then nil
|
|
174
|
+
when :rejected then [+"HTTP #{code}", ADVICE[code]].compact.join(" — ")
|
|
175
|
+
else "#{error.class}: #{error.message}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param endpoint [String, nil] the installation's base URL. Any trailing
|
|
181
|
+
# slashes are dropped; a path prefix is preserved, so an installation
|
|
182
|
+
# behind `https://tools.example.com/specguard` works.
|
|
183
|
+
# @param api_key [String, nil] sent verbatim as a Bearer token.
|
|
184
|
+
# @param timeout [Numeric, String, nil] seconds. Anything that is not a
|
|
185
|
+
# positive finite number falls back to the default rather than raising:
|
|
186
|
+
# a typo in `SPECGUARD_TIMEOUT` must not be able to fail a suite.
|
|
187
|
+
def initialize(endpoint:, api_key:, timeout: Configuration::DEFAULT_TIMEOUT_SECONDS)
|
|
188
|
+
@endpoint = endpoint
|
|
189
|
+
@api_key = api_key
|
|
190
|
+
@timeout = sanitize_timeout(timeout)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
attr_reader :timeout
|
|
194
|
+
|
|
195
|
+
# Where this transport would POST.
|
|
196
|
+
#
|
|
197
|
+
# @return [URI::HTTP]
|
|
198
|
+
# @raise [ArgumentError] when the endpoint is missing or is not an
|
|
199
|
+
# http(s) URL. Raised rather than returned because {#deliver} converts
|
|
200
|
+
# it into a {Result} like every other failure, and a caller asking for
|
|
201
|
+
# the URI directly wants to know.
|
|
202
|
+
def uri
|
|
203
|
+
@uri ||= build_uri
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @param payload [Hash] the run, as {SpecGuard::RSpecFormatter#payload}
|
|
207
|
+
# assembles it. Sent as-is: its key names are already the platform's
|
|
208
|
+
# ingest contract, and reshaping it here would put the wire format two
|
|
209
|
+
# files away from the code that decides it.
|
|
210
|
+
# @return [Result] never nil, never raised through.
|
|
211
|
+
def deliver(payload)
|
|
212
|
+
response = post(JSON.generate(payload))
|
|
213
|
+
code = response.code.to_i
|
|
214
|
+
|
|
215
|
+
return Result.new(outcome: :success, code: code) if response.is_a?(Net::HTTPSuccess)
|
|
216
|
+
|
|
217
|
+
Result.new(outcome: :rejected, code: code)
|
|
218
|
+
rescue ScriptError, StandardError => e
|
|
219
|
+
# Connection refused, DNS failure, TLS failure, open/read timeout, a
|
|
220
|
+
# malformed endpoint — one family, one shape. `ScriptError` is in the
|
|
221
|
+
# list for the same reason the formatter's guard names it: an autoload
|
|
222
|
+
# blowing up under `net/http` is not a `StandardError`, and a bare
|
|
223
|
+
# rescue would let it escape and take the suite's exit code with it.
|
|
224
|
+
Result.new(outcome: :failed, error: e)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def post(body)
|
|
230
|
+
target = uri
|
|
231
|
+
|
|
232
|
+
http = Net::HTTP.new(target.host, target.port)
|
|
233
|
+
http.use_ssl = target.scheme == "https"
|
|
234
|
+
# All three, not just the two the spec names. Compression takes a
|
|
235
|
+
# 20k-example run from 7.01 MiB to 0.33 MiB, which is what puts the
|
|
236
|
+
# write comfortably inside the budget rather than past it — but a peer
|
|
237
|
+
# that accepts the connection and then stops reading still hangs in the
|
|
238
|
+
# *write* whatever the body's size, so the timeout is the thing that
|
|
239
|
+
# bounds it. Same unbounded wait, reached by a different door.
|
|
240
|
+
http.open_timeout = @timeout
|
|
241
|
+
http.read_timeout = @timeout
|
|
242
|
+
http.write_timeout = @timeout
|
|
243
|
+
|
|
244
|
+
http.start { |connection| connection.request(build_request(target, body)) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def build_request(target, body)
|
|
248
|
+
request = Net::HTTP::Post.new(target.request_uri)
|
|
249
|
+
# `Api::BaseController#bearer_token` matches /\ABearer\s+(?<token>.+)\z/i.
|
|
250
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
251
|
+
# Describes the body *inside* any encoding, which is what
|
|
252
|
+
# `Content-Type` means — the platform's `GzipRequestBody` inflates and
|
|
253
|
+
# then hands an ordinary JSON request downstream.
|
|
254
|
+
request["Content-Type"] = CONTENT_TYPE
|
|
255
|
+
request["Accept"] = CONTENT_TYPE
|
|
256
|
+
request["User-Agent"] = USER_AGENT
|
|
257
|
+
|
|
258
|
+
compressed = compress(body)
|
|
259
|
+
request["Content-Encoding"] = CONTENT_ENCODING if compressed
|
|
260
|
+
# `Net::HTTP::Post#body=` sets `Content-Length` from what it is given,
|
|
261
|
+
# so the length always describes the bytes actually on the wire.
|
|
262
|
+
request.body = compressed || body
|
|
263
|
+
request
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The compressed body, or `nil` to mean "send it as it is" — for a run
|
|
267
|
+
# under the threshold and, deliberately, for a compression that failed.
|
|
268
|
+
#
|
|
269
|
+
# Returning `nil` on failure rather than letting it out is the whole
|
|
270
|
+
# point: an identity body is something the platform accepts, so a broken
|
|
271
|
+
# `Zlib` costs a large run some bandwidth and nothing else. Letting the
|
|
272
|
+
# error reach {#deliver} would turn it into `Result(outcome: :failed)`
|
|
273
|
+
# and lose the run to a *saving*.
|
|
274
|
+
#
|
|
275
|
+
# `ScriptError, StandardError` matches {#deliver}'s own guard rather than
|
|
276
|
+
# naming `Zlib::Error`: the failure worth catching here is as likely to be
|
|
277
|
+
# the `zlib` extension missing from a stripped-down Ruby, which is a
|
|
278
|
+
# `LoadError` and so a `ScriptError`, as it is a compression fault.
|
|
279
|
+
def compress(body)
|
|
280
|
+
return nil if body.bytesize < GZIP_THRESHOLD_BYTES
|
|
281
|
+
|
|
282
|
+
Zlib.gzip(body)
|
|
283
|
+
rescue ScriptError, StandardError
|
|
284
|
+
nil
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def build_uri
|
|
288
|
+
base = @endpoint.to_s.strip
|
|
289
|
+
raise ArgumentError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if base.empty?
|
|
290
|
+
|
|
291
|
+
parsed = URI.parse(base.sub(%r{/+\z}, "") + PATH)
|
|
292
|
+
# `URI::HTTPS < URI::HTTP`, so this admits both and rejects the
|
|
293
|
+
# scheme-less `specguard.example.com` that `URI.parse` happily returns
|
|
294
|
+
# as a `URI::Generic` with a nil host — which `Net::HTTP` would then
|
|
295
|
+
# try to connect to.
|
|
296
|
+
raise ArgumentError, "endpoint must be an http:// or https:// URL, got #{base.inspect}" unless
|
|
297
|
+
parsed.is_a?(URI::HTTP) && !parsed.host.to_s.empty?
|
|
298
|
+
|
|
299
|
+
parsed
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def sanitize_timeout(value)
|
|
303
|
+
parsed = Float(value, exception: false)
|
|
304
|
+
return Configuration::DEFAULT_TIMEOUT_SECONDS unless parsed&.finite? && parsed.positive?
|
|
305
|
+
|
|
306
|
+
parsed
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|