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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +653 -0
  3. data/README.md +424 -19
  4. data/end_point_blank.gemspec +5 -3
  5. data/lib/end_point_blank/access_tokens.rb +244 -25
  6. data/lib/end_point_blank/authorization.rb +105 -20
  7. data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
  8. data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
  9. data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
  10. data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
  11. data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
  12. data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
  13. data/lib/end_point_blank/commands/http.rb +20 -1
  14. data/lib/end_point_blank/configuration.rb +111 -4
  15. data/lib/end_point_blank/configuration_error.rb +18 -0
  16. data/lib/end_point_blank/management/client.rb +228 -0
  17. data/lib/end_point_blank/management/configuration.rb +56 -0
  18. data/lib/end_point_blank/management/error.rb +134 -0
  19. data/lib/end_point_blank/management/error_codes.rb +115 -0
  20. data/lib/end_point_blank/management/idempotency_key.rb +33 -0
  21. data/lib/end_point_blank/management/page.rb +55 -0
  22. data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
  23. data/lib/end_point_blank/management/resources/applications.rb +167 -0
  24. data/lib/end_point_blank/management/resources/base.rb +117 -0
  25. data/lib/end_point_blank/management/resources/clients.rb +152 -0
  26. data/lib/end_point_blank/management/retry_policy.rb +65 -0
  27. data/lib/end_point_blank/management/transport.rb +167 -0
  28. data/lib/end_point_blank/management/url_path.rb +18 -0
  29. data/lib/end_point_blank/management.rb +52 -0
  30. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  31. data/lib/end_point_blank/rails/authorized.rb +9 -13
  32. data/lib/end_point_blank/target_url.rb +57 -0
  33. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  34. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  35. data/lib/end_point_blank/version.rb +1 -1
  36. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  37. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  38. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  39. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  40. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  41. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  42. data/lib/end_point_blank/writers/shared.rb +35 -4
  43. data/lib/end_point_blank.rb +240 -2
  44. metadata +29 -10
  45. 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