envless-sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/README.md +171 -0
- data/lib/envless/sdk/api_error.rb +54 -0
- data/lib/envless/sdk/bodies.rb +49 -0
- data/lib/envless/sdk/client.rb +39 -0
- data/lib/envless/sdk/clock.rb +24 -0
- data/lib/envless/sdk/connection_pool.rb +83 -0
- data/lib/envless/sdk/connector.rb +26 -0
- data/lib/envless/sdk/constants.rb +262 -0
- data/lib/envless/sdk/crypto/cipher.rb +58 -0
- data/lib/envless/sdk/crypto/codec.rb +23 -0
- data/lib/envless/sdk/crypto/envelope.rb +41 -0
- data/lib/envless/sdk/crypto/keys.rb +42 -0
- data/lib/envless/sdk/crypto/passphrase.rb +38 -0
- data/lib/envless/sdk/crypto/passphrase_rules.rb +42 -0
- data/lib/envless/sdk/crypto/values.rb +31 -0
- data/lib/envless/sdk/crypto/workspace_key.rb +40 -0
- data/lib/envless/sdk/crypto_messages.rb +31 -0
- data/lib/envless/sdk/default_client.rb +55 -0
- data/lib/envless/sdk/defaults.rb +32 -0
- data/lib/envless/sdk/envelope.rb +48 -0
- data/lib/envless/sdk/environment.rb +42 -0
- data/lib/envless/sdk/errors.rb +23 -0
- data/lib/envless/sdk/exchange.rb +27 -0
- data/lib/envless/sdk/helpers.rb +49 -0
- data/lib/envless/sdk/http.rb +46 -0
- data/lib/envless/sdk/listing.rb +54 -0
- data/lib/envless/sdk/messages.rb +43 -0
- data/lib/envless/sdk/net_http_adapter.rb +84 -0
- data/lib/envless/sdk/numbers.rb +44 -0
- data/lib/envless/sdk/options.rb +56 -0
- data/lib/envless/sdk/pagination.rb +31 -0
- data/lib/envless/sdk/paths.rb +70 -0
- data/lib/envless/sdk/received.rb +28 -0
- data/lib/envless/sdk/redirects.rb +56 -0
- data/lib/envless/sdk/resource.rb +50 -0
- data/lib/envless/sdk/resources/environments.rb +61 -0
- data/lib/envless/sdk/resources/invites.rb +28 -0
- data/lib/envless/sdk/resources/keys.rb +36 -0
- data/lib/envless/sdk/resources/me.rb +11 -0
- data/lib/envless/sdk/resources/members.rb +36 -0
- data/lib/envless/sdk/resources/product_access.rb +30 -0
- data/lib/envless/sdk/resources/products.rb +62 -0
- data/lib/envless/sdk/resources/projects.rb +42 -0
- data/lib/envless/sdk/resources/roles.rb +40 -0
- data/lib/envless/sdk/resources/variable_changes.rb +57 -0
- data/lib/envless/sdk/resources/variables.rb +49 -0
- data/lib/envless/sdk/resources/versions.rb +60 -0
- data/lib/envless/sdk/resources/webhook_deliveries.rb +33 -0
- data/lib/envless/sdk/resources/webhooks.rb +44 -0
- data/lib/envless/sdk/resources/workspace.rb +26 -0
- data/lib/envless/sdk/resources/workspace_domain.rb +31 -0
- data/lib/envless/sdk/resources/workspaces.rb +24 -0
- data/lib/envless/sdk/results.rb +43 -0
- data/lib/envless/sdk/retry_policy.rb +36 -0
- data/lib/envless/sdk/rotation/entry_rotator.rb +61 -0
- data/lib/envless/sdk/rotation/key_ring.rb +34 -0
- data/lib/envless/sdk/rotation/ledger.rb +36 -0
- data/lib/envless/sdk/rotation/runner.rb +45 -0
- data/lib/envless/sdk/rotation/variable_rotator.rb +38 -0
- data/lib/envless/sdk/text.rb +71 -0
- data/lib/envless/sdk/transport.rb +62 -0
- data/lib/envless/sdk/update_notice.rb +66 -0
- data/lib/envless/sdk/urls.rb +70 -0
- data/lib/envless/sdk/variable_names.rb +55 -0
- data/lib/envless/sdk/version.rb +5 -0
- data/lib/envless/sdk/webhooks/headers.rb +49 -0
- data/lib/envless/sdk/webhooks/verify.rb +58 -0
- data/lib/envless/sdk/wire.rb +77 -0
- data/lib/envless/sdk.rb +77 -0
- data/sig/envless/sdk/adapter.rbs +60 -0
- data/sig/envless/sdk/client.rbs +31 -0
- data/sig/envless/sdk/crypto.rbs +92 -0
- data/sig/envless/sdk/errors.rbs +37 -0
- data/sig/envless/sdk/http.rbs +31 -0
- data/sig/envless/sdk/pagination.rbs +81 -0
- data/sig/envless/sdk/results.rbs +62 -0
- data/sig/envless/sdk/rotation.rbs +86 -0
- data/sig/envless/sdk/settings.rbs +81 -0
- data/sig/envless/sdk/stdlib.rbs +8 -0
- data/sig/envless/sdk/text.rbs +52 -0
- data/sig/envless/sdk/transport.rbs +79 -0
- data/sig/envless/sdk/urls.rbs +73 -0
- data/sig/envless/sdk/webhooks.rbs +23 -0
- data/sig/envless/sdk.rbs +36 -0
- data/sig/generated/constants.rbs +57 -0
- data/sig/generated/resources.rbs +209 -0
- data/sig/generated/types.rbs +466 -0
- data/sig/manifest.yaml +3 -0
- metadata +134 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Defaults
|
|
5
|
+
BASE_URL = "https://api.envless.cloud"
|
|
6
|
+
MAX_RETRIES = 3
|
|
7
|
+
TIMEOUT = 30
|
|
8
|
+
USER_AGENT = "envless-sdk-ruby/#{VERSION}"
|
|
9
|
+
KEEP_ALIVE_TIMEOUT = 2
|
|
10
|
+
MAX_IDLE_CONNECTIONS = 8
|
|
11
|
+
MAX_REDIRECTS = 20
|
|
12
|
+
MAX_TIMEOUT = 2_147_483
|
|
13
|
+
DEADLINE_GRACE = 0.1
|
|
14
|
+
MESSAGE_LIMIT = 300
|
|
15
|
+
REQUEST_ID_HEADER = "request-id"
|
|
16
|
+
LOCATION_HEADER = "location"
|
|
17
|
+
REDACTED = "[redacted]"
|
|
18
|
+
|
|
19
|
+
UPDATE_NOTICE_ENV_DISABLE = "ENVLESS_DISABLE_UPDATE_NOTICE"
|
|
20
|
+
UPDATE_NOTICE_TIMEOUT = 2
|
|
21
|
+
UPDATE_NOTICE_PACKAGE = "envless-sdk"
|
|
22
|
+
UPDATE_NOTICE_HOST = "rubygems.org"
|
|
23
|
+
UPDATE_NOTICE_PATH = "/api/v1/versions/envless-sdk/latest.json"
|
|
24
|
+
UPDATE_NOTICE_COMMAND = "bundle update envless-sdk"
|
|
25
|
+
UPDATE_NOTICE_CHANGELOG_URL = "https://envless.cloud/docs/ruby/changelog"
|
|
26
|
+
UPDATE_NOTICE_ICON = "ℹ"
|
|
27
|
+
|
|
28
|
+
NAMESPACES = %i[
|
|
29
|
+
me workspace workspaces products projects environments variables versions members invites roles keys webhooks
|
|
30
|
+
].freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Envelope
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def read(status, reason, content)
|
|
8
|
+
text = Text.decode(content)
|
|
9
|
+
return fallback(status, reason.empty? ? format(Messages::HTTP_STATUS, status:) : reason) if text.empty?
|
|
10
|
+
|
|
11
|
+
parsed = Wire.decode(text)
|
|
12
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
13
|
+
rescue JSON::ParserError
|
|
14
|
+
fallback(status, text.to_s[0, Defaults::MESSAGE_LIMIT].to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fallback(status, message)
|
|
18
|
+
{success: false, code: status, message:, error: {code: Constants::ERROR_CODES[:SERVER_ERROR]}, data: nil}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def success?(status, envelope)
|
|
22
|
+
status.between?(200, 299) && Wire.truthy?(envelope[:success])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def api_error(status, headers, envelope, retry_after)
|
|
26
|
+
payload = mapping(envelope[:error])
|
|
27
|
+
code = payload[:code]
|
|
28
|
+
|
|
29
|
+
ApiError.new(
|
|
30
|
+
text(envelope[:message]) || format(Messages::REQUEST_FAILED, status:),
|
|
31
|
+
status:,
|
|
32
|
+
code: code.is_a?(String) ? code : Constants::ERROR_CODES[:SERVER_ERROR],
|
|
33
|
+
resource: text(payload[:resource]),
|
|
34
|
+
field: text(payload[:field]),
|
|
35
|
+
request_id: text(mapping(envelope[:_req])[:reqId]) || text(headers[Defaults::REQUEST_ID_HEADER]),
|
|
36
|
+
retry_after_seconds: retry_after.finite? ? Numbers.whole(retry_after) : nil
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def mapping(value)
|
|
41
|
+
value.is_a?(Hash) ? value : {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def text(value)
|
|
45
|
+
(value.is_a?(String) && !value.empty?) ? value : nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Environment
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def from_environment(name)
|
|
8
|
+
value = ENV.fetch(name, nil)
|
|
9
|
+
(value.nil? || value.empty?) ? nil : value
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def normalise_endpoint(value)
|
|
13
|
+
return nil if value.nil? || value.empty?
|
|
14
|
+
|
|
15
|
+
trimmed = Text.js_trim(value).sub(Urls::TRAILING_SLASHES, "")
|
|
16
|
+
return nil if trimmed.empty?
|
|
17
|
+
return trimmed if Constants::ENDPOINT_SCHEME_PATTERN.match?(trimmed)
|
|
18
|
+
|
|
19
|
+
host = trimmed.split(":", 2).first.to_s
|
|
20
|
+
"#{Constants::LOCAL_HOSTS.include?(host) ? "http" : "https"}://#{trimmed}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def resolve_token(token)
|
|
24
|
+
resolved = token.nil? ? from_environment(Constants::ENV_VARS[:TOKEN]) : token
|
|
25
|
+
raise ArgumentError, Messages::TOKEN_SHAPE unless resolved.nil? || resolved.is_a?(String)
|
|
26
|
+
|
|
27
|
+
trimmed = resolved.nil? ? "" : Text.http_trim(resolved)
|
|
28
|
+
raise ArgumentError, Messages::TOKEN_REQUIRED if trimmed.empty?
|
|
29
|
+
raise ArgumentError, Messages::TOKEN_CHARACTERS unless Text.token?(trimmed)
|
|
30
|
+
|
|
31
|
+
trimmed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve_base_url(base_url)
|
|
35
|
+
requested = base_url.nil? ? from_environment(Constants::ENV_VARS[:BASE_URL]) : base_url
|
|
36
|
+
requested = requested.to_s if requested.is_a?(URI::Generic)
|
|
37
|
+
raise ArgumentError, "#{requested.inspect} #{Messages::BASE_URL_SHAPE}" unless requested.nil? || requested.is_a?(String)
|
|
38
|
+
|
|
39
|
+
normalise_endpoint(requested) || Defaults::BASE_URL
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class NetworkError < Error
|
|
8
|
+
def initialize(message = nil, timeout: false)
|
|
9
|
+
super(message)
|
|
10
|
+
@timeout = timeout
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def timeout?
|
|
14
|
+
@timeout == true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class DecryptionError < Error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class WebhookVerificationError < Error
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Exchange
|
|
5
|
+
PROGRAMMING_ERRORS = [NameError, TypeError, ArgumentError, FrozenError, NotImplementedError].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def call(adapter, request, seconds, base_url)
|
|
10
|
+
limit = Options.deadline_of(seconds)
|
|
11
|
+
deadline = Clock.deadline(limit)
|
|
12
|
+
return Redirects.follow(adapter, request, deadline) if limit.nil?
|
|
13
|
+
|
|
14
|
+
Timeout.timeout(limit + Defaults::DEADLINE_GRACE) { Redirects.follow(adapter, request, deadline) }
|
|
15
|
+
rescue Timeout::Error
|
|
16
|
+
raise NetworkError.new(format(Messages::TIMED_OUT, seconds: Numbers.text(seconds)), timeout: true)
|
|
17
|
+
rescue NetworkError, *PROGRAMMING_ERRORS
|
|
18
|
+
raise
|
|
19
|
+
rescue => error
|
|
20
|
+
raise NetworkError, format(Messages::UNREACHABLE, base_url:, reason: reason_of(error))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reason_of(error)
|
|
24
|
+
error.message.to_s.empty? ? error.class.name.to_s : error.message
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
class << self
|
|
5
|
+
def encrypt_value(plaintext, passphrase, workspace_unique_id)
|
|
6
|
+
Crypto::Values.encrypt_value(plaintext, passphrase, workspace_unique_id)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def decrypt_value(ciphertext, passphrase, workspace_unique_id)
|
|
10
|
+
Crypto::Values.decrypt_value(ciphertext, passphrase, workspace_unique_id)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def encrypt_with_key(key, plaintext)
|
|
14
|
+
Crypto::Cipher.encrypt_with_key(key, plaintext)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def decrypt_with_key(key, ciphertext)
|
|
18
|
+
Crypto::Cipher.decrypt_with_key(key, ciphertext)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def derive_workspace_key(passphrase, workspace_unique_id)
|
|
22
|
+
Crypto::Keys.derive_workspace_key(passphrase, workspace_unique_id)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def import_workspace_key(key)
|
|
26
|
+
Crypto::Keys.import_workspace_key(key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ciphertext?(value)
|
|
30
|
+
Crypto::Values.ciphertext?(value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def passphrase_strength(passphrase)
|
|
34
|
+
Crypto::Passphrase.strength(passphrase)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def variable_name_validation(name)
|
|
38
|
+
VariableNames.validation(name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def verify_webhook_signature(payload:, headers:, secret:, tolerance_seconds: nil)
|
|
42
|
+
Webhooks::Verify.call(payload:, headers:, secret:, tolerance_seconds:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rotate_workspace_passphrase(client:, workspace_unique_id:, from:, to:, project:, environment:, product: nil)
|
|
46
|
+
Rotation::Runner.new(client:, workspace_unique_id:, from:, to:, project:, environment:, product:).run
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
HttpRequest = Data.define(:method, :url, :headers, :body, :timeout)
|
|
5
|
+
|
|
6
|
+
class HttpRequest
|
|
7
|
+
def inspect
|
|
8
|
+
shown = headers.to_h do |name, value|
|
|
9
|
+
[name, name.to_s.casecmp?(Constants::HEADER_KEYS[:AUTHORIZATION]) ? Defaults::REDACTED : value]
|
|
10
|
+
end
|
|
11
|
+
size = body.nil? ? "nil" : "#{body.bytesize} bytes"
|
|
12
|
+
|
|
13
|
+
"#<data #{self.class.name} method=#{method.inspect}, url=#{url.inspect}, headers=#{shown.inspect}, " \
|
|
14
|
+
"body=#{size}, timeout=#{timeout.inspect}>"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_s
|
|
18
|
+
inspect
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def pretty_print(printer)
|
|
22
|
+
printer.text(inspect)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
HttpResponse = Data.define(:status, :headers, :body, :reason)
|
|
27
|
+
|
|
28
|
+
class HttpResponse
|
|
29
|
+
def self.from(response)
|
|
30
|
+
new(
|
|
31
|
+
status: Integer(response.status),
|
|
32
|
+
headers: (response.headers || {}).to_h { |name, value| [name.to_s.downcase, Text.utf8(header_text(value))] },
|
|
33
|
+
body: response.body.to_s.b,
|
|
34
|
+
reason: response.respond_to?(:reason) ? Text.utf8(response.reason.to_s) : ""
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.header_text(value)
|
|
39
|
+
value.is_a?(Array) ? value.join(", ") : value.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def header(name)
|
|
43
|
+
headers[name.downcase]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
Listing = Data.define(:limit, :offset, :search, :order, :product, :status)
|
|
5
|
+
|
|
6
|
+
class Listing
|
|
7
|
+
KEYS = Constants::QUERY_KEYS
|
|
8
|
+
MAX_LIMIT = Constants::PAGINATION[:MAX_LIMIT]
|
|
9
|
+
|
|
10
|
+
def self.of(limit: nil, offset: nil, search: nil, order: nil, product: nil, status: nil)
|
|
11
|
+
new(limit: Options.paging(limit, "limit"), offset: Options.paging(offset, "offset"), search:, order:, product:,
|
|
12
|
+
status:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def query
|
|
16
|
+
{KEYS[:LIMIT] => limit, KEYS[:OFFSET] => offset, KEYS[:SEARCH] => search, KEYS[:ORDER] => order,
|
|
17
|
+
KEYS[:PRODUCT] => product, KEYS[:STATUS] => status}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def walk
|
|
21
|
+
with(limit: [limit.nil? ? MAX_LIMIT : limit, MAX_LIMIT].min, offset: offset.nil? ? 0 : offset)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def advance(count)
|
|
25
|
+
with(offset: offset.to_i + count)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Pages
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def page_of(payload, collection)
|
|
33
|
+
items = field(payload, collection.to_sym)
|
|
34
|
+
pagination = field(payload, :pagination)
|
|
35
|
+
|
|
36
|
+
Page.new(items: items.is_a?(Array) ? items : [],
|
|
37
|
+
pagination: pagination.respond_to?(:to_hash) ? pagination.to_hash : Constants::EMPTY_PAGINATION)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def field(payload, key)
|
|
41
|
+
payload.is_a?(Hash) ? payload[key] : nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def access_page_of(payload, collection)
|
|
45
|
+
page = page_of(payload, collection)
|
|
46
|
+
ProductAccessPage.new(items: page.items, pagination: page.pagination,
|
|
47
|
+
is_private: Wire.truthy?(field(payload, :isPrivate)))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def more?(page)
|
|
51
|
+
!page.items.empty? && Wire.truthy?(page.pagination[:hasMore])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Messages
|
|
5
|
+
TOKEN_REQUIRED = "An Envless API key is required. Pass token: or set #{Constants::ENV_VARS[:TOKEN]}. " \
|
|
6
|
+
"Create one in the dashboard under Keys."
|
|
7
|
+
TOKEN_TWICE = "Pass the API key once, either as the first argument or as token:."
|
|
8
|
+
TOKEN_SHAPE = "token: must be a String: the Envless API key, which begins \"ev_sk_\"."
|
|
9
|
+
TOKEN_CHARACTERS = "token: holds a character an Envless API key never contains, such as a space, a line break " \
|
|
10
|
+
"or a curly quote. Copy the key again from where it was shown."
|
|
11
|
+
USER_AGENT_SHAPE = "user_agent: must be printable ASCII on a single line, because it is sent as a header."
|
|
12
|
+
BASE_URL_SHAPE = "is not a usable base URL. Pass an http or https origin such as \"https://api.envless.cloud\"."
|
|
13
|
+
BASE_URL_USERINFO = "A base URL must not carry a user name or password. Pass the origin alone and the key as token:."
|
|
14
|
+
PATH_SHAPE = "is not a usable request path. Pass a path on the API that begins with a single \"/\", such as " \
|
|
15
|
+
"\"/projects\". Anything else could send the request, and the key it carries, to another host."
|
|
16
|
+
SEGMENT_SHAPE = "A slug, name or id must be a String."
|
|
17
|
+
EMPTY_SEGMENT = "A slug, name or id must not be empty."
|
|
18
|
+
DOT_SEGMENT = "is not a usable slug, name or id: a path segment made only of dots is removed by URL parsers, so " \
|
|
19
|
+
"the request would reach a different endpoint."
|
|
20
|
+
TIMEOUT_SHAPE = "timeout: must be a number of seconds, 0 or more. 0 turns the deadline off."
|
|
21
|
+
RETRIES_SHAPE = "max_retries: must be a whole number of retries, 0 or more."
|
|
22
|
+
PAGING_SHAPE = "%<name>s: must be a whole number."
|
|
23
|
+
ADAPTER_SHAPE = "adapter: must respond to call(request) and return the response."
|
|
24
|
+
METHOD_SHAPE = "method: must be an HTTP method such as :get or \"POST\"."
|
|
25
|
+
QUERY_SHAPE = "A query value must be a String, Symbol, Integer, Float, true or false, not %<kind>s."
|
|
26
|
+
BODY_SHAPE = "Pass the request body as a Hash, or as keyword arguments."
|
|
27
|
+
LIST_SHAPE = "%<name>s must be an Array, not %<kind>s. Wrap a single value in an Array."
|
|
28
|
+
JSON_SHAPE = "%<kind>s cannot be sent as JSON. Pass a String, Symbol, Integer, Float, true, false, nil, Array, " \
|
|
29
|
+
"Hash, Time or Date."
|
|
30
|
+
JSON_KEY_SHAPE = "A key in a request body must be a String or a Symbol, not %<kind>s."
|
|
31
|
+
JSON_DEPTH = "The request body is nested too deeply, or refers to itself."
|
|
32
|
+
NOT_FINITE = "%<value>s cannot be sent as JSON, because JSON has no NaN or Infinity."
|
|
33
|
+
TIMED_OUT = "Request timed out after %<seconds>ss."
|
|
34
|
+
UNREACHABLE = "Could not reach %<base_url>s: %<reason>s"
|
|
35
|
+
TOO_MANY_REDIRECTS = "Stopped following redirects after %<count>s of them."
|
|
36
|
+
REDIRECT_SCHEME = "Refused to follow a redirect to a URL that is not a usable http or https address."
|
|
37
|
+
NO_RESPONSE = "The connection closed before a response arrived."
|
|
38
|
+
REQUEST_FAILED = "Request failed with status %<status>s"
|
|
39
|
+
HTTP_STATUS = "HTTP %<status>s"
|
|
40
|
+
UPDATE_AVAILABLE = "%<icon>s %<package>s %<latest>s is available, you are on %<current>s. Upgrade with %<command>s. " \
|
|
41
|
+
"%<changelog>s."
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
class NetHttpAdapter
|
|
5
|
+
STALE_ERRORS = [IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, OpenSSL::SSL::SSLError].freeze
|
|
6
|
+
BODY_ERRORS = [IOError, SystemCallError, OpenSSL::SSL::SSLError, Zlib::Error, Net::HTTPBadResponse].freeze
|
|
7
|
+
EMPTY_WRITES = %w[POST PUT].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(max_idle: Defaults::MAX_IDLE_CONNECTIONS, keep_alive_timeout: Defaults::KEEP_ALIVE_TIMEOUT)
|
|
10
|
+
@pool = ConnectionPool.new(max_idle:, keep_alive_timeout:)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(request)
|
|
14
|
+
uri = URI.parse(request.url)
|
|
15
|
+
raise ArgumentError, "#{request.url.inspect} #{Messages::BASE_URL_SHAPE}" unless uri.is_a?(URI::HTTP)
|
|
16
|
+
|
|
17
|
+
attempt(uri, request, Clock.deadline(request.timeout), false)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def close
|
|
21
|
+
@pool.close
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inspect
|
|
25
|
+
"#<#{self.class.name}>"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def attempt(uri, request, deadline, retried)
|
|
31
|
+
connection, reused = @pool.checkout(uri, Clock.remaining(deadline))
|
|
32
|
+
received = Received.new
|
|
33
|
+
complete = false
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
complete = transfer(connection, outbound(uri, request), received, deadline)
|
|
37
|
+
rescue *STALE_ERRORS
|
|
38
|
+
raise unless reused && !retried && Constants::RETRYABLE_METHODS.include?(request.method)
|
|
39
|
+
ensure
|
|
40
|
+
complete ? @pool.checkin(uri, connection) : @pool.discard(connection)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return received.response if received.head
|
|
44
|
+
|
|
45
|
+
@pool.close
|
|
46
|
+
attempt(uri, request, deadline, true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def transfer(connection, outbound, received, deadline)
|
|
50
|
+
limit(connection, deadline)
|
|
51
|
+
connection.request(outbound) do |incoming|
|
|
52
|
+
received.head = incoming
|
|
53
|
+
incoming.read_body do |chunk|
|
|
54
|
+
received.append(chunk)
|
|
55
|
+
limit(connection, deadline)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
true
|
|
59
|
+
rescue Timeout::Error
|
|
60
|
+
raise
|
|
61
|
+
rescue *BODY_ERRORS
|
|
62
|
+
raise if received.head.nil?
|
|
63
|
+
|
|
64
|
+
received.truncate
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def outbound(uri, request)
|
|
69
|
+
empty = request.body.nil? && EMPTY_WRITES.include?(request.method)
|
|
70
|
+
headers = empty ? request.headers.merge("Content-Length" => "0") : request.headers
|
|
71
|
+
outbound = Net::HTTPGenericRequest.new(request.method, !request.body.nil?, request.method != "HEAD",
|
|
72
|
+
uri.request_uri, headers)
|
|
73
|
+
outbound.body = request.body unless request.body.nil?
|
|
74
|
+
outbound
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def limit(connection, deadline)
|
|
78
|
+
left = Clock.remaining(deadline)
|
|
79
|
+
connection.open_timeout = left
|
|
80
|
+
connection.read_timeout = left
|
|
81
|
+
connection.write_timeout = left
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Numbers
|
|
5
|
+
DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
6
|
+
INFINITY = /\A([+-]?)Infinity\z/
|
|
7
|
+
RADIX_DIGITS = /\A[0-9A-Za-z]+\z/
|
|
8
|
+
RADIXES = {"0x" => 16, "0o" => 8, "0b" => 2}.freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def js_number(text)
|
|
13
|
+
trimmed = Text.js_trim(text)
|
|
14
|
+
return 0.0 if trimmed.empty?
|
|
15
|
+
return trimmed.to_f if DECIMAL.match?(trimmed)
|
|
16
|
+
|
|
17
|
+
infinity = INFINITY.match(trimmed)
|
|
18
|
+
return (infinity[1] == "-") ? -Float::INFINITY : Float::INFINITY if infinity
|
|
19
|
+
|
|
20
|
+
radix(trimmed)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def radix(trimmed)
|
|
24
|
+
base = RADIXES[trimmed[0, 2].to_s.downcase]
|
|
25
|
+
digits = trimmed[2..].to_s
|
|
26
|
+
return Float::NAN if base.nil? || !RADIX_DIGITS.match?(digits)
|
|
27
|
+
|
|
28
|
+
value = Integer(digits, base, exception: false)
|
|
29
|
+
value.nil? ? Float::NAN : value.to_f
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def whole(value)
|
|
33
|
+
(value.is_a?(Float) && value.finite? && value == value.floor) ? value.to_i : value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def text(value)
|
|
37
|
+
number = value.is_a?(Integer) ? value : value.to_f
|
|
38
|
+
return "NaN" if number.is_a?(Float) && number.nan?
|
|
39
|
+
return number.positive? ? "Infinity" : "-Infinity" if number.is_a?(Float) && number.infinite?
|
|
40
|
+
|
|
41
|
+
whole(number).to_s
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Options
|
|
5
|
+
VERB = /\A[A-Z]+\z/
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def valid_timeout(value)
|
|
10
|
+
number = value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(Rational)
|
|
11
|
+
raise ArgumentError, Messages::TIMEOUT_SHAPE unless number && !(value.is_a?(Float) && value.nan?) && value >= 0
|
|
12
|
+
|
|
13
|
+
value
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def deadline_of(seconds)
|
|
17
|
+
(seconds.positive? && seconds <= Defaults::MAX_TIMEOUT) ? seconds : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def valid_retries(value)
|
|
21
|
+
return Defaults::MAX_RETRIES if value.nil?
|
|
22
|
+
raise ArgumentError, Messages::RETRIES_SHAPE unless value.is_a?(Integer)
|
|
23
|
+
|
|
24
|
+
[value, 0].max
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def user_agent(value)
|
|
28
|
+
agent = value.nil? ? Defaults::USER_AGENT : value
|
|
29
|
+
raise ArgumentError, Messages::USER_AGENT_SHAPE unless agent.is_a?(String)
|
|
30
|
+
|
|
31
|
+
trimmed = Text.http_trim(agent)
|
|
32
|
+
raise ArgumentError, Messages::USER_AGENT_SHAPE unless Text.header_value?(trimmed)
|
|
33
|
+
|
|
34
|
+
trimmed
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def adapter(value)
|
|
38
|
+
raise ArgumentError, Messages::ADAPTER_SHAPE unless value.respond_to?(:call)
|
|
39
|
+
|
|
40
|
+
value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def verb(method)
|
|
44
|
+
text = (method.is_a?(String) || method.is_a?(Symbol)) ? method.to_s.upcase : ""
|
|
45
|
+
raise ArgumentError, Messages::METHOD_SHAPE unless VERB.match?(text)
|
|
46
|
+
|
|
47
|
+
text
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def paging(value, name)
|
|
51
|
+
return value if value.nil? || value.is_a?(Integer)
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, format(Messages::PAGING_SHAPE, name:)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Pagination
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def fetch_page(transport, path, collection, listing, timeout)
|
|
8
|
+
Pages.page_of(transport.request(path, query: listing.query, timeout:), collection)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def iterate(transport, path, collection, listing, timeout)
|
|
12
|
+
Enumerator.new do |yielder|
|
|
13
|
+
cursor = listing.walk
|
|
14
|
+
|
|
15
|
+
loop do
|
|
16
|
+
page = fetch_page(transport, path, collection, cursor, timeout)
|
|
17
|
+
page.items.each { |item| yielder << item }
|
|
18
|
+
break unless Pages.more?(page)
|
|
19
|
+
|
|
20
|
+
cursor = cursor.advance(page.items.size)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def collect_all(transport, path, collection, listing, timeout)
|
|
28
|
+
iterate(transport, path, collection, listing, timeout).to_a
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Paths
|
|
5
|
+
UNSAFE = /[^A-Za-z0-9\-_.!~*'()]/
|
|
6
|
+
DOTS = /\A\.+\z/
|
|
7
|
+
PATHS = Constants::PATHS
|
|
8
|
+
SEGMENTS = Constants::SEGMENTS
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def segment(value)
|
|
13
|
+
text = segment_text(value)
|
|
14
|
+
raise ArgumentError, Messages::EMPTY_SEGMENT if text.empty?
|
|
15
|
+
raise ArgumentError, "#{text.inspect} #{Messages::DOT_SEGMENT}" if DOTS.match?(text)
|
|
16
|
+
|
|
17
|
+
text.gsub(UNSAFE) { |character| character.bytes.map { |byte| format("%%%02X", byte) }.join }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def segment_text(value)
|
|
21
|
+
case value
|
|
22
|
+
when String, Symbol then Text.utf8(value.to_s)
|
|
23
|
+
when Integer then value.to_s
|
|
24
|
+
else raise ArgumentError, Messages::SEGMENT_SHAPE
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def product_path(slug) = "#{PATHS[:PRODUCTS]}/#{segment(slug)}"
|
|
29
|
+
|
|
30
|
+
def product_projects_path(slug) = "#{product_path(slug)}/#{SEGMENTS[:PROJECTS]}"
|
|
31
|
+
|
|
32
|
+
def product_access_path(slug) = "#{product_path(slug)}/#{SEGMENTS[:ACCESS]}"
|
|
33
|
+
|
|
34
|
+
def product_access_member_path(slug, member_id) = "#{product_access_path(slug)}/#{segment(member_id)}"
|
|
35
|
+
|
|
36
|
+
def project_path(slug) = "#{PATHS[:PROJECTS]}/#{segment(slug)}"
|
|
37
|
+
|
|
38
|
+
def environments_path(project) = "#{project_path(project)}/#{SEGMENTS[:ENVIRONMENTS]}"
|
|
39
|
+
|
|
40
|
+
def environment_path(project, environment) = "#{environments_path(project)}/#{segment(environment)}"
|
|
41
|
+
|
|
42
|
+
def variables_path(project, environment) = "#{environment_path(project, environment)}/#{SEGMENTS[:VARIABLES]}"
|
|
43
|
+
|
|
44
|
+
def variable_path(project, environment, name) = "#{variables_path(project, environment)}/#{segment(name)}"
|
|
45
|
+
|
|
46
|
+
def history_path(project, environment, name) = "#{variable_path(project, environment, name)}/#{SEGMENTS[:HISTORY]}"
|
|
47
|
+
|
|
48
|
+
def versions_path(project, environment) = "#{environment_path(project, environment)}/#{SEGMENTS[:VERSIONS]}"
|
|
49
|
+
|
|
50
|
+
def entries_path(project, environment) = "#{versions_path(project, environment)}/#{SEGMENTS[:ENTRIES]}"
|
|
51
|
+
|
|
52
|
+
def variable_by_id_path(id) = "#{PATHS[:VARIABLES]}/#{segment(id)}"
|
|
53
|
+
|
|
54
|
+
def version_by_id_path(id) = "#{PATHS[:VERSIONS]}/#{segment(id)}"
|
|
55
|
+
|
|
56
|
+
def member_path(id) = "#{PATHS[:MEMBERS]}/#{segment(id)}"
|
|
57
|
+
|
|
58
|
+
def invite_path(id) = "#{PATHS[:INVITES]}/#{segment(id)}"
|
|
59
|
+
|
|
60
|
+
def role_path(id) = "#{PATHS[:ROLES]}/#{segment(id)}"
|
|
61
|
+
|
|
62
|
+
def key_path(id) = "#{PATHS[:KEYS]}/#{segment(id)}"
|
|
63
|
+
|
|
64
|
+
def webhook_path(id) = "#{PATHS[:WEBHOOKS]}/#{segment(id)}"
|
|
65
|
+
|
|
66
|
+
def deliveries_path(id) = "#{webhook_path(id)}/#{SEGMENTS[:DELIVERIES]}"
|
|
67
|
+
|
|
68
|
+
def delivery_path(id, delivery_id) = "#{deliveries_path(id)}/#{segment(delivery_id)}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
class Received
|
|
5
|
+
attr_accessor :head
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@head = nil
|
|
9
|
+
@body = "".b
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def append(chunk)
|
|
13
|
+
@body << chunk
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def truncate
|
|
17
|
+
@body = "".b
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def response
|
|
21
|
+
current = head
|
|
22
|
+
raise Net::HTTPBadResponse, Messages::NO_RESPONSE if current.nil?
|
|
23
|
+
|
|
24
|
+
HttpResponse.new(status: current.code.to_i, headers: current.each_header.to_h, body: @body,
|
|
25
|
+
reason: current.message.to_s)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|