knoxcall 0.0.1 → 1.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.
- checksums.yaml +4 -4
- data/LICENSE +201 -0
- data/README.md +439 -2
- data/exe/knoxcall +8 -0
- data/lib/knoxcall/bootstrap.rb +70 -0
- data/lib/knoxcall/bound_route.rb +47 -0
- data/lib/knoxcall/cli/ai.rb +79 -0
- data/lib/knoxcall/cli/ai_control.rb +275 -0
- data/lib/knoxcall/cli/common.rb +96 -0
- data/lib/knoxcall/cli/init.rb +94 -0
- data/lib/knoxcall/cli/login.rb +306 -0
- data/lib/knoxcall/cli/logout.rb +41 -0
- data/lib/knoxcall/cli/whoami.rb +29 -0
- data/lib/knoxcall/cli.rb +377 -0
- data/lib/knoxcall/client.rb +1025 -0
- data/lib/knoxcall/credentials_file.rb +442 -0
- data/lib/knoxcall/dpop.rb +79 -0
- data/lib/knoxcall/egress_observations.rb +372 -0
- data/lib/knoxcall/errors.rb +304 -0
- data/lib/knoxcall/intercept_patch.rb +181 -0
- data/lib/knoxcall/intercept_pipeline.rb +455 -0
- data/lib/knoxcall/intercept_resolver.rb +140 -0
- data/lib/knoxcall/intercept_store.rb +203 -0
- data/lib/knoxcall/login.rb +144 -0
- data/lib/knoxcall/resources/account.rb +12 -0
- data/lib/knoxcall/resources/agents.rb +25 -0
- data/lib/knoxcall/resources/ai_gateway.rb +417 -0
- data/lib/knoxcall/resources/api_keys.rb +35 -0
- data/lib/knoxcall/resources/audit_logs.rb +45 -0
- data/lib/knoxcall/resources/clients.rb +39 -0
- data/lib/knoxcall/resources/crypto.rb +122 -0
- data/lib/knoxcall/resources/dynamic_db.rb +68 -0
- data/lib/knoxcall/resources/environments.rb +16 -0
- data/lib/knoxcall/resources/logs.rb +51 -0
- data/lib/knoxcall/resources/oauth_clients.rb +34 -0
- data/lib/knoxcall/resources/opportunities.rb +61 -0
- data/lib/knoxcall/resources/pki.rb +41 -0
- data/lib/knoxcall/resources/roles.rb +27 -0
- data/lib/knoxcall/resources/routes.rb +53 -0
- data/lib/knoxcall/resources/secrets.rb +98 -0
- data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
- data/lib/knoxcall/resources/vaults.rb +77 -0
- data/lib/knoxcall/resources/webhooks.rb +48 -0
- data/lib/knoxcall/resources/workflows.rb +86 -0
- data/lib/knoxcall/resources/wrap.rb +352 -0
- data/lib/knoxcall/route_refusal.rb +67 -0
- data/lib/knoxcall/signup.rb +122 -0
- data/lib/knoxcall/token_exchange.rb +169 -0
- data/lib/knoxcall/ulid.rb +19 -0
- data/lib/knoxcall/warnings.rb +63 -0
- data/lib/knoxcall/workload_provider.rb +192 -0
- data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
- data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
- data/lib/knoxcall/wrap_transport.rb +139 -0
- data/lib/knoxcall.rb +45 -1
- metadata +70 -9
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module KnoxCall
|
|
4
|
+
module Resources
|
|
5
|
+
# The server wraps every JSON success response in +{ "data": ..., "meta": ... }+
|
|
6
|
+
# (see sdk/PARITY.md §4). Resource methods unwrap:
|
|
7
|
+
#
|
|
8
|
+
# - single-object methods return +data+ (via {#unwrap});
|
|
9
|
+
# - paginated lists return the envelope as-is —
|
|
10
|
+
# <tt>{"data" => [...], "meta" => {"total", "page", "per_page",
|
|
11
|
+
# "total_pages", "request_id"}}</tt> — and take +page+ / +per_page+
|
|
12
|
+
# params (server default 20, cap 100);
|
|
13
|
+
# - bare-array endpoints unwrap +data+ to a plain Array (no page params);
|
|
14
|
+
# - the +each+/+each_*+ auto-pagers walk pages until
|
|
15
|
+
# +page >= meta.total_pages+ or an empty page.
|
|
16
|
+
#
|
|
17
|
+
# TWO endpoints are keyset/cursor paginated instead, deliberately:
|
|
18
|
+
# <tt>GET /v1/audit-logs/events</tt> and <tt>GET /v1/logs</tt>. Offset
|
|
19
|
+
# pagination over a table that is being written to skips and repeats rows
|
|
20
|
+
# with no way to tell which — fine for a console, wrong for a feed. Use
|
|
21
|
+
# {#paginate_cursor} for those. (Earlier revisions of this comment said
|
|
22
|
+
# there was no cursor pagination anywhere on the API; that stopped being
|
|
23
|
+
# true when the audit event feed shipped.)
|
|
24
|
+
module UnwrapsEnvelope
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Unwrap +data+ from a {data, meta} envelope. Tolerates a bare payload
|
|
28
|
+
# (self-hosted / older servers) by passing it through unchanged.
|
|
29
|
+
def unwrap(response)
|
|
30
|
+
response.is_a?(Hash) && response.key?("data") ? response["data"] : response
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Unwrap +data+ and fold the response's top-level +warning+ into it —
|
|
34
|
+
# the oauth-clients create/rotate-secret endpoints bypass the standard
|
|
35
|
+
# success() wrapper server-side and carry the warning beside +data+.
|
|
36
|
+
def unwrap_with_warning(response)
|
|
37
|
+
data = unwrap(response)
|
|
38
|
+
if data.is_a?(Hash) && response.is_a?(Hash) && response["warning"].is_a?(String)
|
|
39
|
+
data = data.merge("warning" => response["warning"])
|
|
40
|
+
end
|
|
41
|
+
data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Page-based auto-pager (PARITY §4): starts at +params[:page]+ (default
|
|
45
|
+
# 1), fetches a page via the block, yields each row, and stops when
|
|
46
|
+
# +page >= meta.total_pages+ or a page comes back empty (defensive).
|
|
47
|
+
# Lazy: nothing is fetched until the Enumerator is consumed.
|
|
48
|
+
def paginate(params, &fetch_page)
|
|
49
|
+
Enumerator.new do |yielder|
|
|
50
|
+
page = (params[:page] || 1).to_i
|
|
51
|
+
page = 1 if page < 1
|
|
52
|
+
loop do
|
|
53
|
+
result = fetch_page.call(params.merge(page: page))
|
|
54
|
+
rows = result.is_a?(Hash) && result["data"].is_a?(Array) ? result["data"] : []
|
|
55
|
+
rows.each { |row| yielder << row }
|
|
56
|
+
total_pages = result.is_a?(Hash) ? result.dig("meta", "total_pages") : nil
|
|
57
|
+
break if rows.empty? || !total_pages.is_a?(Numeric) || page >= total_pages
|
|
58
|
+
page += 1
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Cursor auto-pager for the keyset feeds. Starts at +params[:cursor]+,
|
|
64
|
+
# yields each row, and STOPS when the server reports
|
|
65
|
+
# +meta.next_cursor == nil+ — which means the feed is drained to the
|
|
66
|
+
# watermark, NOT that it has ended. To keep following it, call again
|
|
67
|
+
# later with the last cursor you saw; an Enumerator that blocked forever
|
|
68
|
+
# would be unusable from a batch job.
|
|
69
|
+
#
|
|
70
|
+
# +next_cursor+ is OPAQUE: it is passed back verbatim and never parsed.
|
|
71
|
+
# Delivery is at-least-once — dedupe on +meta.dedupe_on+.
|
|
72
|
+
# Lazy: nothing is fetched until the Enumerator is consumed.
|
|
73
|
+
def paginate_cursor(params, &fetch_page)
|
|
74
|
+
Enumerator.new do |yielder|
|
|
75
|
+
cursor = params[:cursor]
|
|
76
|
+
loop do
|
|
77
|
+
result = fetch_page.call(cursor.nil? ? params : params.merge(cursor: cursor))
|
|
78
|
+
rows = result.is_a?(Hash) && result["data"].is_a?(Array) ? result["data"] : []
|
|
79
|
+
rows.each { |row| yielder << row }
|
|
80
|
+
nxt = result.is_a?(Hash) ? result.dig("meta", "next_cursor") : nil
|
|
81
|
+
break if nxt.nil?
|
|
82
|
+
cursor = nxt
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def encode(s) = URI.encode_www_form_component(s.to_s)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module Resources
|
|
3
|
+
class Vaults
|
|
4
|
+
include UnwrapsEnvelope
|
|
5
|
+
|
|
6
|
+
def initialize(client) = @client = client
|
|
7
|
+
|
|
8
|
+
# Paginated. Params: page (default 1), per_page (default 20, max 100).
|
|
9
|
+
# Returns the {data, meta} envelope.
|
|
10
|
+
def list(**params) = @client.request("GET", "/v1/vaults", query: params.empty? ? nil : params)
|
|
11
|
+
|
|
12
|
+
# Yield every vault, walking pages transparently. Returns a lazy
|
|
13
|
+
# Enumerator when no block is given.
|
|
14
|
+
def each(**params, &block)
|
|
15
|
+
enum = paginate(params) { |p| list(**p) }
|
|
16
|
+
block ? enum.each(&block) : enum
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns the vault plus "stats" => {token_count, active_count, expiring_in_24h}.
|
|
20
|
+
def get(name_or_id) = unwrap(@client.request("GET", "/v1/vaults/#{encode(name_or_id)}"))
|
|
21
|
+
def create(**input) = unwrap(@client.request("POST", "/v1/vaults", body: input))
|
|
22
|
+
def update(name_or_id, **input) = unwrap(@client.request("PATCH", "/v1/vaults/#{encode(name_or_id)}", body: input))
|
|
23
|
+
# Returns {"deleted" => true}.
|
|
24
|
+
def delete(name_or_id) = unwrap(@client.request("DELETE", "/v1/vaults/#{encode(name_or_id)}"))
|
|
25
|
+
# Returns {"new_version" => Integer}.
|
|
26
|
+
def rotate(name_or_id) = unwrap(@client.request("POST", "/v1/vaults/#{encode(name_or_id)}/rotate", body: {}))
|
|
27
|
+
|
|
28
|
+
# -- Token operations --
|
|
29
|
+
|
|
30
|
+
# Returns {"id", "token", "expires_at", "created_at"}.
|
|
31
|
+
# card_exp_month / card_exp_year are the CARD's own expiry, for a `pan`
|
|
32
|
+
# vault only -- not ttl_seconds, which is how long the TOKEN lives. Both
|
|
33
|
+
# or neither; the year is four digits (2029, never 29). Supplying them
|
|
34
|
+
# subscribes the token to the `vault.token.expiring` webhook, emitted 60
|
|
35
|
+
# and 30 days before the card expires. Offering them to a non-`pan` vault
|
|
36
|
+
# raises a validation_error. The result carries "card_expires_on".
|
|
37
|
+
#
|
|
38
|
+
# The result also carries "card_funding_type" ("credit" / "debit" /
|
|
39
|
+
# "prepaid") and "card_issuing_country" (ISO 3166-1 alpha-2), derived
|
|
40
|
+
# from the card's first six digits. BOTH ARE nil ON EVERY TOKEN TODAY
|
|
41
|
+
# and will be until KnoxCall licenses a BIN table -- treat them as
|
|
42
|
+
# optional indefinitely.
|
|
43
|
+
def tokenize(name_or_id, value:, metadata: nil, ttl_seconds: nil, card_exp_month: nil, card_exp_year: nil)
|
|
44
|
+
body = { value: value }
|
|
45
|
+
body[:metadata] = metadata if metadata
|
|
46
|
+
body[:ttl_seconds] = ttl_seconds if ttl_seconds
|
|
47
|
+
body[:card_exp_month] = card_exp_month if card_exp_month
|
|
48
|
+
body[:card_exp_year] = card_exp_year if card_exp_year
|
|
49
|
+
unwrap(@client.request("POST", "/v1/vaults/#{encode(name_or_id)}/tokens", body: body))
|
|
50
|
+
end
|
|
51
|
+
# Returns {"tokens" => [...], "count" => Integer}. Each value needs "value";
|
|
52
|
+
# optional "metadata", "ttl_seconds", and -- for a `pan` vault --
|
|
53
|
+
# "card_exp_month" / "card_exp_year". A refusal names the offending index
|
|
54
|
+
# and rolls the whole batch back.
|
|
55
|
+
def bulk_tokenize(name_or_id, values) = unwrap(@client.request("POST", "/v1/vaults/#{encode(name_or_id)}/tokens/bulk", body: { values: values }))
|
|
56
|
+
|
|
57
|
+
# Paginated. Params: page (default 1), per_page (default 20, max 100).
|
|
58
|
+
# Returns the {data, meta} envelope.
|
|
59
|
+
def list_tokens(name_or_id, **params) = @client.request("GET", "/v1/vaults/#{encode(name_or_id)}/tokens", query: params.empty? ? nil : params)
|
|
60
|
+
|
|
61
|
+
# Yield every token row, walking pages transparently. Returns a lazy
|
|
62
|
+
# Enumerator when no block is given.
|
|
63
|
+
def each_token(name_or_id, **params, &block)
|
|
64
|
+
enum = paginate(params) { |p| list_tokens(name_or_id, **p) }
|
|
65
|
+
block ? enum.each(&block) : enum
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Reveal the original value. Returns {"id", "token", "value",
|
|
69
|
+
# "value_b64", "metadata", "expires_at", "created_at", "crypto_key_version"}.
|
|
70
|
+
def detokenize(name_or_id, id_or_token) = unwrap(@client.request("GET", "/v1/vaults/#{encode(name_or_id)}/tokens/#{encode(id_or_token)}"))
|
|
71
|
+
# Returns {"updated" => true}.
|
|
72
|
+
def update_token(name_or_id, id_or_token, metadata:) = unwrap(@client.request("PATCH", "/v1/vaults/#{encode(name_or_id)}/tokens/#{encode(id_or_token)}", body: { metadata: metadata }))
|
|
73
|
+
# Returns {"deleted" => true}.
|
|
74
|
+
def delete_token(name_or_id, id_or_token) = unwrap(@client.request("DELETE", "/v1/vaults/#{encode(name_or_id)}/tokens/#{encode(id_or_token)}"))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module Resources
|
|
3
|
+
class Webhooks
|
|
4
|
+
include UnwrapsEnvelope
|
|
5
|
+
|
|
6
|
+
def initialize(client) = @client = client
|
|
7
|
+
|
|
8
|
+
# Paginated. Params: page (default 1), per_page (default 20, max 100).
|
|
9
|
+
# Returns the {data, meta} envelope.
|
|
10
|
+
def list(**params) = @client.request("GET", "/v1/webhooks", query: params.empty? ? nil : params)
|
|
11
|
+
|
|
12
|
+
# Yield every webhook, walking pages transparently. Returns a lazy
|
|
13
|
+
# Enumerator when no block is given.
|
|
14
|
+
def each(**params, &block)
|
|
15
|
+
enum = paginate(params) { |p| list(**p) }
|
|
16
|
+
block ? enum.each(&block) : enum
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get(webhook_id) = unwrap(@client.request("GET", "/v1/webhooks/#{encode(webhook_id)}"))
|
|
20
|
+
# The response's "secret_key" (the HMAC endpoint secret) is shown
|
|
21
|
+
# exactly once — store it now.
|
|
22
|
+
def create(**input) = unwrap(@client.request("POST", "/v1/webhooks", body: input))
|
|
23
|
+
def update(webhook_id, **input) = unwrap(@client.request("PATCH", "/v1/webhooks/#{encode(webhook_id)}", body: input))
|
|
24
|
+
# Returns {"deleted" => true}.
|
|
25
|
+
def delete(webhook_id) = unwrap(@client.request("DELETE", "/v1/webhooks/#{encode(webhook_id)}"))
|
|
26
|
+
|
|
27
|
+
# Paginated delivery logs. Params: page, per_page.
|
|
28
|
+
def get_logs(webhook_id, **params) = @client.request("GET", "/v1/webhooks/#{encode(webhook_id)}/logs", query: params.empty? ? nil : params)
|
|
29
|
+
|
|
30
|
+
# Yield every delivery-log row, walking pages transparently. Returns a
|
|
31
|
+
# lazy Enumerator when no block is given.
|
|
32
|
+
def each_log(webhook_id, **params, &block)
|
|
33
|
+
enum = paginate(params) { |p| get_logs(webhook_id, **p) }
|
|
34
|
+
block ? enum.each(&block) : enum
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns {"event_types" => [{"value", "label", "description"}, ...]}.
|
|
38
|
+
def list_event_types = unwrap(@client.request("GET", "/v1/webhooks/event-types"))
|
|
39
|
+
# Fire a synthetic webhook.test event; returns the delivery result.
|
|
40
|
+
def test(webhook_id) = unwrap(@client.request("POST", "/v1/webhooks/#{encode(webhook_id)}/test"))
|
|
41
|
+
|
|
42
|
+
# Verify an incoming webhook delivery AND parse it in one step — see
|
|
43
|
+
# {KnoxCall::Client.construct_event} for the full contract (formats,
|
|
44
|
+
# tolerance, returned Hash shape).
|
|
45
|
+
def construct_event(...) = Client.construct_event(...)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module Resources
|
|
3
|
+
# Workflows control plane (server: src/client-api/workflows.ts; node
|
|
4
|
+
# reference src/resources/workflows.ts, PARITY §11). Automations built in
|
|
5
|
+
# the workflow builder: list/get/create/update/delete, execute (queue a
|
|
6
|
+
# run), and the execution (run) sub-collection.
|
|
7
|
+
#
|
|
8
|
+
# client.workflows.list(page: 1, per_page: 20)
|
|
9
|
+
# wf = client.workflows.create(name: "Nightly sync", definition: { nodes: [...], edges: [...] })
|
|
10
|
+
# run = client.workflows.execute(wf["id"], input: { since: "2026-08-01" })
|
|
11
|
+
# client.workflows.each_execution(wf["id"]) { |ex| puts ex["status"] }
|
|
12
|
+
#
|
|
13
|
+
# A workflow row is a plain Hash with string keys: "id", "name",
|
|
14
|
+
# "description", "definition", "environment", "enabled", "version",
|
|
15
|
+
# "sandbox", "timeout_seconds", "published_at", "created_at", "updated_at"
|
|
16
|
+
# (plus "run_count" on list). An execution row: "id", "workflow_id",
|
|
17
|
+
# "status", "trigger_type", "started_at", "completed_at",
|
|
18
|
+
# "execution_time_ms", "error_message", "workflow_version", "created_at"
|
|
19
|
+
# (plus "node_executions" on get_execution).
|
|
20
|
+
#
|
|
21
|
+
# Mutating methods carry the ULID idempotency key like every other resource
|
|
22
|
+
# (added by Client#request on non-GET/HEAD methods), so a replayed execute
|
|
23
|
+
# returns the same execution.
|
|
24
|
+
#
|
|
25
|
+
# The sandbox/test client (KnoxCall::Client.new(sandbox: true)) is scoped to
|
|
26
|
+
# the Test data plane server-side; no env parameter is threaded through.
|
|
27
|
+
class Workflows
|
|
28
|
+
include UnwrapsEnvelope
|
|
29
|
+
|
|
30
|
+
def initialize(client) = @client = client
|
|
31
|
+
|
|
32
|
+
# -- Workflows ---------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
# Paginated. Params: page (default 1), per_page (default 20, max 100),
|
|
35
|
+
# plus endpoint filters. Returns the {data, meta} envelope.
|
|
36
|
+
def list(**params) = @client.request("GET", "/v1/workflows", query: params.empty? ? nil : params)
|
|
37
|
+
|
|
38
|
+
# Yield every workflow, walking pages transparently. Returns a lazy
|
|
39
|
+
# Enumerator when no block is given. Also available as +iterate+ for
|
|
40
|
+
# cross-SDK naming parity (node/python name the auto-pager iterate).
|
|
41
|
+
def each(**params, &block)
|
|
42
|
+
enum = paginate(params) { |p| list(**p) }
|
|
43
|
+
block ? enum.each(&block) : enum
|
|
44
|
+
end
|
|
45
|
+
alias_method :iterate, :each
|
|
46
|
+
|
|
47
|
+
def get(workflow_id) = unwrap(@client.request("GET", "/v1/workflows/#{encode(workflow_id)}"))
|
|
48
|
+
|
|
49
|
+
# body: name:, definition: ({nodes:, edges:}), plus optional description:,
|
|
50
|
+
# trigger_config:, environment:, enabled:.
|
|
51
|
+
def create(**input) = unwrap(@client.request("POST", "/v1/workflows", body: input))
|
|
52
|
+
def update(workflow_id, **input) = unwrap(@client.request("PATCH", "/v1/workflows/#{encode(workflow_id)}", body: input))
|
|
53
|
+
# Returns {"id", "deleted" => true}.
|
|
54
|
+
def delete(workflow_id) = unwrap(@client.request("DELETE", "/v1/workflows/#{encode(workflow_id)}"))
|
|
55
|
+
|
|
56
|
+
# Queue a run. Idempotent — Client#request attaches a ULID key that is
|
|
57
|
+
# stable across retries. The optional +input+ becomes the run's {input}
|
|
58
|
+
# body (omitted entirely when nil, matching the node reference). Returns
|
|
59
|
+
# the execution ack {"id", "workflow_id", "status"}.
|
|
60
|
+
def execute(workflow_id, input: nil)
|
|
61
|
+
body = input.nil? ? {} : { input: input }
|
|
62
|
+
unwrap(@client.request("POST", "/v1/workflows/#{encode(workflow_id)}/execute", body: body))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# -- Executions (runs) -------------------------------------------------
|
|
66
|
+
|
|
67
|
+
# Paginated. Params: page, per_page, plus filters. Returns the
|
|
68
|
+
# {data, meta} envelope.
|
|
69
|
+
def list_executions(workflow_id, **params) = @client.request("GET", "/v1/workflows/#{encode(workflow_id)}/executions", query: params.empty? ? nil : params)
|
|
70
|
+
|
|
71
|
+
# Yield every execution for a workflow, walking pages transparently.
|
|
72
|
+
# Returns a lazy Enumerator when no block is given. Also available as
|
|
73
|
+
# +iterate_executions+ for cross-SDK naming parity.
|
|
74
|
+
def each_execution(workflow_id, **params, &block)
|
|
75
|
+
enum = paginate(params) { |p| list_executions(workflow_id, **p) }
|
|
76
|
+
block ? enum.each(&block) : enum
|
|
77
|
+
end
|
|
78
|
+
alias_method :iterate_executions, :each_execution
|
|
79
|
+
|
|
80
|
+
# Executions are addressed by their own id (not nested under the workflow).
|
|
81
|
+
def get_execution(execution_id) = unwrap(@client.request("GET", "/v1/workflows/executions/#{encode(execution_id)}"))
|
|
82
|
+
# Returns {"id", "status"}.
|
|
83
|
+
def cancel_execution(execution_id) = unwrap(@client.request("POST", "/v1/workflows/executions/#{encode(execution_id)}/cancel"))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module Resources
|
|
3
|
+
# Wrap-credential escrow (POST /v1/wrap/credentials).
|
|
4
|
+
#
|
|
5
|
+
# Hands a raw provider credential to KnoxCall for custody: the +value+ is
|
|
6
|
+
# sent ONCE, stored under the given +name+, pinned to the supplied upstream
|
|
7
|
+
# +hosts+, and never returned. The response carries only the escrowed
|
|
8
|
+
# secret's metadata ({secret_id, name, provider, allowed_hosts, sandbox});
|
|
9
|
+
# thereafter the credential is referenced by name and injected by the proxy.
|
|
10
|
+
class Wrap
|
|
11
|
+
include UnwrapsEnvelope
|
|
12
|
+
|
|
13
|
+
def initialize(client) = @client = client
|
|
14
|
+
|
|
15
|
+
# Escrow a provider credential.
|
|
16
|
+
#
|
|
17
|
+
# @param provider [String] free-form provider label (e.g. "stripe")
|
|
18
|
+
# @param name [String] secret name the escrowed key is stored and
|
|
19
|
+
# later referenced under
|
|
20
|
+
# @param value [String] the raw provider credential — sent once, never
|
|
21
|
+
# returned
|
|
22
|
+
# @param hosts [Array<String>] the allowed upstream hostnames (the
|
|
23
|
+
# load-bearing pin)
|
|
24
|
+
# @return [Hash] the escrowed secret's metadata: {"secret_id", "name",
|
|
25
|
+
# "provider", "allowed_hosts", "sandbox"} — the value is never echoed
|
|
26
|
+
def escrow(provider:, name:, value:, hosts:)
|
|
27
|
+
unwrap(@client.request("POST", "/v1/wrap/credentials",
|
|
28
|
+
body: { provider: provider, name: name, value: value, hosts: hosts }))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Mint a base-URL gateway token bound to an escrowed credential
|
|
32
|
+
# (POST /v1/wrap/tokens).
|
|
33
|
+
#
|
|
34
|
+
# For SDKs that expose ONLY a base-URL override and no +fetch+/transport
|
|
35
|
+
# hook (Resend, Mailgun, Airtable, …): set the returned +base_url+ as the
|
|
36
|
+
# wrapped SDK's base URL, and the SDK's own key becomes a placeholder —
|
|
37
|
+
# KnoxCall injects the escrowed +secret+ server-side. ESCROW-ONLY: the
|
|
38
|
+
# +secret+ must already be escrowed (see {#escrow}).
|
|
39
|
+
#
|
|
40
|
+
# The returned +token+ (also embedded in +base_url+) is a bearer
|
|
41
|
+
# credential — treat it as a secret, never store or log it.
|
|
42
|
+
#
|
|
43
|
+
# @param secret [String] the escrowed credential (name or id) to inject
|
|
44
|
+
# @param host [String, nil] upstream host to pin (optional when the
|
|
45
|
+
# credential allows exactly one)
|
|
46
|
+
# @param ttl_seconds [Integer, nil] token TTL in seconds (omit for a
|
|
47
|
+
# non-expiring token)
|
|
48
|
+
# @param label [String, nil] human label for the token list
|
|
49
|
+
# @param style [String, nil] which +base_url+ form to return: "path"
|
|
50
|
+
# (+…/wg/<token>/<host>+) is always available; "subdomain"
|
|
51
|
+
# (+<label>.wrap.<domain>+) is only available when the operator enabled
|
|
52
|
+
# the wildcard-subdomain gateway (else the call 400s). Omit to let the
|
|
53
|
+
# server choose (subdomain when enabled, else path). NOTE: the subdomain
|
|
54
|
+
# form carries the token in the TLS SNI (plaintext on the wire) — weaker
|
|
55
|
+
# token confidentiality than the path form; prefer a short +ttl_seconds+.
|
|
56
|
+
# @return [Hash] {"id", "token", "base_url", "base_url_style", "host",
|
|
57
|
+
# "secret_id", "sandbox", "expires_at"} — "base_url_style" ("path" or
|
|
58
|
+
# "subdomain") reports which form was returned
|
|
59
|
+
def gateway_url(secret:, host: nil, ttl_seconds: nil, label: nil, style: nil)
|
|
60
|
+
body = { secret: secret, host: host, ttl_seconds: ttl_seconds, label: label, style: style }.compact
|
|
61
|
+
unwrap(@client.request("POST", "/v1/wrap/tokens", body: body))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The intercept manifest (GET /v1/wrap/intercept-manifest): which upstream
|
|
65
|
+
# hosts an intercept-enabled Route covers in this space, for one
|
|
66
|
+
# environment (default: the tenant's default), and the slug to send them
|
|
67
|
+
# under. What a route-aware interceptor polls; "version" doubles as the
|
|
68
|
+
# ETag. Scope: routes:read.
|
|
69
|
+
#
|
|
70
|
+
# Conditional form: pass +if_none_match:+ (the "version" you hold — not
|
|
71
|
+
# an ETag) and the SDK sends +If-None-Match: W/"<version>"+; a 304
|
|
72
|
+
# returns +nil+ — keep what you hold. Everything else (auth, the one
|
|
73
|
+
# re-auth on 401, retries, a 200 with a newer manifest) is exactly the
|
|
74
|
+
# unconditional call, which never returns nil.
|
|
75
|
+
#
|
|
76
|
+
# @param environment [String, nil] the environment to resolve for
|
|
77
|
+
# @param if_none_match [String, nil] the manifest version you hold
|
|
78
|
+
# @return [Hash, nil] {"version", "ttl_seconds", "environment", "sandbox",
|
|
79
|
+
# "routes" => [{"host", "base_path", "slug", "route_id",
|
|
80
|
+
# "requires_clients", "allowed_methods", "updated_at"}]}; nil only for
|
|
81
|
+
# a 304 to a conditional call
|
|
82
|
+
def intercept_manifest(environment: nil, if_none_match: nil)
|
|
83
|
+
query = environment.nil? ? nil : { environment: environment }
|
|
84
|
+
if if_none_match.nil? || if_none_match.to_s.empty?
|
|
85
|
+
return unwrap(@client.request("GET", "/v1/wrap/intercept-manifest", query: query))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Conditional form (PARITY §21.1): the held version as the server's weak
|
|
89
|
+
# ETag; its 304 comes back as NOT_MODIFIED, mapped to nil.
|
|
90
|
+
res = @client.request("GET", "/v1/wrap/intercept-manifest", query: query,
|
|
91
|
+
headers: { "If-None-Match" => self.class.manifest_etag(if_none_match) },
|
|
92
|
+
allow_not_modified: true)
|
|
93
|
+
res.equal?(KnoxCall::Client::NOT_MODIFIED) ? nil : unwrap(res)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The weak ETag the manifest endpoint sets for a +version+ (src/client-api/wrap.ts).
|
|
97
|
+
def self.manifest_etag(version) = %(W/"#{version}")
|
|
98
|
+
|
|
99
|
+
# Report uncovered-egress observations (POST /v1/wrap/egress-observations;
|
|
100
|
+
# PARITY §21.3) — the thin typed wrapper the interceptor's reporter uses,
|
|
101
|
+
# exported so an integrator can report by hand. At most 200 observations
|
|
102
|
+
# per call. The body carries names, never values: a credential header's
|
|
103
|
+
# NAME, the host, the first path segment, the method and counts. Scope:
|
|
104
|
+
# routes:read.
|
|
105
|
+
#
|
|
106
|
+
# @param observations [Array<Hash>] each {host:, first_segment:, method:,
|
|
107
|
+
# header_name:, count:, first_seen:, last_seen:} (ISO-8601 UTC times)
|
|
108
|
+
# @param sdk [String, nil] "<language>/<version>"; defaults to this SDK's
|
|
109
|
+
# @return [Hash] {"accepted", "dropped", "reasons", "redacted"} — "redacted"
|
|
110
|
+
# (when present) counts accepted entries whose content the server
|
|
111
|
+
# reduced, by reason (e.g. "first_segment_looks_like_credential")
|
|
112
|
+
def report_egress_observations(observations, sdk: nil)
|
|
113
|
+
body = { sdk: sdk || "ruby/#{KnoxCall::VERSION}", observations: Array(observations) }
|
|
114
|
+
unwrap(@client.request("POST", "/v1/wrap/egress-observations", body: body))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# List this space's gateway tokens (GET /v1/wrap/tokens).
|
|
118
|
+
#
|
|
119
|
+
# Metadata only — the token itself is never stored or returned.
|
|
120
|
+
#
|
|
121
|
+
# @return [Array<Hash>] each {"id", "secret_id", "host", "label",
|
|
122
|
+
# "created_at", "expires_at", "revoked_at", "last_used_at"}
|
|
123
|
+
def list_gateway_tokens = unwrap(@client.request("GET", "/v1/wrap/tokens"))["tokens"]
|
|
124
|
+
|
|
125
|
+
# Revoke a single gateway token by id (DELETE /v1/wrap/tokens/{id}).
|
|
126
|
+
# Immediately invalidates it.
|
|
127
|
+
#
|
|
128
|
+
# @param id [String] the wrap-token id (from {#gateway_url} or
|
|
129
|
+
# {#list_gateway_tokens})
|
|
130
|
+
# @return [Hash] {"id", "revoked"}
|
|
131
|
+
def revoke_gateway_token(id) = unwrap(@client.request("DELETE", "/v1/wrap/tokens/#{encode(id)}"))
|
|
132
|
+
|
|
133
|
+
# Build a +Faraday::Connection+ whose terminal adapter routes each request
|
|
134
|
+
# through KnoxCall — the Ruby analogue of the Node SDK's +wrap.fetch()+
|
|
135
|
+
# (PARITY §18, §21.1). Hand the returned connection to any third-party SDK
|
|
136
|
+
# that accepts an injected +Faraday::Connection+; the SDK keeps its own
|
|
137
|
+
# serialization, retries, idempotency keys and error types — only its HTTP
|
|
138
|
+
# transport is swapped.
|
|
139
|
+
#
|
|
140
|
+
# conn = knox.wrap.faraday_connection(url: "https://api.example.com")
|
|
141
|
+
# sdk = SomeSDK.new(connection: conn) # SDK-specific injection point
|
|
142
|
+
#
|
|
143
|
+
# With +routes: :auto+ the connection is ROUTE-AWARE: the intercept
|
|
144
|
+
# manifest (GET /v1/wrap/intercept-manifest, the client's environment) is
|
|
145
|
+
# refreshed lazily at its TTL and a request whose host + path an
|
|
146
|
+
# intercept-enabled Route covers goes through that Route (the path rebased
|
|
147
|
+
# under the Route's base path, query kept; the Route injects the stored
|
|
148
|
+
# secret — no provider credential travels). Every other request goes
|
|
149
|
+
# through the ephemeral proxy exactly as before — an explicit transport
|
|
150
|
+
# treats every host as listed. The default stays +routes: :off+, so an
|
|
151
|
+
# existing connection keeps its behaviour. The controls live on
|
|
152
|
+
# +conn.knoxcall+ (an {InterceptPipeline}): +ready+, +refresh+,
|
|
153
|
+
# +manifest+, +stop+.
|
|
154
|
+
#
|
|
155
|
+
# For an SDK that builds its own Faraday stack see {#faraday_middleware};
|
|
156
|
+
# for the opt-in process-wide seam see {#intercept!}. An SDK that exposes
|
|
157
|
+
# only a base-URL override (Resend, Mailgun, Airtable, …) uses
|
|
158
|
+
# {#gateway_url}.
|
|
159
|
+
#
|
|
160
|
+
# Requires the OPTIONAL +faraday+ gem (KnoxCall itself has no runtime
|
|
161
|
+
# dependency on it); a clear {KnoxCall::Error} is raised if it is missing.
|
|
162
|
+
#
|
|
163
|
+
# Credential handling mirrors the Node contract:
|
|
164
|
+
# - transit mode (default): the wrapped SDK's own +Authorization+ header is
|
|
165
|
+
# lifted out-of-band into +X-Knox-Upstream-Authorization+ (never
|
|
166
|
+
# forwarded raw, never logged), with a both-must-agree Test/Live check
|
|
167
|
+
# against the client's +sandbox+ flag;
|
|
168
|
+
# - escrow mode (+credential: {secret:}+, or per host via +hosts:+): the raw
|
|
169
|
+
# key stays in KnoxCall custody and only the escrowed secret name travels.
|
|
170
|
+
#
|
|
171
|
+
# Requests matching a route-around rule (raw-card PCI endpoints by default),
|
|
172
|
+
# the client's own hosts, and — with KNOXCALL_INTERCEPT=off — everything
|
|
173
|
+
# are sent to the provider DIRECTLY, untouched — pre-send decisions.
|
|
174
|
+
#
|
|
175
|
+
# Unavailability (decision D4): route mode and escrow fail CLOSED (a
|
|
176
|
+
# +Faraday::ConnectionFailed+); +unavailable: :direct+ opts TRANSIT traffic
|
|
177
|
+
# into going direct instead, firing +on_fallback+.
|
|
178
|
+
#
|
|
179
|
+
# NOTE: because the shared +ephemeral()+ path defaults a missing
|
|
180
|
+
# +Content-Type+ to +application/json+ when a body is present, a wrapped
|
|
181
|
+
# SDK that sends a body with NO Content-Type at all will have one added.
|
|
182
|
+
# Every mainstream SDK (Stripe, OpenAI, …) sets its own Content-Type, which
|
|
183
|
+
# is preserved verbatim, so this affects only exotic transports.
|
|
184
|
+
#
|
|
185
|
+
# @param url [String, nil] base URL for the connection (the provider's API
|
|
186
|
+
# base, exactly as the wrapped SDK expects)
|
|
187
|
+
# @param routes [Symbol] +:auto+ consults the intercept manifest; +:off+
|
|
188
|
+
# (default) is the ephemeral-only transport
|
|
189
|
+
# @param hosts [Array<String>, Hash, nil] per-host options for the ephemeral
|
|
190
|
+
# path: +{"api.resend.com" => {credential: {secret: "resend-key"}}}+
|
|
191
|
+
# (escrow) or +{unavailable: :direct}+ (transit only)
|
|
192
|
+
# @param require_context [Boolean] only intercept inside {#routed}
|
|
193
|
+
# @param unavailable [Symbol] +:error+ (default) or +:direct+ (transit only)
|
|
194
|
+
# @param credential [Hash, nil] escrow mode {secret:, scheme:}; omit for
|
|
195
|
+
# transit mode
|
|
196
|
+
# @param route [String, nil] legacy: send EVERY non-direct request via this
|
|
197
|
+
# durable route slug (x-knoxcall-route), full path, no manifest lookup
|
|
198
|
+
# @param route_around [Array<Hash>, nil] extra route-around rules, each
|
|
199
|
+
# {host:, path_prefix:(optional), reason:}; matching requests go direct
|
|
200
|
+
# @param disable_default_route_around [Boolean] drop the built-in raw-card
|
|
201
|
+
# (PCI) defaults
|
|
202
|
+
# @param auto_switch [Boolean] legacy (routes: :off only): switch a host onto
|
|
203
|
+
# its promoted durable route after the server advertises one
|
|
204
|
+
# @param direct_adapter the Faraday adapter used for direct calls (default
|
|
205
|
+
# +Faraday.default_adapter+)
|
|
206
|
+
# @param on_route_around [#call, nil] {url:, host:, reason:}
|
|
207
|
+
# @param on_promoted [#call, nil] {host:, slug:}
|
|
208
|
+
# @param on_reroute [#call, nil] {host:, url:, mode:, slug:, reason:} before a KnoxCall send
|
|
209
|
+
# @param on_refresh [#call, nil] {reason:, version:, added:, removed:} after a manifest change
|
|
210
|
+
# @param on_manifest_error [#call, nil] the exception of a failed manifest refresh
|
|
211
|
+
# @param on_unmatched_path [#call, nil] {host:, url:} once per host + first path segment
|
|
212
|
+
# @param on_refused [#call, nil] {host:, url:, slug:, status:, redecided:} after a refusal refresh
|
|
213
|
+
# @param on_fallback [#call, nil] {host:, url:, error:} when a transit request went direct
|
|
214
|
+
# @param observe_uncovered [Boolean] report uncovered egress (PARITY §21.3):
|
|
215
|
+
# calls sent DIRECT because their host was +:unlisted+ while carrying a
|
|
216
|
+
# credential-bearing header — host, first path segment, method and the
|
|
217
|
+
# header NAME (never its value; never the query; never the body) — are
|
|
218
|
+
# counted and posted to +POST /v1/wrap/egress-observations+ about once a
|
|
219
|
+
# minute. ON by default for {#intercept!}, {#faraday_middleware} and
|
|
220
|
+
# +routes: :auto+; +false+ or KNOXCALL_OBSERVE_UNCOVERED=off (read when
|
|
221
|
+
# the connection is built) turns it off; nothing is reported while
|
|
222
|
+
# KNOXCALL_INTERCEPT=off. A 403 from the endpoint stops reporting for
|
|
223
|
+
# the life of the connection (warned once).
|
|
224
|
+
# @param on_observation_flush [#call, nil] {accepted:, dropped:} after each accepted report
|
|
225
|
+
# @param faraday_options [Hash] extra options forwarded to +Faraday.new+
|
|
226
|
+
# @yield [Faraday::Connection::Builder] optional block to add
|
|
227
|
+
# request/response middleware ABOVE the KnoxCall transport — do NOT add
|
|
228
|
+
# another adapter (KnoxCall is the terminal adapter)
|
|
229
|
+
# @return [Faraday::Connection] with a +knoxcall+ singleton method
|
|
230
|
+
# @raise [KnoxCall::Error] if the +faraday+ gem is not installed
|
|
231
|
+
# @raise [KnoxCall::WrapSandboxMismatchError] on a malformed route_around or listed host
|
|
232
|
+
# @raise [TypeError] on a malformed escrow credential
|
|
233
|
+
def faraday_connection(url: nil, routes: :off, hosts: nil, require_context: false, unavailable: :error,
|
|
234
|
+
credential: nil, route: nil, route_around: nil,
|
|
235
|
+
disable_default_route_around: false, auto_switch: false,
|
|
236
|
+
direct_adapter: nil, on_route_around: nil, on_promoted: nil,
|
|
237
|
+
on_reroute: nil, on_refresh: nil, on_manifest_error: nil,
|
|
238
|
+
on_unmatched_path: nil, on_refused: nil, on_fallback: nil,
|
|
239
|
+
observe_uncovered: true, on_observation_flush: nil,
|
|
240
|
+
**faraday_options, &block)
|
|
241
|
+
require_faraday!
|
|
242
|
+
# A typo'd hook (`on_reroutes:`) would otherwise vanish into Faraday's
|
|
243
|
+
# options and never fire — fail loud instead.
|
|
244
|
+
typos = faraday_options.keys.select { |k| k.to_s.start_with?("on_") }
|
|
245
|
+
raise ArgumentError, "unknown intercept hook(s): #{typos.join(', ')}" unless typos.empty?
|
|
246
|
+
|
|
247
|
+
pipeline = KnoxCall::InterceptPipeline.new(
|
|
248
|
+
client: @client, all_hosts: true, hosts: hosts, routes: routes, credential: credential,
|
|
249
|
+
route: route, auto_switch: auto_switch, route_around: route_around,
|
|
250
|
+
disable_default_route_around: disable_default_route_around,
|
|
251
|
+
require_context: require_context, unavailable: unavailable,
|
|
252
|
+
on_route_around: on_route_around, on_promoted: on_promoted, on_reroute: on_reroute,
|
|
253
|
+
on_refresh: on_refresh, on_manifest_error: on_manifest_error,
|
|
254
|
+
on_unmatched_path: on_unmatched_path, on_refused: on_refused, on_fallback: on_fallback,
|
|
255
|
+
observe_uncovered: observe_uncovered, on_observation_flush: on_observation_flush
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
conn = Faraday.new(url: url, **faraday_options) do |f|
|
|
259
|
+
# Caller middleware sits ABOVE our transport; KnoxCall is terminal.
|
|
260
|
+
block&.call(f)
|
|
261
|
+
f.adapter(KnoxCall::Resources::Wrap::FaradayAdapter, pipeline: pipeline, direct_adapter: direct_adapter)
|
|
262
|
+
end
|
|
263
|
+
conn.define_singleton_method(:knoxcall) { pipeline }
|
|
264
|
+
conn
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A Faraday MIDDLEWARE for a stack an SDK builds itself and lets you add
|
|
268
|
+
# middleware to. Returns the +[klass, options]+ pair Faraday's builder
|
|
269
|
+
# takes; a request a Route covers, or whose host is listed, is answered
|
|
270
|
+
# from KnoxCall without reaching the SDK's own adapter — everything else
|
|
271
|
+
# continues down the stack untouched.
|
|
272
|
+
#
|
|
273
|
+
# conn.builder.insert_before(Faraday::Adapter, *knox.wrap.faraday_middleware(hosts: ["api.resend.com"]))
|
|
274
|
+
# # or, building a stack yourself:
|
|
275
|
+
# Faraday.new { |f| f.use(*knox.wrap.faraday_middleware); f.adapter :net_http }
|
|
276
|
+
#
|
|
277
|
+
# Route discovery is ON by default here (+routes: :auto+) — only listed
|
|
278
|
+
# or route-covered hosts are touched (decision D2). The pipeline (ready /
|
|
279
|
+
# refresh / manifest / stop) is +options[:pipeline]+ of the returned pair.
|
|
280
|
+
#
|
|
281
|
+
# @param hosts [Array<String>, Hash, nil] hosts to cover even when no Route does
|
|
282
|
+
# @param routes [Symbol] +:auto+ (default) or +:off+
|
|
283
|
+
# @param opts [Hash] the remaining {#faraday_connection} options except
|
|
284
|
+
# +url:+, +direct_adapter:+ and the Faraday options
|
|
285
|
+
# @return [Array(Class, Hash)] +[FaradayMiddleware, {pipeline: …}]+
|
|
286
|
+
def faraday_middleware(hosts: nil, routes: :auto, **opts)
|
|
287
|
+
require_faraday!
|
|
288
|
+
pipeline = KnoxCall::InterceptPipeline.new(client: @client, all_hosts: false, hosts: hosts, routes: routes, **opts)
|
|
289
|
+
[KnoxCall::Resources::Wrap::FaradayMiddleware, { pipeline: pipeline }]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Install the opt-in, EXPERIMENTAL process-wide seam (founder decision D7,
|
|
293
|
+
# 2026-09-25): +Net::HTTP#request+ is prepended so an UNTOUCHED
|
|
294
|
+
# third-party SDK on Net::HTTP — Faraday's default adapter, +rest-client+,
|
|
295
|
+
# +httparty+, raw Net::HTTP — has its calls sent through the Route that
|
|
296
|
+
# covers them, through the ephemeral proxy for hosts listed here that no
|
|
297
|
+
# Route covers, and left alone otherwise (decision D2: never "all egress").
|
|
298
|
+
# Typhoeus / Curb / +http.rb+ have their own socket layer and are NOT
|
|
299
|
+
# reached — point those SDKs at {#gateway_url}.
|
|
300
|
+
#
|
|
301
|
+
# stop = knox.wrap.intercept!(hosts: ["api.resend.com"])
|
|
302
|
+
# stop.ready # first manifest loaded
|
|
303
|
+
# Net::HTTP.get(URI("https://api.hubapi.com/crm/v3/objects/contacts")) # via the covering Route
|
|
304
|
+
# stop.uninstall
|
|
305
|
+
#
|
|
306
|
+
# One handle per process (a second call raises {KnoxCall::Error});
|
|
307
|
+
# +uninstall+ restores pass-through. This is a convenience, not a security
|
|
308
|
+
# boundary: it is a process global and composes with other Net::HTTP
|
|
309
|
+
# patchers in install order. Route mode is the custody path — the key
|
|
310
|
+
# never enters your process.
|
|
311
|
+
#
|
|
312
|
+
# @param hosts [Array<String>, Hash, nil] as {#faraday_connection}
|
|
313
|
+
# @param stacks [Array<Symbol>] which seams to install; only +:net_http+ exists
|
|
314
|
+
# @param routes [Symbol] +:auto+ (default) or +:off+ (listed hosts, ephemeral only)
|
|
315
|
+
# @param require_context [Boolean] only intercept inside {#routed}
|
|
316
|
+
# @param opts [Hash] +unavailable:+, +credential:+, +route_around:+,
|
|
317
|
+
# +disable_default_route_around:+ and the +on_*+ hooks of {#faraday_connection}
|
|
318
|
+
# @return [KnoxCall::InterceptHandle]
|
|
319
|
+
def intercept!(hosts: nil, stacks: [:net_http], routes: :auto, require_context: false, **opts)
|
|
320
|
+
unknown = Array(stacks).map(&:to_sym) - [:net_http]
|
|
321
|
+
raise ArgumentError, "unknown intercept stack(s): #{unknown.join(', ')} (only :net_http exists)" unless unknown.empty?
|
|
322
|
+
|
|
323
|
+
require "knoxcall/intercept_patch"
|
|
324
|
+
pipeline = KnoxCall::InterceptPipeline.new(client: @client, all_hosts: false, hosts: hosts, routes: routes,
|
|
325
|
+
require_context: require_context, **opts)
|
|
326
|
+
KnoxCall::Intercept.install(pipeline)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Run the block in a "routed" scope. With +require_context: true+, only
|
|
330
|
+
# egress performed inside {#routed} (on this thread) is intercepted — you
|
|
331
|
+
# mark the CALL SITE, not the SDK.
|
|
332
|
+
def routed(&block) = KnoxCall::InterceptContext.routed(&block)
|
|
333
|
+
|
|
334
|
+
private
|
|
335
|
+
|
|
336
|
+
# Lazily load the optional faraday gem (and the adapter + middleware, which
|
|
337
|
+
# reference Faraday at load time). Raises a clear, actionable error when it
|
|
338
|
+
# is not installed rather than a bare LoadError.
|
|
339
|
+
def require_faraday!
|
|
340
|
+
require "faraday"
|
|
341
|
+
require "knoxcall/wrap_faraday_adapter"
|
|
342
|
+
require "knoxcall/wrap_faraday_middleware"
|
|
343
|
+
rescue LoadError => e
|
|
344
|
+
raise KnoxCall::Error,
|
|
345
|
+
"wrap.faraday_connection requires the optional `faraday` gem, which is not installed. " \
|
|
346
|
+
"Add `gem \"faraday\"` to your Gemfile — KnoxCall has no runtime dependency on it. For " \
|
|
347
|
+
"third-party SDKs that accept only a base-URL override (no injected Faraday connection), " \
|
|
348
|
+
"use wrap.gateway_url instead. (#{e.class}: #{e.message})"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|