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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4b6f191fde8f917a2a05741ffc41fdb3f0f5555c6cd40f77ef62eee17f4ebe79
|
|
4
|
+
data.tar.gz: 9a9b3a4001d28af65d39ea62e59339d5965c82775d7674ce3478ab737328d8f4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ee19e6d5b3d1c39afb10f480c61b1f0402c785f645fbb9f07831968ba23804d79a4c8957471fe8a81d2f3e189451a66bbd7353f63687ba1c0e1bf25c9d2b82db
|
|
7
|
+
data.tar.gz: 30dfed76e0191bcf6fb253252bfbf75082d2637dd1716da8d7c81ba5197c4bfe1aaec09b8c8b147a718cab4b492a4a9a2917eea363708f013c152611df80a847
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
The first release of the Ruby gem. It covers the whole TypeScript SDK as it stands in this repository: 113 methods across 13 namespaces, each under its TypeScript name in snake_case. `rake parity` proves that every one of them sends exactly the request its TypeScript twin sends, with no options, with every option set and with the body passed as keywords, and retries, fails and returns the same way. It also proves the encryption, the validators, webhook verification and passphrase rotation agree with TypeScript on the same inputs, and that ciphertext written by either one is read by the other.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
`Envless::Client` has a reader for every namespace, from `variables` and `versions` to `workspace.domain` and `products.access`. `listAll` is `list_all`, `getById` is `get_by_id` and `reencryptEntries` is `reencrypt_entries`. A request body goes in as keyword arguments or as one Hash, its keys keep the API's camelCase names, the options of a call are snake_case keywords (`product:`, `limit:`, `offset:`, `search:`, `order:`, `status:`, `reattempt:`), and a response comes back as a Hash with Symbol keys.
|
|
10
|
+
|
|
11
|
+
client = Envless::Client.new(token: ENV.fetch("ENVLESS_TOKEN"))
|
|
12
|
+
client.variables.create("api", "production", name: "API_KEY", value: ciphertext)
|
|
13
|
+
|
|
14
|
+
Every paginated resource has `list`, which returns one `Envless::Page` with `items` and `pagination`, `list_all`, which returns every item as an Array, and `iterate`, which yields each item to a block or returns an Enumerator that fetches the next page only when it gets there.
|
|
15
|
+
|
|
16
|
+
An API refusal raises `Envless::ApiError`, which carries `status`, `code`, `resource`, `field`, `request_id` and `retry_after_seconds`, and answers `auth?`, `scope_missing?`, `validation?`, `not_found?`, `conflict?`, `rate_limited?` and `needs_upgrade?`. No response at all raises `Envless::NetworkError`, with `timeout?` when the deadline passed. A value that cannot be decrypted raises `Envless::DecryptionError`, a delivery that fails verification raises `Envless::WebhookVerificationError`, and all of them inherit `Envless::Error`.
|
|
17
|
+
|
|
18
|
+
`encrypt_value`, `decrypt_value`, `encrypt_with_key`, `decrypt_with_key`, `derive_workspace_key`, `import_workspace_key`, `ciphertext?`, `passphrase_strength` and `ENCRYPTION_PARAMETERS` produce and read the same `v1:` and `v2:` envelopes as the TypeScript and Python SDKs, the CLI and the dashboard: AES-256-GCM under a PBKDF2-SHA-256 key with 200,000 iterations and the workspace salt. `rotate_workspace_passphrase` re-encrypts an environment and its version history under a new passphrase, and lists in `failures` any version entry whose encrypted default it cannot rewrite because the entry's own value is not ciphertext. `variable_name_validation` and `verify_webhook_signature` behave as their TypeScript twins. Plaintext, passphrases and workspace ids must be valid UTF-8, and a raw key goes in only as 32 binary bytes, so binary data or a passphrase passed by mistake raises `ArgumentError` instead of producing ciphertext nothing else can read.
|
|
19
|
+
|
|
20
|
+
`Envless.variables` and every other namespace reach a shared client, built from `ENVLESS_TOKEN` and `ENVLESS_API_ENDPOINT` the first time one is touched, and `Envless.init(...)` configures it once at startup. `client.raw.request` reaches an endpoint no method wraps yet, with the client's key, base URL, timeout and retries.
|
|
21
|
+
|
|
22
|
+
`GET`, `PUT` and `DELETE` requests are retried on 408, 429, 500, 502, 503 and 504, with exponential backoff or the wait `Retry-After` asks for, capped at 30 seconds. `timeout` bounds each attempt, redirects and the response body included. Every request carries `User-Agent: envless-sdk-ruby/0.0.1`.
|
|
23
|
+
|
|
24
|
+
The gem ships RBS signatures for every method, body and response, with a manifest that declares the standard libraries they use, so `library "envless-sdk"` in a Steepfile is all a project needs. It needs Ruby 3.2 or newer and has no runtime dependencies.
|
data/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<div align='center'>
|
|
2
|
+
<a href='https://envless.cloud'>
|
|
3
|
+
<img
|
|
4
|
+
src='https://cdn-prod.envless.cloud/assets/npm/logo.svg'
|
|
5
|
+
alt='Envless Logo'
|
|
6
|
+
width='180'
|
|
7
|
+
/>
|
|
8
|
+
</a>
|
|
9
|
+
|
|
10
|
+
<br />
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<p align='center'>
|
|
14
|
+
End-to-end encrypted environment variables for teams. Manage workspaces, projects, environments and secrets from code.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align='center'>
|
|
18
|
+
<a href='https://envless.cloud'>
|
|
19
|
+
<b>
|
|
20
|
+
Website
|
|
21
|
+
</b>
|
|
22
|
+
</a>
|
|
23
|
+
•
|
|
24
|
+
<a href='https://envless.cloud/dashboard'>
|
|
25
|
+
<b>
|
|
26
|
+
Dashboard
|
|
27
|
+
</b>
|
|
28
|
+
</a>
|
|
29
|
+
•
|
|
30
|
+
<a href='https://envless.cloud/docs/ruby/overview'>
|
|
31
|
+
<b>
|
|
32
|
+
Documentation
|
|
33
|
+
</b>
|
|
34
|
+
</a>
|
|
35
|
+
•
|
|
36
|
+
<a href='https://status.envless.cloud'>
|
|
37
|
+
<b>
|
|
38
|
+
Services Status
|
|
39
|
+
</b>
|
|
40
|
+
</a>
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<br />
|
|
44
|
+
|
|
45
|
+
## Intro to the Ruby Gem
|
|
46
|
+
|
|
47
|
+
The official Ruby client for the Envless API. It has every method of the TypeScript SDK, all 113 across 13 namespaces, under the same names in snake_case, and ships RBS signatures for all of them. It runs on Ruby 3.2 and newer and has no runtime dependencies: HTTP is `Net::HTTP` and the encryption is `OpenSSL`, both from the standard library, with a pool of kept-alive connections that one client shares safely across threads and rebuilds after a fork.
|
|
48
|
+
|
|
49
|
+
It carries an API key with write access, so it belongs on a server, in a job or in CI.
|
|
50
|
+
|
|
51
|
+
### Installing
|
|
52
|
+
```bash
|
|
53
|
+
bundle add envless-sdk
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or `gem install envless-sdk` without Bundler. You require it as `envless/sdk` and use it as `Envless`. The `envless` gem on RubyGems is an unrelated project, so `gem install envless` installs something else.
|
|
57
|
+
|
|
58
|
+
### Using
|
|
59
|
+
```ruby
|
|
60
|
+
require "envless/sdk"
|
|
61
|
+
|
|
62
|
+
passphrase = ENV.fetch("ENVLESS_PASSPHRASE")
|
|
63
|
+
workspace_id = Envless.me.get[:workspaceId]
|
|
64
|
+
|
|
65
|
+
Envless.variables.create("api", "production",
|
|
66
|
+
name: "STRIPE_SECRET_KEY",
|
|
67
|
+
value: Envless.encrypt_value("sk_live_51H...", passphrase, workspace_id))
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`Envless.variables`, `Envless.me` and every other namespace use a shared client that reads `ENVLESS_TOKEN` the first time one is touched. Call `Envless.init(...)` once at startup to configure it, or build your own with `Envless::Client.new`, which reads the same variable when you pass no token.
|
|
71
|
+
|
|
72
|
+
Values are encrypted on your machine, never by the server, so a plaintext value is refused. A request body goes in as keyword arguments or as one Hash, and its keys keep the API's own names, so `defaultValue:` and `projectIds:` read exactly as they do in the API reference. The options of a call are snake_case keywords: `product:`, `limit:`, `offset:`, `search:`, `order:`, `status:`, `reattempt:` and `timeout:`. A response comes back as the parsed JSON, a Hash with Symbol keys, read with `variable[:updatedAt]`.
|
|
73
|
+
|
|
74
|
+
The gem ships RBS signatures in `sig/`, with a record type for every body and response, so Steep and editors that read RBS check the keys and the types of each call. `library "envless-sdk"` in your Steepfile loads them, along with the standard libraries they use.
|
|
75
|
+
|
|
76
|
+
### A client of your own
|
|
77
|
+
```ruby
|
|
78
|
+
require "envless/sdk"
|
|
79
|
+
|
|
80
|
+
client = Envless::Client.new(token: ENV.fetch("ENVLESS_TOKEN"), timeout: 10, max_retries: 2)
|
|
81
|
+
|
|
82
|
+
client.variables.iterate("api", "production", product: "billing") do |variable|
|
|
83
|
+
puts "#{variable[:name]} #{variable[:updatedAt]}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
client.close
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A client is frozen once built, is safe to share between threads and closes its idle connections with `close`.
|
|
90
|
+
|
|
91
|
+
### Pagination
|
|
92
|
+
Every collection has `list` for one page, `list_all` for every page at once as an Array, and `iterate`, which yields each item to a block, or without one returns an Enumerator that fetches the next page only when it gets there, so `first(10)` stops early. A page is an `Envless::Page` with `items` and `pagination`, plus `has_more?` and `total_count`. `products.access.list` returns an `Envless::ProductAccessPage`, which also answers `private?`.
|
|
93
|
+
|
|
94
|
+
### Encryption
|
|
95
|
+
```ruby
|
|
96
|
+
require "envless/sdk"
|
|
97
|
+
|
|
98
|
+
workspace_id = Envless.me.get[:workspaceId]
|
|
99
|
+
key = Envless.derive_workspace_key(ENV.fetch("ENVLESS_PASSPHRASE"), workspace_id)
|
|
100
|
+
|
|
101
|
+
secrets = Envless.variables.list_all("api", "production").filter_map do |variable|
|
|
102
|
+
value = variable[:value]
|
|
103
|
+
[variable[:name], Envless.decrypt_with_key(key, value)] if value && Envless.ciphertext?(value)
|
|
104
|
+
end.to_h
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`encrypt_value` and `decrypt_value` derive the key on every call, which costs 200,000 rounds of PBKDF2. To work with many values, derive the key once with `derive_workspace_key`, or load the `ENVLESS_KEY` your CI holds with `import_workspace_key`, then use `encrypt_with_key` and `decrypt_with_key`. An `Envless::WorkspaceKey` never prints its bytes, and `to_bytes` hands them over when you need them. The `_with_key` methods also take those 32 bytes as a binary String, but never as text, so a passphrase can't be mistaken for a key. Plaintext, passphrases and workspace ids must be valid UTF-8: binary data raises `ArgumentError` rather than being changed silently, so encode it first, for example as base64. Ciphertext from this gem and from the TypeScript and Python SDKs, the CLI and the dashboard is interchangeable.
|
|
108
|
+
|
|
109
|
+
`ciphertext?`, `passphrase_strength`, `variable_name_validation` and `ENCRYPTION_PARAMETERS` work exactly as they do in TypeScript. The two validators return an `Envless::Validation` with `valid?` and `error`.
|
|
110
|
+
|
|
111
|
+
### Rotating the passphrase
|
|
112
|
+
```ruby
|
|
113
|
+
require "envless/sdk"
|
|
114
|
+
|
|
115
|
+
result = Envless.rotate_workspace_passphrase(client: Envless.client, workspace_unique_id: Envless.me.get[:workspaceId],
|
|
116
|
+
from: ENV.fetch("ENVLESS_PASSPHRASE"), to: ENV.fetch("ENVLESS_NEW_PASSPHRASE"), project: "api", environment: "production")
|
|
117
|
+
|
|
118
|
+
puts result.variables, result.version_entries, result.skipped.size, result.failures.size
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
It re-encrypts an environment and its version history under the new passphrase and refuses one that `passphrase_strength` rejects. Values that are not ciphertext are left alone and listed in `skipped`, and anything that failed to decrypt or that the API refused is listed in `failures`, so a partial run can be retried. A version entry whose own value is not ciphertext lands in `failures` too when its default is, because the API cannot rewrite that default.
|
|
122
|
+
|
|
123
|
+
### Errors
|
|
124
|
+
```ruby
|
|
125
|
+
require "envless/sdk"
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
Envless.variables.get("api", "production", "MISSING")
|
|
129
|
+
rescue Envless::ApiError => error
|
|
130
|
+
raise unless error.not_found?
|
|
131
|
+
|
|
132
|
+
warn [error.code, error.request_id].join(" ")
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
An API refusal raises `Envless::ApiError`, with `status`, `code`, `resource`, `field`, `request_id` and `retry_after_seconds`, plus `auth?`, `scope_missing?`, `validation?`, `not_found?`, `conflict?`, `rate_limited?` and `needs_upgrade?`. No response at all raises `Envless::NetworkError`, with `timeout?` when the deadline passed. A value that cannot be decrypted raises `Envless::DecryptionError`, and a delivery that fails verification raises `Envless::WebhookVerificationError`. All of them inherit `Envless::Error`. An unusable variable name is refused before anything is sent, with the same `ApiError` the API would answer with, and a mistake in the call itself, such as an empty slug, raises `ArgumentError`.
|
|
137
|
+
|
|
138
|
+
### Webhooks
|
|
139
|
+
```ruby
|
|
140
|
+
class EnvlessWebhooksController < ActionController::API
|
|
141
|
+
def create
|
|
142
|
+
event = Envless.verify_webhook_signature(
|
|
143
|
+
payload: request.raw_post,
|
|
144
|
+
headers: request.headers,
|
|
145
|
+
secret: Rails.application.credentials.envless_webhook_secret
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
Rails.logger.info("#{event[:type]} #{event[:data]}")
|
|
149
|
+
head :no_content
|
|
150
|
+
rescue Envless::WebhookVerificationError
|
|
151
|
+
head :bad_request
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
It checks the signature in constant time and rejects a delivery more than five minutes old, then returns the parsed event. Pass the raw body, since a parsed or re-encoded body no longer matches its signature. The headers can be a Hash in any case, a Rack env or Rails' `request.headers`.
|
|
157
|
+
|
|
158
|
+
### Configuring
|
|
159
|
+
`Envless::Client.new` takes `token:`, `base_url:`, `timeout:`, `max_retries:`, `adapter:`, `user_agent:` and `disable_update_notice:`. `base_url` also comes from `ENVLESS_API_ENDPOINT`. `timeout` is in seconds, bounds each attempt including redirects and the response body, and `0` turns it off. `GET`, `PUT` and `DELETE` requests are retried on 408, 429, 500, 502, 503 and 504, up to three times with exponential backoff, waiting what `Retry-After` asks for up to 30 seconds. Writes that cannot safely repeat are not retried. Every method also takes `timeout:` for that one call.
|
|
160
|
+
|
|
161
|
+
`adapter:` swaps the HTTP layer for anything that responds to `call(request)`, receives an `Envless::HttpRequest` and returns an object with `status`, `headers` and `body`. The default honours `http_proxy`, `https_proxy` and `no_proxy`.
|
|
162
|
+
|
|
163
|
+
An endpoint no method wraps yet is one `raw.request` away, with the client's key, base URL, timeout and retries applied:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
require "envless/sdk"
|
|
167
|
+
|
|
168
|
+
Envless.client.raw.request("/projects", method: :post, body: {name: "Billing", slug: "billing"})
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When a newer version is on RubyGems the client says so once on a terminal. `ENVLESS_DISABLE_UPDATE_NOTICE=1` or `disable_update_notice: true` turns that off.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
class ApiError < Error
|
|
5
|
+
attr_reader :status, :code, :resource, :field, :request_id, :retry_after_seconds
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, status: nil, code: Constants::ERROR_CODES[:SERVER_ERROR], resource: nil, field: nil,
|
|
8
|
+
request_id: nil, retry_after_seconds: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@status = status
|
|
11
|
+
@code = code
|
|
12
|
+
@resource = resource
|
|
13
|
+
@field = field
|
|
14
|
+
@request_id = request_id
|
|
15
|
+
@retry_after_seconds = retry_after_seconds
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def detailed_message(highlight: false, **options)
|
|
19
|
+
"#{super} [#{[status, code].compact.join(" ")}#{", request #{request_id}" if request_id}]"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inspect
|
|
23
|
+
"#<#{self.class.name} status=#{status.inspect} code=#{code.inspect} message=#{message.inspect}>"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def auth?
|
|
27
|
+
code.to_s.start_with?("auth.")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def scope_missing?
|
|
31
|
+
code == "auth.scope_missing"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validation?
|
|
35
|
+
status == 400
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def not_found?
|
|
39
|
+
status == 404
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def conflict?
|
|
43
|
+
status == 409
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rate_limited?
|
|
47
|
+
status == 429
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def needs_upgrade?
|
|
51
|
+
status == 402
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Bodies
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def merge(body, fields)
|
|
8
|
+
base = hash_of(body).transform_keys(&:to_s)
|
|
9
|
+
base.merge(fields.to_h { |key, value| [key.to_s, value] })
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def hash_of(body)
|
|
13
|
+
return {} if body.nil?
|
|
14
|
+
|
|
15
|
+
mapping(body) || raise(ArgumentError, Messages::BODY_SHAPE)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def mapping(value)
|
|
19
|
+
return value if value.is_a?(Hash)
|
|
20
|
+
return value.to_hash if value.respond_to?(:to_hash)
|
|
21
|
+
|
|
22
|
+
value.to_h if value.is_a?(Struct) || value.is_a?(Data)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def entries(values, name)
|
|
26
|
+
list(values, name).map do |value|
|
|
27
|
+
hash = mapping(value)
|
|
28
|
+
hash ? hash.to_h { |key, item| [key.to_s, item] } : value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list(values, name)
|
|
33
|
+
return values if values.is_a?(Array)
|
|
34
|
+
return values.to_a if values.is_a?(Set)
|
|
35
|
+
|
|
36
|
+
raise ArgumentError, format(Messages::LIST_SHAPE, name:, kind: values.class)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def field(hash, key)
|
|
40
|
+
return nil unless hash.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def field?(hash, key)
|
|
46
|
+
hash.is_a?(Hash) && (hash.key?(key) || hash.key?(key.to_sym))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
class Client
|
|
5
|
+
attr_reader :raw, :me, :workspace, :workspaces, :products, :projects, :environments, :variables, :versions,
|
|
6
|
+
:members, :invites, :roles, :keys, :webhooks
|
|
7
|
+
|
|
8
|
+
def initialize(token_argument = nil, token: nil, base_url: nil, adapter: nil, max_retries: nil, timeout: nil,
|
|
9
|
+
user_agent: nil, disable_update_notice: false)
|
|
10
|
+
raise ArgumentError, Messages::TOKEN_TWICE unless token_argument.nil? || token.nil?
|
|
11
|
+
|
|
12
|
+
@raw = Transport.new(token: Environment.resolve_token(token.nil? ? token_argument : token),
|
|
13
|
+
base_url: Environment.resolve_base_url(base_url), adapter:, max_retries:, timeout:, user_agent:,
|
|
14
|
+
disable_update_notice:)
|
|
15
|
+
@me = Resources::Me.new(@raw)
|
|
16
|
+
@workspace = Resources::Workspace.new(@raw)
|
|
17
|
+
@workspaces = Resources::Workspaces.new(@raw)
|
|
18
|
+
@products = Resources::Products.new(@raw)
|
|
19
|
+
@projects = Resources::Projects.new(@raw)
|
|
20
|
+
@environments = Resources::Environments.new(@raw)
|
|
21
|
+
@variables = Resources::Variables.new(@raw)
|
|
22
|
+
@versions = Resources::Versions.new(@raw)
|
|
23
|
+
@members = Resources::Members.new(@raw)
|
|
24
|
+
@invites = Resources::Invites.new(@raw)
|
|
25
|
+
@roles = Resources::Roles.new(@raw)
|
|
26
|
+
@keys = Resources::Keys.new(@raw)
|
|
27
|
+
@webhooks = Resources::Webhooks.new(@raw)
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def close
|
|
32
|
+
@raw.close
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inspect
|
|
36
|
+
"#<#{self.class.name} base_url=#{@raw.base_url.inspect}>"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Clock
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def now
|
|
8
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def deadline(seconds)
|
|
12
|
+
seconds && (now + seconds)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def remaining(deadline)
|
|
16
|
+
return nil if deadline.nil?
|
|
17
|
+
|
|
18
|
+
left = deadline - now
|
|
19
|
+
raise Timeout::Error if left <= 0
|
|
20
|
+
|
|
21
|
+
left
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
class ConnectionPool
|
|
5
|
+
CLOSE_ERRORS = [IOError, SystemCallError, OpenSSL::SSL::SSLError].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(max_idle:, keep_alive_timeout:)
|
|
8
|
+
@max_idle = max_idle
|
|
9
|
+
@keep_alive_timeout = keep_alive_timeout
|
|
10
|
+
@idle = Hash.new { |pools, key| pools[key] = [] }
|
|
11
|
+
@lock = Mutex.new
|
|
12
|
+
@pid = Process.pid
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def checkout(uri, timeout)
|
|
16
|
+
while (pooled = take(uri))
|
|
17
|
+
return [pooled, true] if usable?(pooled)
|
|
18
|
+
|
|
19
|
+
discard(pooled)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
[Connector.open(uri, timeout, @keep_alive_timeout), false]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def checkin(uri, connection)
|
|
26
|
+
kept = @lock.synchronize do
|
|
27
|
+
room = @pid == Process.pid && @idle[key(uri)].size < @max_idle
|
|
28
|
+
@idle[key(uri)].push(connection) if room
|
|
29
|
+
room
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
discard(connection) unless kept
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def discard(connection)
|
|
36
|
+
connection.finish if connection.started?
|
|
37
|
+
rescue *CLOSE_ERRORS
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def close
|
|
42
|
+
drained = @lock.synchronize do
|
|
43
|
+
connections = @idle.values.flatten
|
|
44
|
+
connections.clear unless @pid == Process.pid
|
|
45
|
+
@idle.clear
|
|
46
|
+
@pid = Process.pid
|
|
47
|
+
connections
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
drained.each { |connection| discard(connection) }
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def take(uri)
|
|
57
|
+
@lock.synchronize do
|
|
58
|
+
forget_inherited
|
|
59
|
+
@idle[key(uri)].pop
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def usable?(connection)
|
|
64
|
+
io = connection.instance_variable_get(:@socket)&.io
|
|
65
|
+
return true if io.nil?
|
|
66
|
+
|
|
67
|
+
!io.closed? && !io.to_io.wait_readable(0)
|
|
68
|
+
rescue *CLOSE_ERRORS
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def forget_inherited
|
|
73
|
+
return if @pid == Process.pid
|
|
74
|
+
|
|
75
|
+
@idle.clear
|
|
76
|
+
@pid = Process.pid
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def key(uri)
|
|
80
|
+
[uri.scheme.to_s.downcase, uri.hostname.to_s.downcase, uri.port]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Envless
|
|
4
|
+
module Connector
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def open(uri, timeout, keep_alive_timeout)
|
|
8
|
+
proxy = uri.find_proxy
|
|
9
|
+
connection = Net::HTTP.new(uri.hostname.to_s, uri.port, proxy&.hostname, proxy&.port, decoded(proxy&.user),
|
|
10
|
+
decoded(proxy&.password))
|
|
11
|
+
connection.use_ssl = uri.scheme.to_s.casecmp?("https")
|
|
12
|
+
connection.min_version = OpenSSL::SSL::TLS1_2_VERSION if connection.use_ssl?
|
|
13
|
+
connection.open_timeout = timeout
|
|
14
|
+
connection.read_timeout = timeout
|
|
15
|
+
connection.write_timeout = timeout
|
|
16
|
+
connection.keep_alive_timeout = keep_alive_timeout
|
|
17
|
+
connection.max_retries = 0
|
|
18
|
+
connection.ignore_eof = false
|
|
19
|
+
connection.start
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def decoded(text)
|
|
23
|
+
text && URI.decode_uri_component(text)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|