Skip to content

Commit 1a8a562

Browse files
committed
fix: further improvements
1 parent 9c124d9 commit 1a8a562

15 files changed

Lines changed: 319 additions & 24 deletions

.yardopts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
--markup markdown
2+
lib/**/*.rb
3+
-
4+
README.md
5+
LICENSE

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ group :development, :test do
88
gem "rspec", "~> 3.13"
99
gem "rubocop", "~> 1.65"
1010
gem "webmock", "~> 3.23"
11+
gem "yard", "~> 0.9"
1112
end

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,13 @@ secrets.each { |secret| puts "#{secret.secret_key}=#{secret.secret_value}" }
4141
### Authentication
4242

4343
```ruby
44-
# Universal Auth (machine identity client id/secret)
45-
client.auth.universal_auth_login(client_id: "...", client_secret: "...")
44+
# Universal Auth (machine identity client id/secret). Returns the full
45+
# credential, so you can build your own token refresh on top of it.
46+
credential = client.auth.universal_auth_login(client_id: "...", client_secret: "...")
47+
credential.access_token # the token this client now uses
48+
credential.expires_in # seconds until it expires
49+
credential.access_token_max_ttl
50+
credential.token_type # "Bearer"
4651

4752
# Or use a token you already have
4853
client.auth.access_token("existing-access-token")
@@ -64,6 +69,29 @@ client.secrets.update("DATABASE_URL", project_id: "...", environment: "dev", sec
6469
client.secrets.delete("DATABASE_URL", project_id: "...", environment: "dev")
6570
```
6671

72+
### Error handling
73+
74+
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:
77+
78+
```ruby
79+
begin
80+
client.secrets.get("DATABASE_URL", project_id: "...", environment: "dev")
81+
rescue Infisical::NotFoundError # 404
82+
# secret does not exist
83+
rescue Infisical::AuthenticationError # 401: bad or expired credentials
84+
# re-authenticate
85+
rescue Infisical::APIError => e # anything else the API rejected
86+
# e.status, e.url, e.method, e.request_id
87+
end
88+
```
89+
90+
Also available: `Infisical::PermissionError` (403), `Infisical::RateLimitError`
91+
(429, raised only after automatic retries are exhausted), and
92+
`Infisical::ServerError` (5xx). Network-level failures (timeouts, DNS,
93+
connection resets) raise `Infisical::RequestError` after retries.
94+
6795
### Self-hosted instances
6896

