Skip to content

Commit 98041a6

Browse files
committed
fix: final fixes
1 parent 1a8a562 commit 98041a6

12 files changed

Lines changed: 246 additions & 29 deletions

File tree

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,29 @@ client.secrets.list(project_id: "...", environment: "dev", secret_path: "/")
6363
# secret per key unless skip_unique_validation: true is passed.
6464
client.secrets.list(project_id: "...", environment: "dev", recursive: true)
6565

66+
# Export fetched secrets into the process environment (never overrides
67+
# variables that already have a value):
68+
client.secrets.list(project_id: "...", environment: "dev", attach_to_process_env: true)
69+
ENV.fetch("DATABASE_URL")
70+
6671
client.secrets.get("DATABASE_URL", project_id: "...", environment: "dev")
6772
client.secrets.create("DATABASE_URL", "postgres://...", project_id: "...", environment: "dev")
6873
client.secrets.update("DATABASE_URL", project_id: "...", environment: "dev", secret_value: "postgres://...")
6974
client.secrets.delete("DATABASE_URL", project_id: "...", environment: "dev")
75+
76+
# create and update accept skip_multiline_encoding: true to disable the
77+
# API's encoding of multi-line values.
7078
```
7179

80+
Each returned secret exposes `secret_key`, `secret_value`, `secret_comment`,
81+
`secret_path`, `version`, `metadata` (an array of key/value entries), and
82+
`tags` (an array with `id`, `slug`, `name`, and `color`).
83+
7284
### Error handling
7385

7486
Every error raised by the SDK inherits from `Infisical::Error`. API failures
75-
raise `Infisical::APIError` (with `status`, `url`, `method`, and `request_id`
76-
readers), and well-known statuses raise a dedicated subclass:
87+
raise `Infisical::APIError` (with `status`, `url`, `http_method`, and
88+
`request_id` readers), and well-known statuses raise a dedicated subclass:
7789

7890
```ruby
7991
begin
@@ -83,7 +95,7 @@ rescue Infisical::NotFoundError # 404
8395
rescue Infisical::AuthenticationError # 401: bad or expired credentials
8496
# re-authenticate
8597
rescue Infisical::APIError => e # anything else the API rejected
86-
# e.status, e.url, e.method, e.request_id
98+
# e.status, e.url, e.http_method, e.request_id
8799
end
88100
```
89101

@@ -98,6 +110,9 @@ connection resets) raise `Infisical::RequestError` after retries.
98110
client = Infisical::Client.new(site_url: "https://your-infisical-instance.com")
99111
```
100112

113+
A trailing `/api` on the site URL (as used with some other Infisical SDKs) is
114+
accepted and normalized away.
115+
101116
## Documentation
102117

