end_point_blank 0.12.0 → 0.13.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/CHANGELOG.md +45 -0
- data/README.md +188 -0
- data/lib/end_point_blank/management/client.rb +228 -0
- data/lib/end_point_blank/management/configuration.rb +56 -0
- data/lib/end_point_blank/management/error.rb +134 -0
- data/lib/end_point_blank/management/error_codes.rb +115 -0
- data/lib/end_point_blank/management/idempotency_key.rb +33 -0
- data/lib/end_point_blank/management/page.rb +55 -0
- data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
- data/lib/end_point_blank/management/resources/applications.rb +167 -0
- data/lib/end_point_blank/management/resources/base.rb +117 -0
- data/lib/end_point_blank/management/resources/clients.rb +152 -0
- data/lib/end_point_blank/management/retry_policy.rb +65 -0
- data/lib/end_point_blank/management/transport.rb +167 -0
- data/lib/end_point_blank/management/url_path.rb +18 -0
- data/lib/end_point_blank/management.rb +52 -0
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank.rb +1 -0
- metadata +15 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../page"
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
module Management
|
|
7
|
+
# The management API's resources, each reached from a {Client} (or a
|
|
8
|
+
# {ManagedClient} view) rather than built directly.
|
|
9
|
+
#
|
|
10
|
+
# Every method answers what the API sent, decoded from JSON into Hashes
|
|
11
|
+
# with String keys: a single resource's +data+, a {Page} for a list, and
|
|
12
|
+
# <tt>{"id" => ..., "deleted" => true}</tt> for a delete. Optional keyword
|
|
13
|
+
# arguments left nil are not sent.
|
|
14
|
+
module Resources
|
|
15
|
+
# Shared plumbing: paths, list pages and auto-paging.
|
|
16
|
+
class Base
|
|
17
|
+
MAX_LIMIT = 100
|
|
18
|
+
|
|
19
|
+
def initialize(transport, prefix = "")
|
|
20
|
+
@transport = transport
|
|
21
|
+
@prefix = prefix
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inspect
|
|
25
|
+
"#<#{self.class.name} prefix=#{@prefix.inspect}>"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# One path segment, percent-escaped. An empty id is refused, so it can
|
|
29
|
+
# never turn a "get one" into a "list all"; so is an id of dots only
|
|
30
|
+
# (".", ".."), a dot-segment that any proxy or URL normalizer may
|
|
31
|
+
# resolve into a different route (DELETE /clients/c1/grants/.. into
|
|
32
|
+
# DELETE /clients/c1). Percent-encoding the dots would not help:
|
|
33
|
+
# normalizers decode %2E first. Real ids are UUIDs.
|
|
34
|
+
def self.escape(segment)
|
|
35
|
+
value = segment.to_s
|
|
36
|
+
raise ArgumentError, "an id must be a non-empty String" if value.empty?
|
|
37
|
+
raise ArgumentError, "an id must not be only dots" if value.each_char.all?(".")
|
|
38
|
+
|
|
39
|
+
value.b.gsub(/[^A-Za-z0-9\-._~]/n) { |char| format("%%%02X", char.ord) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# "/segment/escaped-id/..." under this resource's prefix. Every
|
|
45
|
+
# argument is one path segment (see {.escape}).
|
|
46
|
+
def path(*segments)
|
|
47
|
+
@prefix + segments.map { |segment| "/#{escape(segment)}" }.join
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def escape(segment)
|
|
51
|
+
self.class.escape(segment)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_data(path, query = nil)
|
|
55
|
+
data(@transport.request("GET", path, query: query))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def post_data(path, body, idempotency_key)
|
|
59
|
+
data(@transport.request("POST", path, body: body, idempotency_key: idempotency_key))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def patch_data(path, body)
|
|
63
|
+
data(@transport.request("PATCH", path, body: body))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delete_data(path)
|
|
67
|
+
data(@transport.request("DELETE", path))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def data(body)
|
|
71
|
+
body.is_a?(Hash) && body.key?("data") ? body["data"] : body
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# One page of a list. +filters+ with a nil value are not sent.
|
|
75
|
+
def list_page(path, limit, after, filters = {})
|
|
76
|
+
check_limit(limit)
|
|
77
|
+
query = compact(filters.merge(limit: limit, after: after))
|
|
78
|
+
Page.from_body(@transport.request("GET", path, query: query))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Every item of a list, fetching pages as it goes. With a block,
|
|
82
|
+
# yields each item and answers nil; without one, answers an
|
|
83
|
+
# Enumerator (lazy: no request is made until it is iterated).
|
|
84
|
+
def each_item(path, limit, filters = {}, &block)
|
|
85
|
+
check_limit(limit)
|
|
86
|
+
enumerator = Enumerator.new { |yielder| each_page_item(yielder, path, limit, filters) }
|
|
87
|
+
return enumerator unless block
|
|
88
|
+
|
|
89
|
+
enumerator.each(&block)
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def each_page_item(yielder, path, limit, filters)
|
|
94
|
+
after = nil
|
|
95
|
+
loop do
|
|
96
|
+
page = list_page(path, limit, after, filters)
|
|
97
|
+
page.each { |item| yielder << item }
|
|
98
|
+
break unless page.next_page?
|
|
99
|
+
|
|
100
|
+
after = page.next_cursor
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def check_limit(limit)
|
|
105
|
+
return if limit.nil?
|
|
106
|
+
return if limit.is_a?(Integer) && limit.between?(1, MAX_LIMIT)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "limit must be an Integer from 1 to #{MAX_LIMIT}, got #{limit.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def compact(hash)
|
|
112
|
+
hash.reject { |_key, value| value.nil? }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
module Management
|
|
7
|
+
module Resources
|
|
8
|
+
# +/clients+: the organizations you serve as their provider, as client
|
|
9
|
+
# invites (pending and accepted), and managed clients you run for your
|
|
10
|
+
# customers.
|
|
11
|
+
#
|
|
12
|
+
# A client: <tt>{"id", "name", "status" ("pending"/"accepted"),
|
|
13
|
+
# "invite_code" (write keys, while pending), "accepted_at", "managed",
|
|
14
|
+
# "claimed_at", "client_organization", "inserted_at", "updated_at"}</tt>;
|
|
15
|
+
# {#get} adds +contacts+ and +pre_assignments+.
|
|
16
|
+
class Clients < Base
|
|
17
|
+
# GET /clients. @return [Page]
|
|
18
|
+
def list(limit: nil, after: nil)
|
|
19
|
+
list_page(path("clients"), limit, after)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Every client, across pages. @return [Enumerator, nil]
|
|
23
|
+
def each(limit: nil, &block)
|
|
24
|
+
each_item(path("clients"), limit, &block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# GET /clients/:id. @return [Hash]
|
|
28
|
+
def get(id)
|
|
29
|
+
get_data(path("clients", id))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# POST /clients.
|
|
33
|
+
#
|
|
34
|
+
# @param contacts [Array<Hash>, nil] your contacts to share, each
|
|
35
|
+
# <tt>{email:, first_name:, last_name:, title: nil, phone_number: nil}</tt>
|
|
36
|
+
# @param packages [Array<Hash>, nil] API packages to assign when the
|
|
37
|
+
# client accepts, each <tt>{api_package_id:, environment_id:}</tt>
|
|
38
|
+
# @param grants [Array<Hash>, nil] direct grants to make when it
|
|
39
|
+
# accepts, each <tt>{target_application_id:, environment_id:,
|
|
40
|
+
# target_endpoint_id: nil}</tt>
|
|
41
|
+
# @param managed [Boolean, nil] true creates a managed client (no
|
|
42
|
+
# packages or grants with it; assign those afterwards)
|
|
43
|
+
# @return [Hash] the new client; +invite_code+ is what the client
|
|
44
|
+
# accepts with
|
|
45
|
+
# rubocop:disable Metrics/ParameterLists
|
|
46
|
+
def create(name:, contacts: nil, packages: nil, grants: nil, managed: nil, idempotency_key: nil)
|
|
47
|
+
body = compact(name: name, contacts: contacts, packages: packages, grants: grants, managed: managed)
|
|
48
|
+
post_data(path("clients"), body, idempotency_key)
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Metrics/ParameterLists
|
|
51
|
+
|
|
52
|
+
# Invites a client: {#create} without +managed+.
|
|
53
|
+
def invite(name:, contacts: nil, packages: nil, grants: nil, idempotency_key: nil)
|
|
54
|
+
create(name: name, contacts: contacts, packages: packages, grants: grants,
|
|
55
|
+
idempotency_key: idempotency_key)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Creates a managed client: {#create} with <tt>managed: true</tt>. Set
|
|
59
|
+
# it up with {Client#for_managed_client}.
|
|
60
|
+
def create_managed(name:, contacts: nil, idempotency_key: nil)
|
|
61
|
+
create(name: name, contacts: contacts, managed: true, idempotency_key: idempotency_key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# DELETE /clients/:id. Refused with +managed_client_has_credentials+
|
|
65
|
+
# for a managed client that still holds credentials.
|
|
66
|
+
# @return [Hash] <tt>{"id", "deleted"}</tt>
|
|
67
|
+
def delete(id)
|
|
68
|
+
delete_data(path("clients", id))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# POST /clients/:client_id/claim_invites: emails your customer an
|
|
72
|
+
# invite to claim a managed client.
|
|
73
|
+
# @return [Hash] <tt>{"client_id", "email", "sent_at", "expires_at"}</tt>
|
|
74
|
+
def claim_invite(client_id, email:, idempotency_key: nil)
|
|
75
|
+
post_data(path("clients", client_id, "claim_invites"), { email: email }, idempotency_key)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# +/clients/:client_id/packages+: the API packages a client holds from
|
|
80
|
+
# you, or (on a pending invite) is set up to get when it accepts.
|
|
81
|
+
#
|
|
82
|
+
# An assignment: <tt>{"id", "api_package_id", "api_package_name",
|
|
83
|
+
# "environment_id", "status" ("active"/"pending"/"refused"), "refusal",
|
|
84
|
+
# "inserted_at", "updated_at"}</tt>.
|
|
85
|
+
class PackageAssignments < Base
|
|
86
|
+
# @return [Page]
|
|
87
|
+
def list(client_id, limit: nil, after: nil)
|
|
88
|
+
list_page(path("clients", client_id, "packages"), limit, after)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Enumerator, nil]
|
|
92
|
+
def each(client_id, limit: nil, &block)
|
|
93
|
+
each_item(path("clients", client_id, "packages"), limit, &block)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# POST /clients/:client_id/packages. Refused with e.g.
|
|
97
|
+
# +nothing_published_in_environment+ or +already_assigned+.
|
|
98
|
+
# @return [Hash] the assignment
|
|
99
|
+
def create(client_id, api_package_id:, environment_id:, idempotency_key: nil)
|
|
100
|
+
post_data(path("clients", client_id, "packages"),
|
|
101
|
+
{ api_package_id: api_package_id, environment_id: environment_id }, idempotency_key)
|
|
102
|
+
end
|
|
103
|
+
alias assign create
|
|
104
|
+
|
|
105
|
+
# PATCH /clients/:client_id/packages/:id: move the assignment to
|
|
106
|
+
# another environment. @return [Hash]
|
|
107
|
+
def update(client_id, id, environment_id:)
|
|
108
|
+
patch_data(path("clients", client_id, "packages", id), { environment_id: environment_id })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# DELETE /clients/:client_id/packages/:id. @return [Hash] <tt>{"id", "deleted"}</tt>
|
|
112
|
+
def delete(client_id, id)
|
|
113
|
+
delete_data(path("clients", client_id, "packages", id))
|
|
114
|
+
end
|
|
115
|
+
alias unassign delete
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# +/clients/:client_id/grants+: the grants a client holds directly from
|
|
119
|
+
# you (package-derived grants are managed through {PackageAssignments}).
|
|
120
|
+
#
|
|
121
|
+
# A grant: <tt>{"id", "client_organization_id", "target_application_id",
|
|
122
|
+
# "target_endpoint_id", "all_endpoints", "environment_id",
|
|
123
|
+
# "also_granted_by_api_package_ids", "synced_at", "status", "refusal",
|
|
124
|
+
# "inserted_at", "updated_at"}</tt>.
|
|
125
|
+
class Grants < Base
|
|
126
|
+
# @return [Page]
|
|
127
|
+
def list(client_id, limit: nil, after: nil)
|
|
128
|
+
list_page(path("clients", client_id, "grants"), limit, after)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Enumerator, nil]
|
|
132
|
+
def each(client_id, limit: nil, &block)
|
|
133
|
+
each_item(path("clients", client_id, "grants"), limit, &block)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# POST /clients/:client_id/grants. Leave +target_endpoint_id+ nil to
|
|
137
|
+
# grant every endpoint of the application. @return [Hash] the grant
|
|
138
|
+
def create(client_id, target_application_id:, environment_id:, target_endpoint_id: nil, idempotency_key: nil)
|
|
139
|
+
body = compact(target_application_id: target_application_id, target_endpoint_id: target_endpoint_id,
|
|
140
|
+
environment_id: environment_id)
|
|
141
|
+
post_data(path("clients", client_id, "grants"), body, idempotency_key)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# DELETE /clients/:client_id/grants/:id. @return [Hash] <tt>{"id", "deleted"}</tt>
|
|
145
|
+
def delete(client_id, id)
|
|
146
|
+
delete_data(path("clients", client_id, "grants", id))
|
|
147
|
+
end
|
|
148
|
+
alias revoke delete
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
module Management
|
|
7
|
+
# Decides whether a failed management API request is sent again, and
|
|
8
|
+
# after how long. See {Transport} for the rules.
|
|
9
|
+
class RetryPolicy
|
|
10
|
+
RETRYABLE_STATUSES = [500, 502, 503, 504].freeze
|
|
11
|
+
|
|
12
|
+
# GET and DELETE are idempotent; POST always carries an Idempotency-Key.
|
|
13
|
+
# PATCH is neither, so a 5xx or a lost answer to one is never re-sent.
|
|
14
|
+
RETRYABLE_METHODS = %w[GET DELETE POST].freeze
|
|
15
|
+
|
|
16
|
+
# Wait before retrying a 409 in progress that has no Retry-After header.
|
|
17
|
+
IN_PROGRESS_WAIT = 1
|
|
18
|
+
# Wait before retrying a 429 that has no Retry-After header (app_portal
|
|
19
|
+
# always sends one; a proxy's 429 may not).
|
|
20
|
+
RATE_LIMITED_WAIT = 1
|
|
21
|
+
# First backoff for a 5xx or a request with no answer; doubles per attempt.
|
|
22
|
+
BACKOFF_BASE = 0.5
|
|
23
|
+
|
|
24
|
+
attr_reader :max_retries, :max_retry_wait
|
|
25
|
+
|
|
26
|
+
def initialize(max_retries:, max_retry_wait:)
|
|
27
|
+
@max_retries = max_retries
|
|
28
|
+
@max_retry_wait = max_retry_wait
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Seconds to wait before attempt number +attempt+ + 1 (0 is the first
|
|
32
|
+
# retry), or nil when +error+ must be raised now.
|
|
33
|
+
def delay(method, error, attempt)
|
|
34
|
+
return nil if attempt >= max_retries
|
|
35
|
+
|
|
36
|
+
seconds = wanted_wait(method, error, attempt)
|
|
37
|
+
# A wait longer than max_retry_wait is not waited out.
|
|
38
|
+
seconds && seconds <= max_retry_wait ? seconds : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def wanted_wait(method, error, attempt)
|
|
44
|
+
return error.retry_after || RATE_LIMITED_WAIT if error.status == 429
|
|
45
|
+
return in_progress_wait(method, error) if error.code?(ErrorCodes::IDEMPOTENCY_REQUEST_IN_PROGRESS)
|
|
46
|
+
return nil unless server_side_failure?(error) && RETRYABLE_METHODS.include?(method)
|
|
47
|
+
|
|
48
|
+
error.retry_after || backoff(attempt)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Only a POST carries the key the first request is still running under.
|
|
52
|
+
def in_progress_wait(method, error)
|
|
53
|
+
method == "POST" ? error.retry_after || IN_PROGRESS_WAIT : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def server_side_failure?(error)
|
|
57
|
+
error.code?(ErrorCodes::CONNECTION_ERROR) || RETRYABLE_STATUSES.include?(error.status)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def backoff(attempt)
|
|
61
|
+
[BACKOFF_BASE * (2**attempt), max_retry_wait].min
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "excon"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "../version"
|
|
7
|
+
require_relative "error"
|
|
8
|
+
require_relative "retry_policy"
|
|
9
|
+
require_relative "idempotency_key"
|
|
10
|
+
require_relative "url_path"
|
|
11
|
+
|
|
12
|
+
module EndPointBlank
|
|
13
|
+
module Management
|
|
14
|
+
# The HTTP layer under {Client}: one request to the management API, with
|
|
15
|
+
# its headers, its Idempotency-Key and its retries. Built on Excon, the
|
|
16
|
+
# HTTP library the rest of this gem already uses.
|
|
17
|
+
#
|
|
18
|
+
# Retries (at most +max_retries+ after the first attempt, each wait passed
|
|
19
|
+
# to +sleeper+):
|
|
20
|
+
#
|
|
21
|
+
# - 429 +rate_limited+, any method: after Retry-After seconds. Nothing was
|
|
22
|
+
# done, so even a PATCH is safe to send again. A Retry-After longer than
|
|
23
|
+
# +max_retry_wait+ is not waited out; the error is raised instead.
|
|
24
|
+
# - 409 +idempotency_request_in_progress+ (POST only): shortly, with the
|
|
25
|
+
# same key, so the first request's answer is replayed once it finishes.
|
|
26
|
+
# - 5xx (+internal_server_error+, +audit_unavailable+,
|
|
27
|
+
# +intake_unavailable+, or a proxy's 502/503/504) and requests that never
|
|
28
|
+
# got an answer: for GET and DELETE, which are idempotent, and for POST,
|
|
29
|
+
# which always carries an Idempotency-Key. Never for PATCH.
|
|
30
|
+
#
|
|
31
|
+
# Every POST carries an Idempotency-Key (a random UUID v4 unless the
|
|
32
|
+
# caller passed one), and every retry of it sends the same key.
|
|
33
|
+
#
|
|
34
|
+
# 409 +idempotency_replay_unavailable+ is never retried: the first POST
|
|
35
|
+
# succeeded and its answer held a secret shown only once.
|
|
36
|
+
class Transport
|
|
37
|
+
API_PREFIX = "/api/v1"
|
|
38
|
+
|
|
39
|
+
attr_reader :base_url
|
|
40
|
+
|
|
41
|
+
# rubocop:disable Metrics/ParameterLists
|
|
42
|
+
def initialize(api_key:, base_url:, max_retries:, max_retry_wait:, connect_timeout:, read_timeout:,
|
|
43
|
+
sleeper:, excon_options: {})
|
|
44
|
+
@api_key = api_key
|
|
45
|
+
@base_url = base_url
|
|
46
|
+
@base_path = UrlPath.strip_trailing_slashes(URI.parse(base_url).path)
|
|
47
|
+
@retry_policy = RetryPolicy.new(max_retries: max_retries, max_retry_wait: max_retry_wait)
|
|
48
|
+
@connect_timeout = connect_timeout
|
|
49
|
+
@read_timeout = read_timeout
|
|
50
|
+
@sleeper = sleeper
|
|
51
|
+
@excon_options = excon_options
|
|
52
|
+
end
|
|
53
|
+
# rubocop:enable Metrics/ParameterLists
|
|
54
|
+
|
|
55
|
+
# Sends one API request and answers its decoded JSON body (nil for an
|
|
56
|
+
# empty body). +path+ is under /api/v1, e.g. "/organization".
|
|
57
|
+
#
|
|
58
|
+
# @raise [Error] for any answer outside 2xx, once retries are spent
|
|
59
|
+
def request(method, path, query: nil, body: nil, idempotency_key: nil)
|
|
60
|
+
method = method.to_s.upcase
|
|
61
|
+
idempotency_key = IdempotencyKey.resolve(method, idempotency_key)
|
|
62
|
+
attempt = 0
|
|
63
|
+
|
|
64
|
+
loop do
|
|
65
|
+
result = attempt_request(method, path, query, body, idempotency_key)
|
|
66
|
+
return result unless result.is_a?(Error)
|
|
67
|
+
|
|
68
|
+
wait_before_retry(method, path, result, attempt)
|
|
69
|
+
attempt += 1
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.user_agent
|
|
74
|
+
"end_point_blank-ruby/#{EndPointBlank::VERSION} (management)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect
|
|
78
|
+
"#<#{self.class.name} base_url=#{base_url.inspect}>"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# The decoded body on success, or the Error to raise or retry on.
|
|
84
|
+
def attempt_request(method, path, query, body, idempotency_key)
|
|
85
|
+
response = perform(method, path, query, body, idempotency_key)
|
|
86
|
+
return Error.from_response(**error_fields(response, method, path)) unless success?(response)
|
|
87
|
+
|
|
88
|
+
decode(response, method, path)
|
|
89
|
+
rescue Excon::Error::StubNotFound
|
|
90
|
+
raise
|
|
91
|
+
rescue Excon::Error => e
|
|
92
|
+
connection_error(e, method, path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def perform(method, path, query, body, idempotency_key)
|
|
96
|
+
connection = Excon.new(base_url, connect_timeout: @connect_timeout, read_timeout: @read_timeout,
|
|
97
|
+
write_timeout: @read_timeout, persistent: false, **@excon_options)
|
|
98
|
+
params = { method: method, path: "#{@base_path}#{API_PREFIX}#{path}",
|
|
99
|
+
headers: headers(body, idempotency_key) }
|
|
100
|
+
params[:query] = query if query && !query.empty?
|
|
101
|
+
params[:body] = JSON.generate(body) unless body.nil?
|
|
102
|
+
connection.request(params)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def headers(body, idempotency_key)
|
|
106
|
+
headers = {
|
|
107
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
108
|
+
"Accept" => "application/json",
|
|
109
|
+
"User-Agent" => self.class.user_agent
|
|
110
|
+
}
|
|
111
|
+
headers["Content-Type"] = "application/json" unless body.nil?
|
|
112
|
+
headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
113
|
+
headers
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def success?(response)
|
|
117
|
+
(200..299).cover?(response.status)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def decode(response, method, path)
|
|
121
|
+
text = response.body.to_s
|
|
122
|
+
return nil if text.strip.empty?
|
|
123
|
+
|
|
124
|
+
JSON.parse(text)
|
|
125
|
+
rescue JSON::ParserError
|
|
126
|
+
Error.new("The management API answered HTTP #{response.status} with a body that is not JSON.",
|
|
127
|
+
code: ErrorCodes::INVALID_RESPONSE, status: response.status, http_method: method, path: path)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def error_fields(response, method, path)
|
|
131
|
+
{ status: response.status, headers: response.headers, body: response.body,
|
|
132
|
+
http_method: method, path: path }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def connection_error(error, method, path)
|
|
136
|
+
detail = redact("#{error.class.name}: #{error.message}")
|
|
137
|
+
Error.new("Could not reach the management API at #{base_url} (#{detail}).",
|
|
138
|
+
code: ErrorCodes::CONNECTION_ERROR, http_method: method, path: path)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Sleeps before the next attempt, or raises +error+ when it is not to
|
|
142
|
+
# be retried.
|
|
143
|
+
def wait_before_retry(method, path, error, attempt)
|
|
144
|
+
delay = @retry_policy.delay(method, error, attempt)
|
|
145
|
+
raise error if delay.nil?
|
|
146
|
+
|
|
147
|
+
log_retry(method, path, error, delay)
|
|
148
|
+
@sleeper.call(delay)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Method, path, status and code only: never headers, query or bodies,
|
|
152
|
+
# which carry the key, ids and credential secrets.
|
|
153
|
+
def log_retry(method, path, error, delay)
|
|
154
|
+
EndPointBlank.logger.debug(
|
|
155
|
+
"[EndPointBlank] management API #{method} #{path} answered " \
|
|
156
|
+
"#{error.status || "no response"} (#{error.code}); retrying in #{delay}s"
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def redact(text)
|
|
161
|
+
return text if @api_key.nil? || @api_key.empty?
|
|
162
|
+
|
|
163
|
+
text.gsub(@api_key, "[REDACTED]")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EndPointBlank
|
|
4
|
+
module Management
|
|
5
|
+
# URL string helpers for the management client.
|
|
6
|
+
module UrlPath
|
|
7
|
+
SLASH = "/".ord
|
|
8
|
+
|
|
9
|
+
# +text+ without trailing slashes. One backward scan over the bytes, not
|
|
10
|
+
# a regex, so its cost is linear however the input is shaped.
|
|
11
|
+
def self.strip_trailing_slashes(text)
|
|
12
|
+
stop = text.bytesize
|
|
13
|
+
stop -= 1 while stop.positive? && text.getbyte(stop - 1) == SLASH
|
|
14
|
+
text.byteslice(0, stop)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration_error"
|
|
4
|
+
require_relative "management/configuration"
|
|
5
|
+
require_relative "management/error_codes"
|
|
6
|
+
require_relative "management/error"
|
|
7
|
+
require_relative "management/page"
|
|
8
|
+
require_relative "management/retry_policy"
|
|
9
|
+
require_relative "management/idempotency_key"
|
|
10
|
+
require_relative "management/url_path"
|
|
11
|
+
require_relative "management/transport"
|
|
12
|
+
require_relative "management/client"
|
|
13
|
+
|
|
14
|
+
module EndPointBlank
|
|
15
|
+
# The organization management API client. See {Management::Client}.
|
|
16
|
+
#
|
|
17
|
+
# Configure defaults once, e.g. in config/initializers/end_point_blank.rb,
|
|
18
|
+
# apart from the runtime's EndPointBlank.configure:
|
|
19
|
+
#
|
|
20
|
+
# EndPointBlank::Management.configure do |m|
|
|
21
|
+
# m.api_key = Rails.application.credentials.dig(:end_point_blank, :management_key)
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# EndPointBlank::Management.client.organization
|
|
25
|
+
module Management
|
|
26
|
+
@configuration_mutex = Mutex.new
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# The defaults {Client.new} reads. @return [Configuration]
|
|
30
|
+
def configuration
|
|
31
|
+
@configuration_mutex.synchronize { @configuration ||= Configuration.new }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Yields {configuration} to change the defaults.
|
|
35
|
+
def configure
|
|
36
|
+
yield configuration
|
|
37
|
+
configuration
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A new {Client} from {configuration}; +options+ override it.
|
|
41
|
+
# @return [Client]
|
|
42
|
+
def client(**options)
|
|
43
|
+
Client.new(**options)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Back to the built-in defaults (for tests).
|
|
47
|
+
def reset_configuration!
|
|
48
|
+
@configuration_mutex.synchronize { @configuration = Configuration.new }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/end_point_blank.rb
CHANGED
|
@@ -36,6 +36,7 @@ require_relative "end_point_blank/rack/headers"
|
|
|
36
36
|
require_relative "end_point_blank/unauthorized_error"
|
|
37
37
|
require_relative "end_point_blank/token_unavailable_error"
|
|
38
38
|
require_relative "end_point_blank/configuration_error"
|
|
39
|
+
require_relative "end_point_blank/management"
|
|
39
40
|
if defined?(::Rails)
|
|
40
41
|
require_relative "end_point_blank/rails/authenticated"
|
|
41
42
|
require_relative "end_point_blank/rails/authorized"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: end_point_blank
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Robert A. Lasch
|
|
@@ -120,6 +120,20 @@ files:
|
|
|
120
120
|
- lib/end_point_blank/deprecation_headers.rb
|
|
121
121
|
- lib/end_point_blank/fast_json_truncator.rb
|
|
122
122
|
- lib/end_point_blank/log_entry.rb
|
|
123
|
+
- lib/end_point_blank/management.rb
|
|
124
|
+
- lib/end_point_blank/management/client.rb
|
|
125
|
+
- lib/end_point_blank/management/configuration.rb
|
|
126
|
+
- lib/end_point_blank/management/error.rb
|
|
127
|
+
- lib/end_point_blank/management/error_codes.rb
|
|
128
|
+
- lib/end_point_blank/management/idempotency_key.rb
|
|
129
|
+
- lib/end_point_blank/management/page.rb
|
|
130
|
+
- lib/end_point_blank/management/resources/api_packages.rb
|
|
131
|
+
- lib/end_point_blank/management/resources/applications.rb
|
|
132
|
+
- lib/end_point_blank/management/resources/base.rb
|
|
133
|
+
- lib/end_point_blank/management/resources/clients.rb
|
|
134
|
+
- lib/end_point_blank/management/retry_policy.rb
|
|
135
|
+
- lib/end_point_blank/management/transport.rb
|
|
136
|
+
- lib/end_point_blank/management/url_path.rb
|
|
123
137
|
- lib/end_point_blank/masking.rb
|
|
124
138
|
- lib/end_point_blank/middleware/rack/report_interaction.rb
|
|
125
139
|
- lib/end_point_blank/rack/env_store.rb
|