end_point_blank 0.6.1 → 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 +653 -0
- data/README.md +424 -19
- data/end_point_blank.gemspec +5 -3
- data/lib/end_point_blank/access_tokens.rb +244 -25
- data/lib/end_point_blank/authorization.rb +105 -20
- data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
- data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
- data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
- data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
- data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
- data/lib/end_point_blank/commands/http.rb +20 -1
- data/lib/end_point_blank/configuration.rb +111 -4
- data/lib/end_point_blank/configuration_error.rb +18 -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/rails/authenticated.rb +62 -7
- data/lib/end_point_blank/rails/authorized.rb +9 -13
- data/lib/end_point_blank/target_url.rb +57 -0
- data/lib/end_point_blank/token_unavailable_error.rb +102 -0
- data/lib/end_point_blank/unauthorized_error.rb +81 -1
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
- data/lib/end_point_blank/writers/direct_writer.rb +1 -1
- data/lib/end_point_blank/writers/exception_writer.rb +11 -2
- data/lib/end_point_blank/writers/log_writer.rb +1 -1
- data/lib/end_point_blank/writers/request_writer.rb +1 -0
- data/lib/end_point_blank/writers/response_writer.rb +1 -0
- data/lib/end_point_blank/writers/shared.rb +35 -4
- data/lib/end_point_blank.rb +240 -2
- metadata +29 -10
- data/lib/end_point_blank/loggers/logger.rb +0 -30
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "configuration"
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "transport"
|
|
7
|
+
require_relative "resources/base"
|
|
8
|
+
require_relative "resources/api_packages"
|
|
9
|
+
require_relative "resources/clients"
|
|
10
|
+
require_relative "resources/applications"
|
|
11
|
+
|
|
12
|
+
module EndPointBlank
|
|
13
|
+
module Management
|
|
14
|
+
# A client for the EndPointBlank organization management API (app_portal's
|
|
15
|
+
# +/api/v1+): your organization's API packages, clients, package
|
|
16
|
+
# assignments, grants, applications, environments and runtime credentials,
|
|
17
|
+
# and the managed clients you run for your customers.
|
|
18
|
+
#
|
|
19
|
+
# Plain Ruby: usable from a script, a job or a console as well as a Rails
|
|
20
|
+
# app. It authenticates only with a management API key
|
|
21
|
+
# (<tt>Authorization: Bearer epb_mk_...</tt>, created in the portal under
|
|
22
|
+
# Settings > API Keys) and shares nothing with the runtime configuration:
|
|
23
|
+
# it never sends a runtime client_id/client_secret and never calls intake.
|
|
24
|
+
#
|
|
25
|
+
# mgmt = EndPointBlank::Management::Client.new(api_key: ENV.fetch("EPB_MGMT_KEY"))
|
|
26
|
+
# mgmt.organization # => {"id" => ..., "name" => ..., ...}
|
|
27
|
+
# mgmt.api_packages.each { |package| puts package["name"] }
|
|
28
|
+
#
|
|
29
|
+
# Arguments left nil fall back to {Management.configure} (and its
|
|
30
|
+
# ENDPOINTBLANK_MANAGEMENT_* environment variables), then the defaults.
|
|
31
|
+
#
|
|
32
|
+
# Thread-safe: each request opens its own connection.
|
|
33
|
+
class Client
|
|
34
|
+
KEY_PREFIX = "epb_mk_"
|
|
35
|
+
# The prefix and the rest of the key in the alphabet app_portal mints it
|
|
36
|
+
# in (URL-safe base64): nothing else can be a valid key, so nothing else
|
|
37
|
+
# is ever put in a header.
|
|
38
|
+
KEY_FORMAT = /\Aepb_mk_[A-Za-z0-9_-]+\z/.freeze
|
|
39
|
+
|
|
40
|
+
# The options {#initialize} takes besides api_key and base_url.
|
|
41
|
+
OPTIONS = %i[max_retries max_retry_wait connect_timeout read_timeout sleeper excon_options].freeze
|
|
42
|
+
|
|
43
|
+
DEFAULT_SLEEPER = ->(seconds) { Kernel.sleep(seconds) }
|
|
44
|
+
|
|
45
|
+
# @return [Resources::ApiPackages]
|
|
46
|
+
attr_reader :api_packages
|
|
47
|
+
# @return [Resources::Endpoints]
|
|
48
|
+
attr_reader :endpoints
|
|
49
|
+
# @return [Resources::Clients]
|
|
50
|
+
attr_reader :clients
|
|
51
|
+
# @return [Resources::PackageAssignments]
|
|
52
|
+
attr_reader :package_assignments
|
|
53
|
+
# @return [Resources::Grants]
|
|
54
|
+
attr_reader :grants
|
|
55
|
+
# @return [Resources::Applications]
|
|
56
|
+
attr_reader :applications
|
|
57
|
+
# @return [Resources::Environments]
|
|
58
|
+
attr_reader :environments
|
|
59
|
+
# @return [Resources::Credentials]
|
|
60
|
+
attr_reader :credentials
|
|
61
|
+
|
|
62
|
+
# @param api_key [String] a management API key, epb_mk_...
|
|
63
|
+
# @param base_url [String] app_portal's URL (default https://app.endpointblank.com)
|
|
64
|
+
# @param max_retries [Integer] retries after the first attempt; 0 turns them off
|
|
65
|
+
# @param max_retry_wait [Numeric] the longest single wait, in seconds
|
|
66
|
+
# @param connect_timeout [Numeric] seconds
|
|
67
|
+
# @param read_timeout [Numeric] seconds
|
|
68
|
+
# @param sleeper [#call] called with the seconds to wait before a retry
|
|
69
|
+
# (default Kernel#sleep); replace it in tests
|
|
70
|
+
# @param excon_options [Hash] extra options for Excon.new (e.g. a proxy,
|
|
71
|
+
# or <tt>mock: true</tt> with Excon.stub in tests)
|
|
72
|
+
# @raise [EndPointBlank::ConfigurationError] for a missing or malformed key or base URL
|
|
73
|
+
def initialize(api_key: nil, base_url: nil, **options)
|
|
74
|
+
unknown = options.keys - OPTIONS
|
|
75
|
+
raise ArgumentError, "unknown option(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
76
|
+
|
|
77
|
+
config = Management.configuration
|
|
78
|
+
@transport = Transport.new(
|
|
79
|
+
api_key: self.class.validate_key(api_key || config.api_key),
|
|
80
|
+
base_url: self.class.validate_base_url(base_url || config.base_url),
|
|
81
|
+
**transport_options(config, options)
|
|
82
|
+
)
|
|
83
|
+
build_resources
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# app_portal's URL this client calls.
|
|
87
|
+
def base_url
|
|
88
|
+
@transport.base_url
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# GET /organization: the organization the key belongs to, and the key's
|
|
92
|
+
# name and scope.
|
|
93
|
+
# @return [Hash] <tt>{"id", "name", "domain", "slug", "key" => {"name", "scope"}}</tt>
|
|
94
|
+
def organization
|
|
95
|
+
body = @transport.request("GET", "/organization")
|
|
96
|
+
body.is_a?(Hash) ? body["data"] : body
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A view of the API as your managed client +client_id+ (a client
|
|
100
|
+
# created with <tt>clients.create_managed</tt>): its applications,
|
|
101
|
+
# environments and credentials, under /clients/:client_id/. Works only
|
|
102
|
+
# while the client is unclaimed; afterwards every call answers 404.
|
|
103
|
+
#
|
|
104
|
+
# @return [ManagedClient]
|
|
105
|
+
def for_managed_client(client_id)
|
|
106
|
+
ManagedClient.new(@transport, client_id, @clients)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Never shows the key.
|
|
110
|
+
def inspect
|
|
111
|
+
"#<#{self.class.name} base_url=#{base_url.inspect} api_key=[REDACTED]>"
|
|
112
|
+
end
|
|
113
|
+
alias to_s inspect
|
|
114
|
+
|
|
115
|
+
# The key, if it is a management API key. The error never repeats it.
|
|
116
|
+
# @api private
|
|
117
|
+
def self.validate_key(api_key)
|
|
118
|
+
key = normalized_key(api_key)
|
|
119
|
+
return key if key&.match?(KEY_FORMAT)
|
|
120
|
+
|
|
121
|
+
given = key.nil? || key.empty? ? "No key was given" : "The key given does not have that form"
|
|
122
|
+
raise EndPointBlank::ConfigurationError,
|
|
123
|
+
"EndPointBlank::Management::Client needs a management API key: #{KEY_PREFIX} followed by " \
|
|
124
|
+
"the rest of the key, as the portal shows it under Settings > API Keys. #{given}. " \
|
|
125
|
+
"Runtime client credentials are not accepted by the management API."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# +api_key+ without surrounding whitespace (a key read from a file or an
|
|
129
|
+
# env var often ends in a newline), or nil when it is not a readable
|
|
130
|
+
# String at all.
|
|
131
|
+
def self.normalized_key(api_key)
|
|
132
|
+
return nil unless api_key.is_a?(String) && api_key.valid_encoding? && api_key.encoding.ascii_compatible?
|
|
133
|
+
|
|
134
|
+
api_key.strip
|
|
135
|
+
end
|
|
136
|
+
private_class_method :normalized_key
|
|
137
|
+
|
|
138
|
+
# +base_url+ without a trailing slash, if it is an http(s) URL with a
|
|
139
|
+
# host and no userinfo, query or fragment. The error never repeats it,
|
|
140
|
+
# since a URL can carry credentials.
|
|
141
|
+
# @api private
|
|
142
|
+
def self.validate_base_url(base_url)
|
|
143
|
+
return UrlPath.strip_trailing_slashes(base_url.to_s) if plain_http_url?(base_url)
|
|
144
|
+
|
|
145
|
+
raise EndPointBlank::ConfigurationError,
|
|
146
|
+
"The management API base_url must be an http(s) URL with a host and no userinfo, " \
|
|
147
|
+
"query or fragment, such as #{Configuration::DEFAULT_BASE_URL}."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.plain_http_url?(base_url)
|
|
151
|
+
uri = URI.parse(base_url.to_s)
|
|
152
|
+
%w[http https].include?(uri.scheme) && !uri.host.to_s.empty? &&
|
|
153
|
+
[uri.userinfo, uri.query, uri.fragment].all?(&:nil?)
|
|
154
|
+
rescue URI::InvalidURIError
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
private_class_method :plain_http_url?
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def transport_options(config, options)
|
|
162
|
+
{
|
|
163
|
+
max_retries: non_negative(:max_retries, options.fetch(:max_retries, config.max_retries)),
|
|
164
|
+
max_retry_wait: non_negative(:max_retry_wait, options.fetch(:max_retry_wait, config.max_retry_wait)),
|
|
165
|
+
connect_timeout: options.fetch(:connect_timeout, config.connect_timeout),
|
|
166
|
+
read_timeout: options.fetch(:read_timeout, config.read_timeout),
|
|
167
|
+
sleeper: options.fetch(:sleeper, DEFAULT_SLEEPER),
|
|
168
|
+
excon_options: options.fetch(:excon_options, {})
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def non_negative(name, value)
|
|
173
|
+
return value if value.is_a?(Numeric) && !value.negative?
|
|
174
|
+
|
|
175
|
+
raise ArgumentError, "#{name} must be a non-negative number, got #{value.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def build_resources
|
|
179
|
+
@api_packages = Resources::ApiPackages.new(@transport)
|
|
180
|
+
@endpoints = Resources::Endpoints.new(@transport)
|
|
181
|
+
@clients = Resources::Clients.new(@transport)
|
|
182
|
+
@package_assignments = Resources::PackageAssignments.new(@transport)
|
|
183
|
+
@grants = Resources::Grants.new(@transport)
|
|
184
|
+
@applications = Resources::Applications.new(@transport)
|
|
185
|
+
@environments = Resources::Environments.new(@transport)
|
|
186
|
+
@credentials = Resources::Credentials.new(@transport)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Your managed client's applications, environments and credentials, from
|
|
191
|
+
# {Client#for_managed_client}. The same calls as the {Client}'s own,
|
|
192
|
+
# sent under /api/v1/clients/:client_id/.
|
|
193
|
+
class ManagedClient
|
|
194
|
+
# @return [String] the client's id (as in /clients/:client_id)
|
|
195
|
+
attr_reader :client_id
|
|
196
|
+
# @return [Resources::Applications]
|
|
197
|
+
attr_reader :applications
|
|
198
|
+
# @return [Resources::Environments]
|
|
199
|
+
attr_reader :environments
|
|
200
|
+
# @return [Resources::Credentials]
|
|
201
|
+
attr_reader :credentials
|
|
202
|
+
|
|
203
|
+
def initialize(transport, client_id, clients)
|
|
204
|
+
prefix = "/clients/#{Resources::Base.escape(client_id)}"
|
|
205
|
+
@client_id = client_id
|
|
206
|
+
@clients = clients
|
|
207
|
+
@applications = Resources::Applications.new(transport, prefix)
|
|
208
|
+
@environments = Resources::Environments.new(transport, prefix)
|
|
209
|
+
@credentials = Resources::Credentials.new(transport, prefix)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The client itself (GET /clients/:client_id). @return [Hash]
|
|
213
|
+
def get
|
|
214
|
+
@clients.get(client_id)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# POST /clients/:client_id/claim_invites: emails your customer an
|
|
218
|
+
# invite to claim the client. @return [Hash]
|
|
219
|
+
def claim_invite(email:, idempotency_key: nil)
|
|
220
|
+
@clients.claim_invite(client_id, email: email, idempotency_key: idempotency_key)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def inspect
|
|
224
|
+
"#<#{self.class.name} client_id=#{client_id.inspect}>"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EndPointBlank
|
|
4
|
+
module Management
|
|
5
|
+
# Defaults for {Client.new}, set with {Management.configure}, e.g. in a
|
|
6
|
+
# Rails initializer.
|
|
7
|
+
#
|
|
8
|
+
# Separate from {EndPointBlank::Configuration} on purpose: the runtime
|
|
9
|
+
# client_id/client_secret are never sent to the management API, and the
|
|
10
|
+
# management key is never sent to intake. Nothing here reads the runtime
|
|
11
|
+
# configuration, and nothing in the runtime configuration reads this.
|
|
12
|
+
class Configuration
|
|
13
|
+
DEFAULT_BASE_URL = "https://app.endpointblank.com"
|
|
14
|
+
DEFAULT_MAX_RETRIES = 2
|
|
15
|
+
DEFAULT_MAX_RETRY_WAIT = 60
|
|
16
|
+
DEFAULT_CONNECT_TIMEOUT = 5
|
|
17
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
18
|
+
|
|
19
|
+
attr_writer :api_key, :base_url
|
|
20
|
+
|
|
21
|
+
# Retries after the first attempt (see {Transport}). 0 turns them off.
|
|
22
|
+
attr_accessor :max_retries
|
|
23
|
+
# The longest wait, in seconds, a retry will sleep (a longer
|
|
24
|
+
# Retry-After raises the error instead).
|
|
25
|
+
attr_accessor :max_retry_wait
|
|
26
|
+
attr_accessor :connect_timeout, :read_timeout
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@max_retries = DEFAULT_MAX_RETRIES
|
|
30
|
+
@max_retry_wait = DEFAULT_MAX_RETRY_WAIT
|
|
31
|
+
@connect_timeout = DEFAULT_CONNECT_TIMEOUT
|
|
32
|
+
@read_timeout = DEFAULT_READ_TIMEOUT
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The management API key (epb_mk_...), falling back to the
|
|
36
|
+
# ENDPOINTBLANK_MANAGEMENT_KEY environment variable.
|
|
37
|
+
def api_key
|
|
38
|
+
@api_key || ENV.fetch("ENDPOINTBLANK_MANAGEMENT_KEY", nil)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# app_portal's URL, falling back to the
|
|
42
|
+
# ENDPOINTBLANK_MANAGEMENT_BASE_URL environment variable, then
|
|
43
|
+
# {DEFAULT_BASE_URL}. Not the runtime's intake base_url.
|
|
44
|
+
def base_url
|
|
45
|
+
@base_url || ENV.fetch("ENDPOINTBLANK_MANAGEMENT_BASE_URL", nil) || DEFAULT_BASE_URL
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Never shows the key.
|
|
49
|
+
def inspect
|
|
50
|
+
"#<#{self.class.name} base_url=#{base_url.inspect} api_key=#{api_key ? "[REDACTED]" : "nil"} " \
|
|
51
|
+
"max_retries=#{max_retries.inspect}>"
|
|
52
|
+
end
|
|
53
|
+
alias to_s inspect
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "error_codes"
|
|
5
|
+
|
|
6
|
+
module EndPointBlank
|
|
7
|
+
# Reopened with the same superclass in end_point_blank.rb; declared here too
|
|
8
|
+
# so this file can be required on its own.
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
module Management
|
|
12
|
+
# Raised by {Client} for every refused or failed management API call.
|
|
13
|
+
#
|
|
14
|
+
# The API answers errors as
|
|
15
|
+
# <tt>{"error": {"code", "message", "details"}}</tt>. +code+ is stable and
|
|
16
|
+
# meant for programs; match on it (see {ErrorCodes}). +message+ is for
|
|
17
|
+
# people. +details+ is present only when there is something field-level to
|
|
18
|
+
# say, e.g. a +validation_failed+ error's fields.
|
|
19
|
+
#
|
|
20
|
+
# A code this SDK does not know still raises, carrying that code. An answer
|
|
21
|
+
# that is not the API's error shape (an HTML page from a proxy) raises with
|
|
22
|
+
# code +http_error+; a request that never got an answer raises with code
|
|
23
|
+
# +connection_error+ and no status.
|
|
24
|
+
class Error < EndPointBlank::Error
|
|
25
|
+
# @return [String] the API's error code, or one of {ErrorCodes::SDK}
|
|
26
|
+
attr_reader :code
|
|
27
|
+
# @return [Object, nil] field-level details, as the API sent them
|
|
28
|
+
attr_reader :details
|
|
29
|
+
# @return [Integer, nil] the HTTP status; nil for +connection_error+
|
|
30
|
+
attr_reader :status
|
|
31
|
+
# @return [Numeric, nil] the Retry-After header's seconds, when present
|
|
32
|
+
attr_reader :retry_after
|
|
33
|
+
# @return [String, nil] the Location header, e.g. on +idempotency_replay_unavailable+
|
|
34
|
+
attr_reader :location
|
|
35
|
+
# @return [String, nil] the X-Request-Id header, for support requests
|
|
36
|
+
attr_reader :request_id
|
|
37
|
+
# @return [String, nil] the HTTP method of the failed request
|
|
38
|
+
attr_reader :http_method
|
|
39
|
+
# @return [String, nil] the path of the failed request (no query, no host)
|
|
40
|
+
attr_reader :path
|
|
41
|
+
|
|
42
|
+
# rubocop:disable Metrics/ParameterLists
|
|
43
|
+
def initialize(message, code:, status: nil, details: nil, retry_after: nil, location: nil,
|
|
44
|
+
request_id: nil, http_method: nil, path: nil)
|
|
45
|
+
@code = code
|
|
46
|
+
@status = status
|
|
47
|
+
@details = details
|
|
48
|
+
@retry_after = retry_after
|
|
49
|
+
@location = location
|
|
50
|
+
@request_id = request_id
|
|
51
|
+
@http_method = http_method
|
|
52
|
+
@path = path
|
|
53
|
+
super(message)
|
|
54
|
+
end
|
|
55
|
+
# rubocop:enable Metrics/ParameterLists
|
|
56
|
+
|
|
57
|
+
# Builds the error for an answer outside 2xx. +headers+ is any Hash-like
|
|
58
|
+
# whose keys are header names in any case.
|
|
59
|
+
def self.from_response(status:, headers:, body:, http_method: nil, path: nil)
|
|
60
|
+
code, message, details = parse_body(status, body)
|
|
61
|
+
if code == ErrorCodes::IDEMPOTENCY_REPLAY_UNAVAILABLE
|
|
62
|
+
message = replay_unavailable_message(header(headers, "location"))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
new(message,
|
|
66
|
+
code: code, status: status, details: details,
|
|
67
|
+
retry_after: parse_retry_after(header(headers, "retry-after")),
|
|
68
|
+
location: header(headers, "location"), request_id: header(headers, "x-request-id"),
|
|
69
|
+
http_method: http_method, path: path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether this error is +code+ (a String or Symbol).
|
|
73
|
+
def code?(code)
|
|
74
|
+
self.code == code.to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inspect
|
|
78
|
+
"#<#{self.class.name} code=#{code.inspect} status=#{status.inspect} message=#{message.inspect}>"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
# The case-insensitive value of header +name+, or nil.
|
|
83
|
+
def header(headers, name)
|
|
84
|
+
return nil if headers.nil?
|
|
85
|
+
|
|
86
|
+
headers.each { |key, value| return value.to_s if key.to_s.casecmp?(name) }
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Seconds to wait from a Retry-After header: delta-seconds, or an
|
|
91
|
+
# HTTP-date. nil when absent or unreadable; never negative.
|
|
92
|
+
def parse_retry_after(value)
|
|
93
|
+
return nil if value.nil? || value.strip.empty?
|
|
94
|
+
return Integer(value.strip, 10) if value.strip.match?(/\A\d+\z/)
|
|
95
|
+
|
|
96
|
+
require "time"
|
|
97
|
+
[Time.httpdate(value.strip) - Time.now, 0].max
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def parse_body(status, body)
|
|
105
|
+
error = JSON.parse(body.to_s)["error"]
|
|
106
|
+
if error.is_a?(Hash) && error["code"].is_a?(String)
|
|
107
|
+
message = error["message"].is_a?(String) ? error["message"] : "The request failed (#{error["code"]})."
|
|
108
|
+
return [error["code"], message, error["details"]]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unexpected(status, body)
|
|
112
|
+
rescue JSON::ParserError, TypeError, NoMethodError
|
|
113
|
+
unexpected(status, body)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def unexpected(status, body)
|
|
117
|
+
snippet = body.to_s.strip.gsub(/\s+/, " ")[0, 200]
|
|
118
|
+
message = "The management API answered HTTP #{status} without its JSON error shape"
|
|
119
|
+
message += snippet.empty? ? "." : ": #{snippet}"
|
|
120
|
+
[ErrorCodes::HTTP_ERROR, message, nil]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def replay_unavailable_message(location)
|
|
124
|
+
message = +"A request with this Idempotency-Key already succeeded, and its answer held a secret " \
|
|
125
|
+
"that is shown only once, so it can't be replayed and was not retried. Read or list " \
|
|
126
|
+
"the resource to see its current state"
|
|
127
|
+
message << " (#{location})" if location
|
|
128
|
+
message << ". If you need a secret you never received, rotate the credential."
|
|
129
|
+
message
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EndPointBlank
|
|
4
|
+
module Management
|
|
5
|
+
# Every +error.code+ the management API can answer, with its HTTP status, as
|
|
6
|
+
# app_portal's docs list them, plus the few codes this SDK raises itself
|
|
7
|
+
# when there is no API answer to read one from.
|
|
8
|
+
#
|
|
9
|
+
# Match on these rather than on messages, which are for people and may
|
|
10
|
+
# change. The list is not closed: an {Error} carrying a code missing here
|
|
11
|
+
# still raises with that code, so a newer API never breaks an older SDK.
|
|
12
|
+
module ErrorCodes
|
|
13
|
+
# Authentication and limits
|
|
14
|
+
MISSING_KEY = "missing_key"
|
|
15
|
+
INVALID_KEY = "invalid_key"
|
|
16
|
+
RUNTIME_CREDENTIAL_REFUSED = "runtime_credential_refused"
|
|
17
|
+
INSUFFICIENT_SCOPE = "insufficient_scope"
|
|
18
|
+
AUDIT_UNAVAILABLE = "audit_unavailable"
|
|
19
|
+
RATE_LIMITED = "rate_limited"
|
|
20
|
+
PLAN_LIMIT = "plan_limit"
|
|
21
|
+
|
|
22
|
+
# Requests
|
|
23
|
+
VALIDATION_FAILED = "validation_failed"
|
|
24
|
+
# No such resource in your organization (another organization's id
|
|
25
|
+
# answers this too), or no such /api/v1 path.
|
|
26
|
+
NOT_FOUND = "not_found"
|
|
27
|
+
INVALID_PAGINATION = "invalid_pagination"
|
|
28
|
+
INVALID_FILTER = "invalid_filter"
|
|
29
|
+
INVALID_IDEMPOTENCY_KEY = "invalid_idempotency_key"
|
|
30
|
+
IDEMPOTENCY_KEY_REUSED = "idempotency_key_reused"
|
|
31
|
+
IDEMPOTENCY_REQUEST_IN_PROGRESS = "idempotency_request_in_progress"
|
|
32
|
+
IDEMPOTENCY_REPLAY_UNAVAILABLE = "idempotency_replay_unavailable"
|
|
33
|
+
# Any request the API cannot read (e.g. a body that is not JSON).
|
|
34
|
+
BAD_REQUEST = "bad_request"
|
|
35
|
+
INTERNAL_SERVER_ERROR = "internal_server_error"
|
|
36
|
+
|
|
37
|
+
# Applications, environments and API packages
|
|
38
|
+
HAS_DEPENDENTS = "has_dependents"
|
|
39
|
+
PROTECTED = "protected"
|
|
40
|
+
INVALID_ENVIRONMENT_BASE_URLS = "invalid_environment_base_urls"
|
|
41
|
+
API_PACKAGE_ASSIGNED = "api_package_assigned"
|
|
42
|
+
INTAKE_SYNC_FAILED = "intake_sync_failed"
|
|
43
|
+
|
|
44
|
+
# Credentials
|
|
45
|
+
DELETE_REFUSED = "delete_refused"
|
|
46
|
+
INTAKE_CREDENTIAL = "intake_credential"
|
|
47
|
+
INTAKE_REJECTED = "intake_rejected"
|
|
48
|
+
INTAKE_UNAVAILABLE = "intake_unavailable"
|
|
49
|
+
|
|
50
|
+
# Clients, packages and grants
|
|
51
|
+
INVALID_CONTACTS = "invalid_contacts"
|
|
52
|
+
INVALID_PACKAGES = "invalid_packages"
|
|
53
|
+
INVALID_GRANTS = "invalid_grants"
|
|
54
|
+
INVALID_MANAGED = "invalid_managed"
|
|
55
|
+
CLIENT_NOT_ACCEPTED = "client_not_accepted"
|
|
56
|
+
CLIENT_ACCEPTED = "client_accepted"
|
|
57
|
+
CLIENT_NOT_MANAGED = "client_not_managed"
|
|
58
|
+
ALREADY_A_MEMBER = "already_a_member"
|
|
59
|
+
MANAGED_CLIENT_HAS_CREDENTIALS = "managed_client_has_credentials"
|
|
60
|
+
API_PACKAGE_NOT_FOUND = "api_package_not_found"
|
|
61
|
+
ENVIRONMENT_NOT_FOUND = "environment_not_found"
|
|
62
|
+
ALREADY_ASSIGNED = "already_assigned"
|
|
63
|
+
NOTHING_PUBLISHED_IN_ENVIRONMENT = "nothing_published_in_environment"
|
|
64
|
+
APPLICATION_NOT_FOUND = "application_not_found"
|
|
65
|
+
ENDPOINT_NOT_FOUND = "endpoint_not_found"
|
|
66
|
+
ENVIRONMENT_NOT_IN_APPLICATION = "environment_not_in_application"
|
|
67
|
+
ALREADY_GRANTED = "already_granted"
|
|
68
|
+
GRANT_REVOKED_CONCURRENTLY = "grant_revoked_concurrently"
|
|
69
|
+
|
|
70
|
+
# The warning code API package endpoint writes answer in +warnings+ (not
|
|
71
|
+
# an error): a client assignment of the package now derives no grant.
|
|
72
|
+
ASSIGNMENT_DERIVES_NOTHING = "assignment_derives_nothing"
|
|
73
|
+
|
|
74
|
+
# Raised by this SDK, never sent by the API: the request never got an
|
|
75
|
+
# answer (DNS, TLS, refused connection, timeout).
|
|
76
|
+
CONNECTION_ERROR = "connection_error"
|
|
77
|
+
# Raised by this SDK: an error answer whose body is not the API's JSON
|
|
78
|
+
# error shape, such as an HTML page from a proxy. +status+ still says
|
|
79
|
+
# what happened.
|
|
80
|
+
HTTP_ERROR = "http_error"
|
|
81
|
+
# Raised by this SDK: a success answer whose body is not JSON.
|
|
82
|
+
INVALID_RESPONSE = "invalid_response"
|
|
83
|
+
|
|
84
|
+
# The HTTP status the API answers each of its codes with.
|
|
85
|
+
STATUSES = {
|
|
86
|
+
MISSING_KEY => 401, INVALID_KEY => 401, RUNTIME_CREDENTIAL_REFUSED => 401,
|
|
87
|
+
INSUFFICIENT_SCOPE => 403, AUDIT_UNAVAILABLE => 503, RATE_LIMITED => 429, PLAN_LIMIT => 402,
|
|
88
|
+
VALIDATION_FAILED => 422, NOT_FOUND => 404, INVALID_PAGINATION => 400, INVALID_FILTER => 422,
|
|
89
|
+
INVALID_IDEMPOTENCY_KEY => 400, IDEMPOTENCY_KEY_REUSED => 422,
|
|
90
|
+
IDEMPOTENCY_REQUEST_IN_PROGRESS => 409, IDEMPOTENCY_REPLAY_UNAVAILABLE => 409,
|
|
91
|
+
BAD_REQUEST => 400, INTERNAL_SERVER_ERROR => 500,
|
|
92
|
+
HAS_DEPENDENTS => 422, PROTECTED => 422, INVALID_ENVIRONMENT_BASE_URLS => 422,
|
|
93
|
+
API_PACKAGE_ASSIGNED => 422, INTAKE_SYNC_FAILED => 422,
|
|
94
|
+
DELETE_REFUSED => 422, INTAKE_CREDENTIAL => 409, INTAKE_REJECTED => 422, INTAKE_UNAVAILABLE => 503,
|
|
95
|
+
INVALID_CONTACTS => 422, INVALID_PACKAGES => 422, INVALID_GRANTS => 422, INVALID_MANAGED => 422,
|
|
96
|
+
CLIENT_NOT_ACCEPTED => 422, CLIENT_ACCEPTED => 422, CLIENT_NOT_MANAGED => 422,
|
|
97
|
+
ALREADY_A_MEMBER => 422, MANAGED_CLIENT_HAS_CREDENTIALS => 422, API_PACKAGE_NOT_FOUND => 422,
|
|
98
|
+
ENVIRONMENT_NOT_FOUND => 422, ALREADY_ASSIGNED => 422, NOTHING_PUBLISHED_IN_ENVIRONMENT => 422,
|
|
99
|
+
APPLICATION_NOT_FOUND => 422, ENDPOINT_NOT_FOUND => 422, ENVIRONMENT_NOT_IN_APPLICATION => 422,
|
|
100
|
+
ALREADY_GRANTED => 422, GRANT_REVOKED_CONCURRENTLY => 409
|
|
101
|
+
}.freeze
|
|
102
|
+
|
|
103
|
+
# Every code the API can send, as strings.
|
|
104
|
+
ALL = STATUSES.keys.freeze
|
|
105
|
+
|
|
106
|
+
# Codes this SDK raises itself.
|
|
107
|
+
SDK = [CONNECTION_ERROR, HTTP_ERROR, INVALID_RESPONSE].freeze
|
|
108
|
+
|
|
109
|
+
# Whether +code+ is one this version of the SDK knows about.
|
|
110
|
+
def self.known?(code)
|
|
111
|
+
STATUSES.key?(code) || SDK.include?(code)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module EndPointBlank
|
|
6
|
+
module Management
|
|
7
|
+
# The Idempotency-Key a management API request carries: every POST has
|
|
8
|
+
# one, so a retry of it is replayed by the API rather than run twice.
|
|
9
|
+
module IdempotencyKey
|
|
10
|
+
MAX_LENGTH = 255
|
|
11
|
+
|
|
12
|
+
# The key to send for +method+: the caller's (stripped, as the API reads
|
|
13
|
+
# it), or a new random UUID v4 for a POST without one; nil for any other
|
|
14
|
+
# method, which must not be given one.
|
|
15
|
+
#
|
|
16
|
+
# @raise [ArgumentError] for a key on a non-POST, or an empty or
|
|
17
|
+
# too-long key
|
|
18
|
+
def self.resolve(method, key)
|
|
19
|
+
unless method == "POST"
|
|
20
|
+
raise ArgumentError, "idempotency_key applies to POST requests only" unless key.nil?
|
|
21
|
+
|
|
22
|
+
return nil
|
|
23
|
+
end
|
|
24
|
+
return SecureRandom.uuid if key.nil?
|
|
25
|
+
|
|
26
|
+
key = key.to_s.strip
|
|
27
|
+
return key if key.length.between?(1, MAX_LENGTH)
|
|
28
|
+
|
|
29
|
+
raise ArgumentError, "idempotency_key must be 1 to #{MAX_LENGTH} characters"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EndPointBlank
|
|
4
|
+
module Management
|
|
5
|
+
# One page of a management API list: the items (+data+, Hashes with String
|
|
6
|
+
# keys, as the API sent them) and the cursor for the next page, nil on the
|
|
7
|
+
# last one. Enumerable over its items.
|
|
8
|
+
#
|
|
9
|
+
# Pass +next_cursor+ as +after:+ to the same list call for the next page,
|
|
10
|
+
# or use the resource's +each+ to walk every page.
|
|
11
|
+
class Page
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
# @return [Array<Hash>]
|
|
15
|
+
attr_reader :data
|
|
16
|
+
# @return [String, nil]
|
|
17
|
+
attr_reader :next_cursor
|
|
18
|
+
|
|
19
|
+
def initialize(data:, next_cursor:)
|
|
20
|
+
@data = data
|
|
21
|
+
@next_cursor = next_cursor
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Builds a page from a list answer, <tt>{"data" => [...], "next_cursor" => ...}</tt>.
|
|
25
|
+
def self.from_body(body)
|
|
26
|
+
body = {} unless body.is_a?(Hash)
|
|
27
|
+
new(data: Array(body["data"]), next_cursor: body["next_cursor"])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def each(&block)
|
|
31
|
+
return enum_for(:each) unless block
|
|
32
|
+
|
|
33
|
+
data.each(&block)
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether there is a page after this one.
|
|
38
|
+
def next_page?
|
|
39
|
+
!next_cursor.nil? && !next_cursor.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def size
|
|
43
|
+
data.size
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def empty?
|
|
47
|
+
data.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inspect
|
|
51
|
+
"#<#{self.class.name} size=#{size} next_cursor=#{next_cursor.inspect}>"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|