6997
```ruby

lib/infisical.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
require_relative "infisical/errors"
55
require_relative "infisical/client"
66

7+
# Official Infisical SDK for Ruby. See {Client} to get started.
78
module Infisical
89
end

lib/infisical/auth.rb

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,44 @@
11
# frozen_string_literal: true
22

3+
require_relative "models/machine_identity_credential"
4+
35
module Infisical
46
# Authenticates the underlying HTTP client. Every other resource client
57
# shares that same HTTP client, so a successful login here authenticates
6-
# the whole `Client` instance.
8+
# the whole {Client} instance.
79
class Auth
810
UNIVERSAL_AUTH_LOGIN_PATH = "api/v1/auth/universal-auth/login"
911

12+
# @api private Obtain instances via {Client#auth} instead.
1013
def initialize(http_client)
1114
@http_client = http_client
1215
end
1316

1417
# Logs in with Universal Auth (machine identity client id/secret) and
15-
# returns the resulting access token.
18+
# authenticates this client with the resulting access token. The full
19+
# credential is returned so callers can track `expires_in` and re-login
20+
# (or renew) before the token lapses.
21+
#
22+
# @param client_id [String] machine identity client id
23+
# @param client_secret [String] machine identity client secret
24+
# @return [Models::MachineIdentityCredential] the credential now used by
25+
# this client
26+
# @raise [AuthenticationError] if the credentials are rejected
1627
def universal_auth_login(client_id:, client_secret:)
1728
response = @http_client.post(
1829
UNIVERSAL_AUTH_LOGIN_PATH,
1930
body: { clientId: client_id, clientSecret: client_secret }
2031
)
2132

2233
access_token(response["accessToken"])
34+
Models::MachineIdentityCredential.from_api(response)
2335
end
2436

2537
# Authenticates with an access token obtained out-of-band, skipping
2638
# the login request entirely.
39+
#
40+
# @param token [String] a valid Infisical access token
41+
# @return [String] the token, now used by this client
2742
def access_token(token)
2843
@http_client.access_token = token
2944
token

lib/infisical/client.rb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,38 @@
77
require_relative "secrets"
88

99
module Infisical
10-
# Entry point for the SDK. Construct one, authenticate via `auth`, then
11-
# use `secrets` (and future resource clients) to talk to Infisical.
10+
# Entry point for the SDK. Construct one, authenticate via {#auth}, then
11+
# use {#secrets} (and future resource clients) to talk to Infisical.
12+
#
13+
# @example Fetch a secret
14+
# client = Infisical::Client.new
15+
# client.auth.universal_auth_login(client_id: "...", client_secret: "...")
16+
# secret = client.secrets.get("DATABASE_URL", project_id: "...", environment: "dev")
17+
# secret.secret_value # => "postgres://..."
1218
class Client
1319
DEFAULT_SITE_URL = "https://app.infisical.com"
1420
ALLOWED_SCHEMES = %w[http https].freeze
1521

22+
# @param site_url [String] base URL of the Infisical instance; defaults to
23+
# Infisical Cloud, so only self-hosted deployments need to set it
24+
# @param timeout [Numeric] open/read timeout in seconds for each request
25+
# @raise [ArgumentError] if site_url is not an http(s) URL
1626
def initialize(site_url: DEFAULT_SITE_URL, timeout: HTTPClient::DEFAULT_TIMEOUT)
1727
validate_site_url!(site_url)
1828
@http_client = HTTPClient.new(base_url: site_url, timeout: timeout)
1929
end
2030

31+
# Authentication operations. Logging in through this authenticates the
32+
# whole client, since all resource clients share one HTTP client.
33+
#
34+
# @return [Auth]
2135
def auth
2236
@auth ||= Auth.new(@http_client)
2337
end
2438

39+
# Secret CRUD operations.
40+
#
41+
# @return [Secrets]
2542
def secrets
2643
@secrets ||= Secrets.new(@http_client)
2744
end

lib/infisical/errors.rb

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,49 @@
11
# frozen_string_literal: true
22

33
module Infisical
4-
# Base class for all errors raised by this SDK.
4+
# Base class for all errors raised by this SDK. Rescue this to catch
5+
# anything Infisical-related.
56
class Error < StandardError; end
67

7-
# Raised when the Infisical API responds with a non-2xx status.
8+
# Raised when the Infisical API responds with a non-2xx status. Well-known
9+
# statuses raise a subclass ({AuthenticationError}, {PermissionError},
10+
# {NotFoundError}, {RateLimitError}, {ServerError}); rescuing this class
11+
# catches them all.
812
class APIError < Error
9-
attr_reader :status, :url, :method, :request_id
13+
# @return [Integer] HTTP status code of the failed response
14+
attr_reader :status
1015

16+
# @return [String] full URL the failed request was sent to
17+
attr_reader :url
18+
19+
# @return [String] uppercase HTTP method of the failed request, e.g. "GET"
20+
attr_reader :method
21+
22+
# @return [String, nil] server-assigned request id, when the response carried one
23+
attr_reader :request_id
24+
25+
# Returns the error class that best matches an HTTP status, falling back
26+
# to {APIError} itself. Constants are resolved at call time, so the
27+
# subclasses defined below are visible here.
28+
#
29+
# @param status [Integer] HTTP status code
30+
# @return [Class<APIError>]
31+
def self.for_status(status)
32+
case status
33+
when 401 then AuthenticationError
34+
when 403 then PermissionError
35+
when 404 then NotFoundError
36+
when 429 then RateLimitError
37+
when 500..599 then ServerError
38+
else self
39+
end
40+
end
41+
42+
# @param message [String] human-readable error detail from the API
43+
# @param status [Integer] HTTP status code
44+
# @param url [String] full URL the request was sent to
45+
# @param method [String] HTTP method of the request
46+
# @param request_id [String, nil] server-assigned request id, if any
1147
def initialize(message, status:, url:, method:, request_id: nil)
1248
@status = status
1349
@url = url
@@ -21,9 +57,26 @@ def initialize(message, status:, url:, method:, request_id: nil)
2157
end
2258
end
2359

60+
# Raised on HTTP 401: missing, expired, or invalid credentials.
61+
class AuthenticationError < APIError; end
62+
63+
# Raised on HTTP 403: the authenticated identity lacks permission for the
64+
# requested resource or action.
65+
class PermissionError < APIError; end
66+
67+
# Raised on HTTP 404: the secret, project, environment, or path does not
68+
# exist (or is not visible to the authenticated identity).
69+
class NotFoundError < APIError; end
70+
71+
# Raised on HTTP 429, after the client has exhausted its automatic retries.
72+
class RateLimitError < APIError; end
73+
74+
# Raised on HTTP 5xx: the Infisical server failed to process the request.
75+
class ServerError < APIError; end
76+
2477
# Raised when the request itself fails (network/timeout) before a
2578
# response is received. Always raised from inside a `rescue` for the
2679
# underlying network exception, so Ruby's built-in `Exception#cause`
27-
# already carries it through no need to track it ourselves.
80+
# already carries it through; no need to track it ourselves.
2881
class RequestError < Error; end
2982
end

