infisical-sdk 2.3.8 → 3.0.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +37 -0
  4. data/.ruby-version +1 -0
  5. data/.yardopts +5 -0
  6. data/LICENSE +201 -0
  7. data/README.md +134 -0
  8. data/Rakefile +5 -5
  9. data/infisical-sdk.gemspec +21 -38
  10. data/lib/infisical/auth.rb +50 -0
  11. data/lib/infisical/client.rb +67 -0
  12. data/lib/infisical/errors.rb +82 -0
  13. data/lib/infisical/http_client.rb +212 -0
  14. data/lib/infisical/models/machine_identity_credential.rb +34 -0
  15. data/lib/infisical/models/secret.rb +93 -0
  16. data/lib/infisical/secrets.rb +213 -0
  17. data/lib/infisical/version.rb +6 -0
  18. data/lib/infisical-sdk.rb +3 -43
  19. data/lib/infisical.rb +9 -0
  20. metadata +35 -124
  21. data/Steepfile +0 -46
  22. data/lib/clients/auth.rb +0 -100
  23. data/lib/clients/cryptography.rb +0 -90
  24. data/lib/clients/secrets.rb +0 -171
  25. data/lib/command_runner.rb +0 -16
  26. data/lib/extended_schemas/schemas.rb +0 -38
  27. data/lib/infisical_error.rb +0 -10
  28. data/lib/infisical_lib.rb +0 -44
  29. data/lib/linux-arm64/libinfisical_c.so +0 -0
  30. data/lib/linux-x64/libinfisical_c.so +0 -0
  31. data/lib/macos-arm64/libinfisical_c.dylib +0 -0
  32. data/lib/macos-x64/libinfisical_c.dylib +0 -0
  33. data/lib/schemas.rb +0 -1728
  34. data/lib/version.rb +0 -5
  35. data/lib/windows-x64/infisical_c.dll +0 -0
  36. data/sig/infisical_sdk/auth_client.rbs +0 -20
  37. data/sig/infisical_sdk/command_runner.rbs +0 -10
  38. data/sig/infisical_sdk/cryptography_client.rbs +0 -15
  39. data/sig/infisical_sdk/infisical_client.rbs +0 -30
  40. data/sig/infisical_sdk/infisical_lib.rbs +0 -7
  41. data/sig/infisical_sdk/sdk.rbs +0 -3
  42. data/sig/infisical_sdk/secrets_client.rbs +0 -56
  43. data/sig/infisical_sdk/structs.rbs +0 -37
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infisical
4
+ # Base class for all errors raised by this SDK. Rescue this to catch
5
+ # anything Infisical-related.
6
+ class Error < StandardError; end
7
+
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.
12
+ class APIError < Error
13
+ # @return [Integer] HTTP status code of the failed response
14
+ attr_reader :status
15
+
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 :http_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 http_method [String] HTTP method of the request
46
+ # @param request_id [String, nil] server-assigned request id, if any
47
+ def initialize(message, status:, url:, http_method:, request_id: nil)
48
+ @status = status
49
+ @url = url
50
+ @http_method = http_method
51
+ @request_id = request_id
52
+
53
+ context = "[Method=#{http_method}] [URL=#{url}] [StatusCode=#{status}]"
54
+ context += " [RequestId=#{request_id}]" if request_id
55
+
56
+ super("#{context} #{message}")
57
+ end
58
+ end
59
+
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
+
77
+ # Raised when the request itself fails (network/timeout) before a
78
+ # response is received. Always raised from inside a `rescue` for the
79
+ # underlying network exception, so Ruby's built-in `Exception#cause`
80
+ # already carries it through; no need to track it ourselves.
81
+ class RequestError < Error; end
82
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "time"
7
+
8
+ require_relative "errors"
9
+ require_relative "version"
10
+
11
+ module Infisical
12
+ # Thin wrapper around Net::HTTP that knows how to talk to the Infisical
13
+ # API: bearer token auth, JSON (de)serialization, and retrying transient
14
+ # failures with exponential backoff + jitter.
15
+ #
16
+ # @api private Internal plumbing; use {Client} instead.
17
+ class HTTPClient
18
+ USER_AGENT = "infisical-ruby-sdk/v#{VERSION}".freeze
19
+
20
+ DEFAULT_TIMEOUT = 10 # seconds
21
+ DEFAULT_MAX_RETRIES = 4
22
+ DEFAULT_INITIAL_DELAY = 1.0 # seconds
23
+ DEFAULT_BACKOFF_FACTOR = 2
24
+ JITTER_RATIO = 0.2 # +/- 20%
25
+
26
+ RETRYABLE_EXCEPTIONS = [
27
+ Errno::ECONNRESET,
28
+ Errno::ECONNREFUSED,
29
+ Errno::ETIMEDOUT,
30
+ Net::OpenTimeout,
31
+ Net::ReadTimeout,
32
+ SocketError,
33
+ IOError
34
+ ].freeze
35
+
36
+ def initialize(base_url:, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
37
+ initial_delay: DEFAULT_INITIAL_DELAY, backoff_factor: DEFAULT_BACKOFF_FACTOR,
38
+ sleeper: ->(seconds) { sleep(seconds) })
39
+ @base_url = base_url.end_with?("/") ? base_url : "#{base_url}/"
40
+ @timeout = timeout
41
+ @max_retries = max_retries
42
+ @initial_delay = initial_delay
43
+ @backoff_factor = backoff_factor
44
+ @sleeper = sleeper
45
+ @access_token = nil
46
+ end
47
+
48
+ attr_writer :access_token
49
+
50
+ def get(path, params: {})
51
+ request(:get, path, params: params)
52
+ end
53
+
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)
58
+ end
59
+
60
+ def patch(path, body: nil, params: {})
61
+ request(:patch, path, body: body, params: params)
62
+ end
63
+
64
+ def delete(path, body: nil, params: {})
65
+ request(:delete, path, body: body, params: params)
66
+ end
67
+
68
+ # Computes the (pre-jitter) backoff delay for a given retry attempt
69
+ # (0-indexed). Exposed for testing.
70
+ def self.backoff_delay(attempt, initial_delay: DEFAULT_INITIAL_DELAY, backoff_factor: DEFAULT_BACKOFF_FACTOR)
71
+ initial_delay * (backoff_factor**attempt)
72
+ end
73
+
74
+ private
75
+
76
+ def request(method, path, body: nil, params: {}, auth: true)
77
+ uri = build_uri(path, params)
78
+
79
+ attempt = 0
80
+ begin
81
+ response = perform(method, uri, body, auth)
82
+ handle_response(response, method: method, uri: uri)
83
+ rescue *RETRYABLE_EXCEPTIONS => e
84
+ raise RequestError, "request to #{uri} failed: #{e.message}" if attempt >= @max_retries
85
+
86
+ attempt += 1
87
+ wait_before_retry(attempt)
88
+ retry
89
+ rescue RetryableAPIError => e
90
+ raise e.api_error if attempt >= @max_retries
91
+
92
+ attempt += 1
93
+ wait_before_retry(attempt, override: e.retry_after)
94
+ retry
95
+ end
96
+ end
97
+
98
+ def build_uri(path, params)
99
+ uri = URI.join(@base_url, path)
100
+ filtered = params.compact
101
+ uri.query = URI.encode_www_form(filtered) unless filtered.empty?
102
+ uri
103
+ end
104
+
105
+ def perform(method, uri, body, auth)
106
+ http = Net::HTTP.new(uri.host, uri.port)
107
+ http.use_ssl = uri.scheme == "https"
108
+ http.open_timeout = @timeout
109
+ http.read_timeout = @timeout
110
+
111
+ request = build_request(method, uri, body, auth)
112
+ http.request(request)
113
+ end
114
+
115
+ def build_request(method, uri, body, auth)
116
+ request_class = {
117
+ get: Net::HTTP::Get,
118
+ post: Net::HTTP::Post,
119
+ patch: Net::HTTP::Patch,
120
+ delete: Net::HTTP::Delete
121
+ }.fetch(method)
122
+
123
+ request = request_class.new(uri)
124
+ request["Accept"] = "application/json"
125
+ request["User-Agent"] = USER_AGENT
126
+ request["Authorization"] = "Bearer #{@access_token}" if auth && @access_token
127
+
128
+ unless body.nil?
129
+ request["Content-Type"] = "application/json"
130
+ request.body = JSON.generate(body)
131
+ end
132
+
133
+ request
134
+ end
135
+
136
+ def handle_response(response, method:, uri:)
137
+ status = response.code.to_i
138
+ if status == 429
139
+ raise RetryableAPIError.new(build_api_error(response, status, method, uri),
140
+ retry_after: parse_retry_after(response["Retry-After"]))
141
+ end
142
+ raise build_api_error(response, status, method, uri) unless (200..299).cover?(status)
143
+
144
+ parse_body(response.body)
145
+ end
146
+
147
+ # Retry-After is usually an integer/float number of seconds, but per
148
+ # RFC 9110 it may also be an HTTP-date.
149
+ def parse_retry_after(value)
150
+ return nil if value.nil? || value.empty?
151
+
152
+ begin
153
+ Float(value)
154
+ rescue ArgumentError, TypeError
155
+ begin
156
+ seconds = Time.httpdate(value) - Time.now
157
+ seconds.positive? ? seconds : nil
158
+ rescue ArgumentError
159
+ nil
160
+ end
161
+ end
162
+ end
163
+
164
+ def build_api_error(response, status, method, uri)
165
+ data = parse_body(response.body)
166
+ data = {} unless data.is_a?(Hash)
167
+ message = data["message"] || data["error"] || response.body
168
+
169
+ APIError.for_status(status).new(
170
+ message.to_s,
171
+ status: status,
172
+ url: uri.to_s,
173
+ http_method: method.to_s.upcase,
174
+ request_id: data["reqId"]
175
+ )
176
+ end
177
+
178
+ def parse_body(raw)
179
+ return nil if raw.nil? || raw.empty?
180
+
181
+ JSON.parse(raw)
182
+ rescue JSON::ParserError
183
+ raw
184
+ end
185
+
186
+ def wait_before_retry(attempt, override: nil)
187
+ if override
188
+ @sleeper.call(override)
189
+ return
190
+ end
191
+
192
+ base = self.class.backoff_delay(attempt - 1, initial_delay: @initial_delay, backoff_factor: @backoff_factor)
193
+ jitter = base * JITTER_RATIO * ((rand * 2) - 1)
194
+ @sleeper.call(base + jitter)
195
+ end
196
+
197
+ # Internal-only signal so 429s share the same retry path as network
198
+ # errors without retrying every other 4xx/5xx status. Carries the
199
+ # server's Retry-After hint (if any) so the wait honors it instead of
200
+ # our own computed backoff.
201
+ class RetryableAPIError < StandardError
202
+ attr_reader :api_error, :retry_after
203
+
204
+ def initialize(api_error, retry_after: nil)
205
+ @api_error = api_error
206
+ @retry_after = retry_after
207
+ super(api_error.message)
208
+ end
209
+ end
210
+ private_constant :RetryableAPIError
211
+ end
212
+ end
@@ -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
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infisical
4
+ # Value objects returned by the resource clients.
5
+ 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
+
36
+ # Value object representing a single Infisical secret.
37
+ #
38
+ # @!attribute id
39
+ # @return [String] unique id of the secret
40
+ # @!attribute workspace
41
+ # @return [String] id of the project the secret belongs to
42
+ # @!attribute environment
43
+ # @return [String] environment slug the secret belongs to
44
+ # @!attribute version
45
+ # @return [Integer] version number, incremented on every update
46
+ # @!attribute type
47
+ # @return [String] "shared" or "personal"
48
+ # @!attribute secret_key
49
+ # @return [String] the secret's name
50
+ # @!attribute secret_value
51
+ # @return [String] the secret's value
52
+ # @!attribute secret_comment
53
+ # @return [String, nil] comment stored with the secret
54
+ # @!attribute secret_path
55
+ # @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
60
+ # @!attribute created_at
61
+ # @return [String, nil] ISO 8601 creation timestamp
62
+ # @!attribute updated_at
63
+ # @return [String, nil] ISO 8601 last-update timestamp
64
+ Secret = Struct.new(
65
+ :id, :workspace, :environment, :version, :type,
66
+ :secret_key, :secret_value, :secret_comment, :secret_path,
67
+ :metadata, :tags,
68
+ :created_at, :updated_at,
69
+ keyword_init: true
70
+ ) do
71
+ # Builds a Secret from an API response hash (camelCase keys).
72
+ #
73
+ # @api private
74
+ def self.from_api(data)
75
+ new(
76
+ id: data["id"],
77
+ workspace: data["workspace"],
78
+ environment: data["environment"],
79
+ version: data["version"],
80
+ type: data["type"],
81
+ secret_key: data["secretKey"],
82
+ secret_value: data["secretValue"],
83
+ secret_comment: data["secretComment"],
84
+ 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) },
87
+ created_at: data["createdAt"],
88
+ updated_at: data["updatedAt"]
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "models/secret"
6
+
7
+ module Infisical
8
+ # CRUD operations against Infisical's v4 secrets API.
9
+ class Secrets
10
+ # @api private
11
+ BASE_PATH = "api/v4/secrets"
12
+
13
+ # @api private Obtain instances via {Client#secrets} instead.
14
+ def initialize(http_client)
15
+ @http_client = http_client
16
+ end
17
+
18
+ # Lists the secrets in a project environment, sorted by key.
19
+ #
20
+ # @param project_id [String] id of the project to read from
21
+ # @param environment [String] environment slug, e.g. "dev"
22
+ # @param secret_path [String] folder path to list from
23
+ # @param include_imports [Boolean] fold in secrets from imported folders;
24
+ # direct secrets win over imports on key conflicts, and earlier import
25
+ # blocks win over later ones
26
+ # @param recursive [Boolean] also list secrets from sub-folders
27
+ # @param skip_unique_validation [Boolean] in recursive mode, duplicate keys
28
+ # across folders are collapsed to one secret per key (last occurrence
29
+ # 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
33
+ # @return [Array<Models::Secret>]
34
+ # @raise [APIError] if the API rejects the request
35
+ def list(project_id:, environment:, secret_path: "/", include_imports: true, recursive: false,
36
+ skip_unique_validation: false, attach_to_process_env: false)
37
+ response = @http_client.get(
38
+ BASE_PATH,
39
+ params: {
40
+ projectId: project_id,
41
+ environment: environment,
42
+ secretPath: secret_path,
43
+ includeImports: include_imports,
44
+ recursive: recursive
45
+ }
46
+ )
47
+
48
+ secrets = Array(response["secrets"]).map { |secret| Models::Secret.from_api(secret) }
49
+ secrets = ensure_unique_secrets_by_key(secrets, skip_unique_validation) if recursive
50
+ secrets = merge_imported_secrets(secrets, response["imports"]) if include_imports
51
+ secrets = secrets.sort_by(&:secret_key)
52
+ attach_to_env(secrets) if attach_to_process_env
53
+ secrets
54
+ end
55
+
56
+ # Fetches a single secret by name.
57
+ #
58
+ # @param secret_name [String] key of the secret to fetch
59
+ # @param project_id [String] id of the project to read from
60
+ # @param environment [String] environment slug, e.g. "dev"
61
+ # @param secret_path [String] folder path the secret lives at
62
+ # @param include_imports [Boolean] also look through imported folders when
63
+ # the secret is not found at the path itself
64
+ # @return [Models::Secret]
65
+ # @raise [NotFoundError] if no such secret exists
66
+ def get(secret_name, project_id:, environment:, secret_path: "/", include_imports: true)
67
+ response = @http_client.get(
68
+ secret_path_for(secret_name),
69
+ params: {
70
+ projectId: project_id,
71
+ environment: environment,
72
+ secretPath: secret_path,
73
+ includeImports: include_imports
74
+ }
75
+ )
76
+
77
+ Models::Secret.from_api(response["secret"])
78
+ end
79
+
80
+ # Creates a new secret.
81
+ #
82
+ # @param secret_name [String] key of the secret to create
83
+ # @param secret_value [String] value of the secret
84
+ # @param project_id [String] id of the project to write to
85
+ # @param environment [String] environment slug, e.g. "dev"
86
+ # @param secret_path [String] folder path to create the secret at
87
+ # @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
90
+ # @return [Models::Secret] the created secret
91
+ # @raise [APIError] if the API rejects the request, e.g. the name is taken
92
+ def create(secret_name, secret_value, project_id:, environment:, secret_path: "/", secret_comment: nil,
93
+ skip_multiline_encoding: nil)
94
+ response = @http_client.post(
95
+ secret_path_for(secret_name),
96
+ body: {
97
+ projectId: project_id,
98
+ environment: environment,
99
+ secretPath: secret_path,
100
+ secretValue: secret_value,
101
+ secretComment: secret_comment,
102
+ skipMultilineEncoding: skip_multiline_encoding
103
+ }.compact
104
+ )
105
+
106
+ Models::Secret.from_api(response["secret"])
107
+ end
108
+
109
+ # Updates a secret's value, name, or both.
110
+ #
111
+ # @param secret_name [String] key of the secret to update
112
+ # @param project_id [String] id of the project to write to
113
+ # @param environment [String] environment slug, e.g. "dev"
114
+ # @param secret_value [String, nil] new value, if changing it
115
+ # @param new_secret_name [String, nil] new key, if renaming
116
+ # @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
119
+ # @return [Models::Secret] the updated secret
120
+ # @raise [ArgumentError] if neither secret_value nor new_secret_name is given
121
+ # @raise [NotFoundError] if no such secret exists
122
+ def update(secret_name, project_id:, environment:, secret_value: nil, new_secret_name: nil, secret_path: "/",
123
+ skip_multiline_encoding: nil)
124
+ if secret_value.nil? && new_secret_name.nil?
125
+ raise ArgumentError, "update requires at least one of secret_value: or new_secret_name:"
126
+ end
127
+
128
+ response = @http_client.patch(
129
+ secret_path_for(secret_name),
130
+ body: {
131
+ projectId: project_id,
132
+ environment: environment,
133
+ secretPath: secret_path,
134
+ secretValue: secret_value,
135
+ newSecretName: new_secret_name,
136
+ skipMultilineEncoding: skip_multiline_encoding
137
+ }.compact
138
+ )
139
+
140
+ Models::Secret.from_api(response["secret"])
141
+ end
142
+
143
+ # Deletes a secret.
144
+ #
145
+ # @param secret_name [String] key of the secret to delete
146
+ # @param project_id [String] id of the project to write to
147
+ # @param environment [String] environment slug, e.g. "dev"
148
+ # @param secret_path [String] folder path the secret lives at
149
+ # @return [Models::Secret] the deleted secret
150
+ # @raise [NotFoundError] if no such secret exists
151
+ def delete(secret_name, project_id:, environment:, secret_path: "/")
152
+ response = @http_client.delete(
153
+ secret_path_for(secret_name),
154
+ body: {
155
+ projectId: project_id,
156
+ environment: environment,
157
+ secretPath: secret_path
158
+ }
159
+ )
160
+
161
+ Models::Secret.from_api(response["secret"])
162
+ end
163
+
164
+ private
165
+
166
+ # In recursive mode the same key can exist at several paths; collapse to
167
+ # one secret per key (the last occurrence wins). With
168
+ # skip_unique_validation, secrets are instead kept unique per
169
+ # path+key, so same-named secrets at different paths all survive.
170
+ def ensure_unique_secrets_by_key(secrets, skip_unique_validation)
171
+ secrets.each_with_object({}) do |secret, by_key|
172
+ key = skip_unique_validation ? "#{secret.secret_path}:#{secret.secret_key}" : secret.secret_key
173
+ by_key[key] = secret
174
+ end.values
175
+ end
176
+
177
+ # Folds secrets from import blocks into the main list. Secrets already
178
+ # present win over imports, and earlier import blocks win over later
179
+ # ones.
180
+ def merge_imported_secrets(secrets, import_blocks)
181
+ merged = secrets.dup
182
+ seen = merged.to_h { |secret| [secret.secret_key, true] }
183
+
184
+ Array(import_blocks).each do |block|
185
+ Array(block["secrets"]).each do |data|
186
+ secret = Models::Secret.from_api(data)
187
+ next if seen[secret.secret_key]
188
+
189
+ seen[secret.secret_key] = true
190
+ merged << secret
191
+ end
192
+ end
193
+
194
+ merged
195
+ end
196
+
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
+
206
+ # Escapes a secret name for safe use as a single URI path segment, so
207
+ # names containing "/", "?", "#", or "%" can't be misread as path
208
+ # separators or query-string tokens.
209
+ def secret_path_for(secret_name)
210
+ "#{BASE_PATH}/#{URI::DEFAULT_PARSER.escape(secret_name.to_s, /[^A-Za-z0-9\-._~]/)}"
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Infisical
4
+ # Version of the infisical-sdk gem.
5
+ VERSION = "3.0.0"
6
+ end
data/lib/infisical-sdk.rb CHANGED
@@ -1,45 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
- require 'dry-types'
5
-
6
- require_relative 'schemas'
7
- require_relative 'extended_schemas/schemas'
8
- require_relative 'infisical_lib'
9
- require_relative 'infisical_error'
10
- require_relative 'command_runner'
11
- require_relative 'clients/secrets'
12
- require_relative 'clients/auth'
13
- require_relative 'clients/cryptography'
14
-
15
- module InfisicalSDK
16
- class InfisicalClient
17
- attr_reader :infisical, :command_runner, :secrets, :auth, :cryptography
18
-
19
- def initialize(site_url = nil, cache_ttl = 300)
20
- settings = ClientSettings.new(
21
- # We preset these values or we'll get type validation errors (thanks Quicktype!)
22
- access_token: nil,
23
- client_secret: nil,
24
- client_id: nil,
25
- auth: nil,
26
- ssl_certificate_path: nil,
27
- user_agent: 'infisical-ruby-sdk',
28
- cache_ttl: cache_ttl,
29
- site_url: site_url
30
- )
31
-
32
-
33
- @infisical = InfisicalLib
34
- @handle = @infisical.init(settings.to_dynamic.compact.to_json)
35
- @command_runner = CommandRunner.new(@infisical, @handle)
36
- @secrets = SecretsClient.new(@command_runner)
37
- @auth = AuthClient.new(@command_runner)
38
- @cryptography = CryptographyClient.new(@command_runner)
39
- end
40
-
41
- def free_mem
42
- @infisical.free_mem(@handle)
43
- end
44
- end
45
- end
3
+ # Entry point matching the gem name, so `Bundler.require` (e.g. in Rails)
4
+ # can `require "infisical-sdk"` without an explicit `require:` override.
5
+ require_relative "infisical"