103118
You can find the documentation for the Ruby SDK on our [SDK documentation page](https://infisical.com/docs/sdks/languages/ruby).

lib/infisical/auth.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ def initialize(http_client)
2525
# this client
2626
# @raise [AuthenticationError] if the credentials are rejected
2727
def universal_auth_login(client_id:, client_secret:)
28+
# auth: false keeps any previously stored (possibly expired) bearer
29+
# token off the login request; the API breaks on stale tokens there.
2830
response = @http_client.post(
2931
UNIVERSAL_AUTH_LOGIN_PATH,
30-
body: { clientId: client_id, clientSecret: client_secret }
32+
body: { clientId: client_id, clientSecret: client_secret },
33+
auth: false
3134
)
3235

3336
access_token(response["accessToken"])

lib/infisical/client.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
module Infisical
1010
# Entry point for the SDK. Construct one, authenticate via {#auth}, then
11-
# use {#secrets} (and future resource clients) to talk to Infisical.
11+
# use {#secrets} to talk to Infisical.
1212
#
1313
# @example Fetch a secret
1414
# client = Infisical::Client.new
@@ -20,12 +20,13 @@ class Client
2020
ALLOWED_SCHEMES = %w[http https].freeze
2121

2222
# @param site_url [String] base URL of the Infisical instance; defaults to
23-
# Infisical Cloud, so only self-hosted deployments need to set it
23+
# Infisical Cloud, so only self-hosted deployments need to set it. A
24+
# trailing "/api" is tolerated and stripped.
2425
# @param timeout [Numeric] open/read timeout in seconds for each request
2526
# @raise [ArgumentError] if site_url is not an http(s) URL
2627
def initialize(site_url: DEFAULT_SITE_URL, timeout: HTTPClient::DEFAULT_TIMEOUT)
2728
validate_site_url!(site_url)
28-
@http_client = HTTPClient.new(base_url: site_url, timeout: timeout)
29+
@http_client = HTTPClient.new(base_url: normalize_site_url(site_url), timeout: timeout)
2930
end
3031

3132
# Authentication operations. Logging in through this authenticates the
@@ -45,6 +46,15 @@ def secrets
4546

4647
private
4748

49+
# Users coming from other Infisical SDKs are used to passing the site
50+
# URL with an "/api" suffix; the SDK adds that segment itself, so strip
51+
# it (and any trailing slashes) rather than requesting ".../api/api/...".
52+
def normalize_site_url(site_url)
53+
url = site_url.dup
54+
url.chomp!("/") while url.end_with?("/")
55+
url.delete_suffix("/api")
56+
end
57+
4858
def validate_site_url!(site_url)
4959
uri = URI.parse(site_url)
5060
return if ALLOWED_SCHEMES.include?(uri.scheme) && uri.host

lib/infisical/errors.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class APIError < Error
1717
attr_reader :url
1818

1919
# @return [String] uppercase HTTP method of the failed request, e.g. "GET"
20-
attr_reader :method
20+
attr_reader :http_method
2121

2222
# @return [String, nil] server-assigned request id, when the response carried one
2323
attr_reader :request_id
@@ -42,15 +42,15 @@ def self.for_status(status)
4242
# @param message [String] human-readable error detail from the API
4343
# @param status [Integer] HTTP status code
4444
# @param url [String] full URL the request was sent to
45-
# @param method [String] HTTP method of the request
45+
# @param http_method [String] HTTP method of the request
4646
# @param request_id [String, nil] server-assigned request id, if any
47-
def initialize(message, status:, url:, method:, request_id: nil)
47+
def initialize(message, status:, url:, http_method:, request_id: nil)
4848
@status = status
4949
@url = url
50-
@method = method
50+
@http_method = http_method
5151
@request_id = request_id
5252

53-
context = "[Method=#{method}] [URL=#{url}] [StatusCode=#{status}]"
53+
context = "[Method=#{http_method}] [URL=#{url}] [StatusCode=#{status}]"
5454
context += " [RequestId=#{request_id}]" if request_id
5555

5656
super("#{context} #{message}")

lib/infisical/http_client.rb

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require "time"
77

88
require_relative "errors"
9+
require_relative "version"
910

1011
module Infisical
1112
# Thin wrapper around Net::HTTP that knows how to talk to the Infisical
@@ -14,6 +15,8 @@ module Infisical
1415
#
1516
# @api private Internal plumbing; use {Client} instead.
1617
class HTTPClient
18+
USER_AGENT = "infisical-ruby-sdk/v#{VERSION}".freeze
19+
1720
DEFAULT_TIMEOUT = 10 # seconds
1821
DEFAULT_MAX_RETRIES = 4
1922
DEFAULT_INITIAL_DELAY = 1.0 # seconds
@@ -48,8 +51,10 @@ def get(path, params: {})
4851
request(:get, path, params: params)
4952
end
5053

51-
def post(path, body: nil, params: {})
52-
request(:post, path, body: body, params: params)
54+
# auth: false sends the request without the stored access token, for
55+
# endpoints like login where a stale bearer token must not be attached.
56+
def post(path, body: nil, params: {}, auth: true)
57+
request(:post, path, body: body, params: params, auth: auth)
5358
end
5459

5560
def patch(path, body: nil, params: {})
@@ -68,12 +73,12 @@ def self.backoff_delay(attempt, initial_delay: DEFAULT_INITIAL_DELAY, backoff_fa
6873

6974
private
7075

71-
def request(method, path, body: nil, params: {})
76+
def request(method, path, body: nil, params: {}, auth: true)
7277
uri = build_uri(path, params)
7378

7479
attempt = 0
7580
begin
76-
response = perform(method, uri, body)
81+
response = perform(method, uri, body, auth)
7782
handle_response(response, method: method, uri: uri)
7883
rescue *RETRYABLE_EXCEPTIONS => e
7984
raise RequestError, "request to #{uri} failed: #{e.message}" if attempt >= @max_retries
@@ -97,17 +102,17 @@ def build_uri(path, params)
97102
uri
98103
end
99104

100-
def perform(method, uri, body)
105+
def perform(method, uri, body, auth)
101106
http = Net::HTTP.new(uri.host, uri.port)
102107
http.use_ssl = uri.scheme == "https"
103108
http.open_timeout = @timeout
104109
http.read_timeout = @timeout
105110

106-
request = build_request(method, uri, body)
111+
request = build_request(method, uri, body, auth)
107112
http.request(request)
108113
end
109114

110-
def build_request(method, uri, body)
115+
def build_request(method, uri, body, auth)
111116
request_class = {
112117
get: Net::HTTP::Get,
113118
post: Net::HTTP::Post,
@@ -117,7 +122,8 @@ def build_request(method, uri, body)
117122

118123
request = request_class.new(uri)
119124
request["Accept"] = "application/json"
120-
request["Authorization"] = "Bearer #{@access_token}" if @access_token
125+
request["User-Agent"] = USER_AGENT
126+
request["Authorization"] = "Bearer #{@access_token}" if auth && @access_token
121127

122128
unless body.nil?
123129
request["Content-Type"] = "application/json"
@@ -164,7 +170,7 @@ def build_api_error(response, status, method, uri)
164170
message.to_s,
165171
status: status,
166172
url: uri.to_s,
167-
method: method.to_s.upcase,
173+
http_method: method.to_s.upcase,
168174
request_id: data["reqId"]
169175
)
170176
end

lib/infisical/models/secret.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,36 @@
33
module Infisical
44
# Value objects returned by the resource clients.
55
module Models
6+
# A key/value metadata entry attached to a secret.
7+
#
8+
# @!attribute key
9+
# @return [String] metadata key
10+
# @!attribute value
11+
# @return [String] metadata value
12+
SecretMetadata = Struct.new(:key, :value, keyword_init: true) do
13+
# @api private
14+
def self.from_api(data)
15+
new(key: data["key"], value: data["value"])
16+
end
17+
end
18+
19+
# A tag attached to a secret.
20+
#
21+
# @!attribute id
22+
# @return [String] unique id of the tag
23+
# @!attribute slug
24+
# @return [String] URL-safe identifier of the tag
25+
# @!attribute name
26+
# @return [String] display name of the tag
27+
# @!attribute color
28+
# @return [String, nil] display color of the tag
29+
SecretTag = Struct.new(:id, :slug, :name, :color, keyword_init: true) do
30+
# @api private
31+
def self.from_api(data)
32+
new(id: data["id"], slug: data["slug"], name: data["name"], color: data["color"])
33+
end
34+
end
35+
636
# Value object representing a single Infisical secret.
737
#
838
# @!attribute id
@@ -23,13 +53,18 @@ module Models
2353
# @return [String, nil] comment stored with the secret
2454
# @!attribute secret_path
2555
# @return [String, nil] folder path the secret lives at
56+
# @!attribute metadata
57+
# @return [Array<SecretMetadata>] key/value metadata entries
58+
# @!attribute tags
59+
# @return [Array<SecretTag>] tags attached to the secret
2660
# @!attribute created_at
2761
# @return [String, nil] ISO 8601 creation timestamp
2862
# @!attribute updated_at
2963
# @return [String, nil] ISO 8601 last-update timestamp
3064
Secret = Struct.new(
3165
:id, :workspace, :environment, :version, :type,
3266
:secret_key, :secret_value, :secret_comment, :secret_path,
67+
:metadata, :tags,
3368
:created_at, :updated_at,
3469
keyword_init: true
3570
) do
@@ -47,6 +82,8 @@ def self.from_api(data)
4782
secret_value: data["secretValue"],
4883
secret_comment: data["secretComment"],
4984
secret_path: data["secretPath"],
85+
metadata: Array(data["secretMetadata"]).map { |entry| SecretMetadata.from_api(entry) },
86+
tags: Array(data["tags"]).map { |tag| SecretTag.from_api(tag) },
5087
created_at: data["createdAt"],
5188
updated_at: data["updatedAt"]
5289
)

lib/infisical/secrets.rb

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ def initialize(http_client)
2727
# @param skip_unique_validation [Boolean] in recursive mode, duplicate keys
2828
# across folders are collapsed to one secret per key (last occurrence
2929
# wins) unless this is true, in which case all of them are kept
30+
# @param attach_to_process_env [Boolean] export each fetched secret into
31+
# the process environment (ENV), without overriding variables that are
32+
# already set
3033
# @return [Array<Models::Secret>]
3134
# @raise [APIError] if the API rejects the request
3235
def list(project_id:, environment:, secret_path: "/", include_imports: true, recursive: false,
33-
skip_unique_validation: false)
36+
skip_unique_validation: false, attach_to_process_env: false)
3437
response = @http_client.get(
3538
BASE_PATH,
3639
params: {
@@ -45,7 +48,9 @@ def list(project_id:, environment:, secret_path: "/", include_imports: true, rec
4548
secrets = Array(response["secrets"]).map { |secret| Models::Secret.from_api(secret) }
4649
secrets = ensure_unique_secrets_by_key(secrets, skip_unique_validation) if recursive
4750
secrets = merge_imported_secrets(secrets, response["imports"]) if include_imports
48-
secrets.sort_by(&:secret_key)
51+
secrets = secrets.sort_by(&:secret_key)
52+
attach_to_env(secrets) if attach_to_process_env
53+
secrets
4954
end
5055

5156
# Fetches a single secret by name.
@@ -80,17 +85,21 @@ def get(secret_name, project_id:, environment:, secret_path: "/", include_import
8085
# @param environment [String] environment slug, e.g. "dev"
8186
# @param secret_path [String] folder path to create the secret at
8287
# @param secret_comment [String, nil] optional comment stored with the secret
88+
# @param skip_multiline_encoding [Boolean, nil] disable the API's encoding
89+
# of multi-line values; omitted from the request when nil
8390
# @return [Models::Secret] the created secret
8491
# @raise [APIError] if the API rejects the request, e.g. the name is taken
85-
def create(secret_name, secret_value, project_id:, environment:, secret_path: "/", secret_comment: nil)
92+
def create(secret_name, secret_value, project_id:, environment:, secret_path: "/", secret_comment: nil,
93+
skip_multiline_encoding: nil)
8694
response = @http_client.post(
8795
secret_path_for(secret_name),
8896
body: {
8997
projectId: project_id,
9098
environment: environment,
9199
secretPath: secret_path,
92100
secretValue: secret_value,
93-
secretComment: secret_comment
101+
secretComment: secret_comment,
102+
skipMultilineEncoding: skip_multiline_encoding
94103
}.compact
95104
)
96105

@@ -105,10 +114,13 @@ def create(secret_name, secret_value, project_id:, environment:, secret_path: "/
105114
# @param secret_value [String, nil] new value, if changing it
106115
# @param new_secret_name [String, nil] new key, if renaming
107116
# @param secret_path [String] folder path the secret lives at
117+
# @param skip_multiline_encoding [Boolean, nil] disable the API's encoding
118+
# of multi-line values; omitted from the request when nil
108119
# @return [Models::Secret] the updated secret
109120
# @raise [ArgumentError] if neither secret_value nor new_secret_name is given
110121
# @raise [NotFoundError] if no such secret exists
111-
def update(secret_name, project_id:, environment:, secret_value: nil, new_secret_name: nil, secret_path: "/")
122+
def update(secret_name, project_id:, environment:, secret_value: nil, new_secret_name: nil, secret_path: "/",
123+
skip_multiline_encoding: nil)
112124
if secret_value.nil? && new_secret_name.nil?
113125
raise ArgumentError, "update requires at least one of secret_value: or new_secret_name:"
114126
end
@@ -120,7 +132,8 @@ def update(secret_name, project_id:, environment:, secret_value: nil, new_secret
120132
environment: environment,
121133
secretPath: secret_path,
122134
secretValue: secret_value,
123-
newSecretName: new_secret_name
135+
newSecretName: new_secret_name,
136+
skipMultilineEncoding: skip_multiline_encoding
124137
}.compact
125138
)
126139

@@ -181,6 +194,15 @@ def merge_imported_secrets(secrets, import_blocks)
181194
merged
182195
end
183196

197+
# Exports secrets into the process environment. A variable that already
198+
# has a non-empty value is left untouched; an empty value counts as
199+
# unset, matching the Go SDK.
200+
def attach_to_env(secrets)
201+
secrets.each do |secret|
202+
ENV[secret.secret_key] = secret.secret_value if ENV[secret.secret_key].to_s.empty?
203+
end
204+
end
205+
184206
# Escapes a secret name for safe use as a single URI path segment, so
185207
# names containing "/", "?", "#", or "%" can't be misread as path
186208
# separators or query-string tokens.

spec/infisical/auth_spec.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@
2323
expect(credential.token_type).to eq("Bearer")
2424
end
2525

26+
it "does not send a previously stored (possibly expired) bearer token" do
27+
http_client.access_token = "stale-token"
28+
stub = stub_request(:post, "#{base_url}/api/v1/auth/universal-auth/login")
29+
.with { |request| request.headers["Authorization"].nil? }
30+
.to_return(status: 200, body: '{"accessToken":"tok-new"}')
31+
32+
auth.universal_auth_login(client_id: "id-1", client_secret: "secret-1")
33+
34+
expect(stub).to have_been_requested
35+
end
36+
2637
it "authenticates the shared HTTP client so subsequent requests carry the token" do
2738
stub_request(:post, "#{base_url}/api/v1/auth/universal-auth/login")
2839
.to_return(status: 200, body: '{"accessToken":"tok-abc"}')

0 commit comments

Comments
 (0)