lib/infisical/http_client.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ module Infisical
1111
# Thin wrapper around Net::HTTP that knows how to talk to the Infisical
1212
# API: bearer token auth, JSON (de)serialization, and retrying transient
1313
# failures with exponential backoff + jitter.
14+
#
15+
# @api private Internal plumbing; use {Client} instead.
1416
class HTTPClient
1517
DEFAULT_TIMEOUT = 10 # seconds
1618
DEFAULT_MAX_RETRIES = 4
@@ -155,14 +157,15 @@ def parse_retry_after(value)
155157

156158
def build_api_error(response, status, method, uri)
157159
data = parse_body(response.body)
158-
message = data.is_a?(Hash) ? (data["message"] || data["error"] || response.body) : response.body
160+
data = {} unless data.is_a?(Hash)
161+
message = data["message"] || data["error"] || response.body
159162

160-
APIError.new(
163+
APIError.for_status(status).new(
161164
message.to_s,
162165
status: status,
163166
url: uri.to_s,
164167
method: method.to_s.upcase,
165-
request_id: response["x-request-id"]
168+
request_id: data["reqId"]
166169
)
167170
end
168171

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
module Infisical
4+
module Models
5+
# Credential material issued by a machine identity login. Exposes the
6+
# token's lifetime so callers can build their own refresh logic.
7+
#
8+
# @!attribute access_token
9+
# @return [String] bearer token used to authenticate API requests
10+
# @!attribute expires_in
11+
# @return [Integer] seconds until the token expires, from issue time
12+
# @!attribute access_token_max_ttl
13+
# @return [Integer] hard ceiling in seconds on the token's total
14+
# lifetime across renewals
15+
# @!attribute token_type
16+
# @return [String] token scheme, e.g. "Bearer"
17+
MachineIdentityCredential = Struct.new(
18+
:access_token, :expires_in, :access_token_max_ttl, :token_type,
19+
keyword_init: true
20+
) do
21+
# Builds a credential from an API response hash (camelCase keys).
22+
#
23+
# @api private
24+
def self.from_api(data)
25+
new(
26+
access_token: data["accessToken"],
27+
expires_in: data["expiresIn"],
28+
access_token_max_ttl: data["accessTokenMaxTTL"],
29+
token_type: data["tokenType"]
30+
)
31+
end
32+
end
33+
end
34+
end

lib/infisical/models/secret.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,41 @@
11
# frozen_string_literal: true
22

33
module Infisical
4+
# Value objects returned by the resource clients.
45
module Models
5-
# Immutable value object representing a single Infisical secret.
6+
# Value object representing a single Infisical secret.
7+
#
8+
# @!attribute id
9+
# @return [String] unique id of the secret
10+
# @!attribute workspace
11+
# @return [String] id of the project the secret belongs to
12+
# @!attribute environment
13+
# @return [String] environment slug the secret belongs to
14+
# @!attribute version
15+
# @return [Integer] version number, incremented on every update
16+
# @!attribute type
17+
# @return [String] "shared" or "personal"
18+
# @!attribute secret_key
19+
# @return [String] the secret's name
20+
# @!attribute secret_value
21+
# @return [String] the secret's value
22+
# @!attribute secret_comment
23+
# @return [String, nil] comment stored with the secret
24+
# @!attribute secret_path
25+
# @return [String, nil] folder path the secret lives at
26+
# @!attribute created_at
27+
# @return [String, nil] ISO 8601 creation timestamp
28+
# @!attribute updated_at
29+
# @return [String, nil] ISO 8601 last-update timestamp
630
Secret = Struct.new(
731
:id, :workspace, :environment, :version, :type,
832
:secret_key, :secret_value, :secret_comment, :secret_path,
933
:created_at, :updated_at,
1034
keyword_init: true
1135
) do
36+
# Builds a Secret from an API response hash (camelCase keys).
37+
#
38+
# @api private
1239
def self.from_api(data)
1340
new(
1441
id: data["id"],

0 commit comments

Comments
 (0)