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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +171 -0
  4. data/lib/envless/sdk/api_error.rb +54 -0
  5. data/lib/envless/sdk/bodies.rb +49 -0
  6. data/lib/envless/sdk/client.rb +39 -0
  7. data/lib/envless/sdk/clock.rb +24 -0
  8. data/lib/envless/sdk/connection_pool.rb +83 -0
  9. data/lib/envless/sdk/connector.rb +26 -0
  10. data/lib/envless/sdk/constants.rb +262 -0
  11. data/lib/envless/sdk/crypto/cipher.rb +58 -0
  12. data/lib/envless/sdk/crypto/codec.rb +23 -0
  13. data/lib/envless/sdk/crypto/envelope.rb +41 -0
  14. data/lib/envless/sdk/crypto/keys.rb +42 -0
  15. data/lib/envless/sdk/crypto/passphrase.rb +38 -0
  16. data/lib/envless/sdk/crypto/passphrase_rules.rb +42 -0
  17. data/lib/envless/sdk/crypto/values.rb +31 -0
  18. data/lib/envless/sdk/crypto/workspace_key.rb +40 -0
  19. data/lib/envless/sdk/crypto_messages.rb +31 -0
  20. data/lib/envless/sdk/default_client.rb +55 -0
  21. data/lib/envless/sdk/defaults.rb +32 -0
  22. data/lib/envless/sdk/envelope.rb +48 -0
  23. data/lib/envless/sdk/environment.rb +42 -0
  24. data/lib/envless/sdk/errors.rb +23 -0
  25. data/lib/envless/sdk/exchange.rb +27 -0
  26. data/lib/envless/sdk/helpers.rb +49 -0
  27. data/lib/envless/sdk/http.rb +46 -0
  28. data/lib/envless/sdk/listing.rb +54 -0
  29. data/lib/envless/sdk/messages.rb +43 -0
  30. data/lib/envless/sdk/net_http_adapter.rb +84 -0
  31. data/lib/envless/sdk/numbers.rb +44 -0
  32. data/lib/envless/sdk/options.rb +56 -0
  33. data/lib/envless/sdk/pagination.rb +31 -0
  34. data/lib/envless/sdk/paths.rb +70 -0
  35. data/lib/envless/sdk/received.rb +28 -0
  36. data/lib/envless/sdk/redirects.rb +56 -0
  37. data/lib/envless/sdk/resource.rb +50 -0
  38. data/lib/envless/sdk/resources/environments.rb +61 -0
  39. data/lib/envless/sdk/resources/invites.rb +28 -0
  40. data/lib/envless/sdk/resources/keys.rb +36 -0
  41. data/lib/envless/sdk/resources/me.rb +11 -0
  42. data/lib/envless/sdk/resources/members.rb +36 -0
  43. data/lib/envless/sdk/resources/product_access.rb +30 -0
  44. data/lib/envless/sdk/resources/products.rb +62 -0
  45. data/lib/envless/sdk/resources/projects.rb +42 -0
  46. data/lib/envless/sdk/resources/roles.rb +40 -0
  47. data/lib/envless/sdk/resources/variable_changes.rb +57 -0
  48. data/lib/envless/sdk/resources/variables.rb +49 -0
  49. data/lib/envless/sdk/resources/versions.rb +60 -0
  50. data/lib/envless/sdk/resources/webhook_deliveries.rb +33 -0
  51. data/lib/envless/sdk/resources/webhooks.rb +44 -0
  52. data/lib/envless/sdk/resources/workspace.rb +26 -0
  53. data/lib/envless/sdk/resources/workspace_domain.rb +31 -0
  54. data/lib/envless/sdk/resources/workspaces.rb +24 -0
  55. data/lib/envless/sdk/results.rb +43 -0
  56. data/lib/envless/sdk/retry_policy.rb +36 -0
  57. data/lib/envless/sdk/rotation/entry_rotator.rb +61 -0
  58. data/lib/envless/sdk/rotation/key_ring.rb +34 -0
  59. data/lib/envless/sdk/rotation/ledger.rb +36 -0
  60. data/lib/envless/sdk/rotation/runner.rb +45 -0
  61. data/lib/envless/sdk/rotation/variable_rotator.rb +38 -0
  62. data/lib/envless/sdk/text.rb +71 -0
  63. data/lib/envless/sdk/transport.rb +62 -0
  64. data/lib/envless/sdk/update_notice.rb +66 -0
  65. data/lib/envless/sdk/urls.rb +70 -0
  66. data/lib/envless/sdk/variable_names.rb +55 -0
  67. data/lib/envless/sdk/version.rb +5 -0
  68. data/lib/envless/sdk/webhooks/headers.rb +49 -0
  69. data/lib/envless/sdk/webhooks/verify.rb +58 -0
  70. data/lib/envless/sdk/wire.rb +77 -0
  71. data/lib/envless/sdk.rb +77 -0
  72. data/sig/envless/sdk/adapter.rbs +60 -0
  73. data/sig/envless/sdk/client.rbs +31 -0
  74. data/sig/envless/sdk/crypto.rbs +92 -0
  75. data/sig/envless/sdk/errors.rbs +37 -0
  76. data/sig/envless/sdk/http.rbs +31 -0
  77. data/sig/envless/sdk/pagination.rbs +81 -0
  78. data/sig/envless/sdk/results.rbs +62 -0
  79. data/sig/envless/sdk/rotation.rbs +86 -0
  80. data/sig/envless/sdk/settings.rbs +81 -0
  81. data/sig/envless/sdk/stdlib.rbs +8 -0
  82. data/sig/envless/sdk/text.rbs +52 -0
  83. data/sig/envless/sdk/transport.rbs +79 -0
  84. data/sig/envless/sdk/urls.rbs +73 -0
  85. data/sig/envless/sdk/webhooks.rbs +23 -0
  86. data/sig/envless/sdk.rbs +36 -0
  87. data/sig/generated/constants.rbs +57 -0
  88. data/sig/generated/resources.rbs +209 -0
  89. data/sig/generated/types.rbs +466 -0
  90. data/sig/manifest.yaml +3 -0
  91. 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