Skip to content

Commit 3bd7786

Browse files
committed
fix: v4 api and improvements to list and get methods
1 parent d95e824 commit 3bd7786

3 files changed

Lines changed: 142 additions & 34 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ client.auth.access_token("existing-access-token")
5252

5353
```ruby
5454
client.secrets.list(project_id: "...", environment: "dev", secret_path: "/")
55+
56+
# Imported secrets are folded in by default; pass include_imports: false to
57+
# opt out. Recursive mode collapses duplicate keys across folders to one
58+
# secret per key unless skip_unique_validation: true is passed.
59+
client.secrets.list(project_id: "...", environment: "dev", recursive: true)
60+
5561
client.secrets.get("DATABASE_URL", project_id: "...", environment: "dev")
5662
client.secrets.create("DATABASE_URL", "postgres://...", project_id: "...", environment: "dev")
5763
client.secrets.update("DATABASE_URL", project_id: "...", environment: "dev", secret_value: "postgres://...")

lib/infisical/secrets.rb

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,40 @@
55
require_relative "models/secret"
66

77
module Infisical
8-
# CRUD operations against Infisical's raw secrets API.
8+
# CRUD operations against Infisical's v4 secrets API.
99
class Secrets
10-
BASE_PATH = "api/v3/secrets/raw"
10+
BASE_PATH = "api/v4/secrets"
1111

1212
def initialize(http_client)
1313
@http_client = http_client
1414
end
1515

16-
def list(project_id:, environment:, secret_path: "/", expand_secret_references: true,
17-
include_imports: false, recursive: false)
16+
def list(project_id:, environment:, secret_path: "/", include_imports: true, recursive: false,
17+
skip_unique_validation: false)
1818
response = @http_client.get(
1919
BASE_PATH,
2020
params: {
21-
workspaceId: project_id,
21+
projectId: project_id,
2222
environment: environment,
2323
secretPath: secret_path,
24-
expandSecretReferences: expand_secret_references,
2524
includeImports: include_imports,
2625
recursive: recursive
2726
}
2827
)
2928

30-
Array(response["secrets"]).map { |secret| Models::Secret.from_api(secret) }
29+
secrets = Array(response["secrets"]).map { |secret| Models::Secret.from_api(secret) }
30+
secrets = ensure_unique_secrets_by_key(secrets, skip_unique_validation) if recursive
31+
secrets = merge_imported_secrets(secrets, response["imports"]) if include_imports
32+
secrets.sort_by(&:secret_key)
3133
end
3234

33-
def get(secret_name, project_id:, environment:, secret_path: "/",
34-
expand_secret_references: true, include_imports: false)
35+
def get(secret_name, project_id:, environment:, secret_path: "/", include_imports: true)
3536
response = @http_client.get(
3637
secret_path_for(secret_name),
3738
params: {
38-
workspaceId: project_id,
39+
projectId: project_id,
3940
environment: environment,
4041
secretPath: secret_path,
41-
expandSecretReferences: expand_secret_references,
4242
includeImports: include_imports
4343
}
4444
)
@@ -50,7 +50,7 @@ def create(secret_name, secret_value, project_id:, environment:, secret_path: "/
5050
response = @http_client.post(
5151
secret_path_for(secret_name),
5252
body: {
53-
workspaceId: project_id,
53+
projectId: project_id,
5454
environment: environment,
5555
secretPath: secret_path,
5656
secretValue: secret_value,
@@ -69,7 +69,7 @@ def update(secret_name, project_id:, environment:, secret_value: nil, new_secret
6969
response = @http_client.patch(
7070
secret_path_for(secret_name),
7171
body: {
72-
workspaceId: project_id,
72+
projectId: project_id,
7373
environment: environment,
7474
secretPath: secret_path,
7575
secretValue: secret_value,
@@ -84,7 +84,7 @@ def delete(secret_name, project_id:, environment:, secret_path: "/")
8484
response = @http_client.delete(
8585
secret_path_for(secret_name),
8686
body: {
87-
workspaceId: project_id,
87+
projectId: project_id,
8888
environment: environment,
8989
secretPath: secret_path
9090
}
@@ -95,6 +95,37 @@ def delete(secret_name, project_id:, environment:, secret_path: "/")
9595

9696
private
9797

98+
# In recursive mode the same key can exist at several paths; collapse to
99+
# one secret per key (the last occurrence wins). With
100+
# skip_unique_validation, secrets are instead kept unique per
101+
# path+key, so same-named secrets at different paths all survive.
102+
def ensure_unique_secrets_by_key(secrets, skip_unique_validation)
103+
secrets.each_with_object({}) do |secret, by_key|
104+
key = skip_unique_validation ? "#{secret.secret_path}:#{secret.secret_key}" : secret.secret_key
105+
by_key[key] = secret
106+
end.values
107+
end
108+
109+
# Folds secrets from import blocks into the main list. Secrets already
110+
# present win over imports, and earlier import blocks win over later
111+
# ones.
112+
def merge_imported_secrets(secrets, import_blocks)
113+
merged = secrets.dup
114+
seen = merged.to_h { |secret| [secret.secret_key, true] }
115+
116+
Array(import_blocks).each do |block|
117+
Array(block["secrets"]).each do |data|
118+
secret = Models::Secret.from_api(data)
119+
next if seen[secret.secret_key]
120+
121+
seen[secret.secret_key] = true
122+
merged << secret
123+
end
124+
end
125+
126+
merged
127+
end
128+
98129
# Escapes a secret name for safe use as a single URI path segment, so
99130
# names containing "/", "?", "#", or "%" can't be misread as path
100131
# separators or query-string tokens.

spec/infisical/secrets_spec.rb

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,100 @@
55
let(:http_client) { Infisical::HTTPClient.new(base_url: base_url, sleeper: ->(_seconds) {}) }
66
let(:secrets) { described_class.new(http_client) }
77

8+
def secret_payload(key, value: "v", path: "/")
9+
{ id: key.downcase, secretKey: key, secretValue: value, secretPath: path, version: 1, type: "shared" }
10+
end
11+
812
describe "#list" do
9-
it "lists secrets for a project/environment" do
10-
stub_request(:get, "#{base_url}/api/v3/secrets/raw")
11-
.with(query: hash_including("workspaceId" => "proj-1", "environment" => "dev"))
13+
it "lists secrets for a project/environment, sorted by key" do
14+
stub_request(:get, "#{base_url}/api/v4/secrets")
15+
.with(query: hash_including("projectId" => "proj-1", "environment" => "dev",
16+
"includeImports" => "true", "recursive" => "false"))
1217
.to_return(
1318
status: 200,
14-
body: { secrets: [{ id: "1", workspace: "proj-1", environment: "dev", secretKey: "FOO",
15-
secretValue: "bar", secretPath: "/", version: 1, type: "shared" }] }.to_json
19+
body: { secrets: [secret_payload("FOO", value: "bar"), secret_payload("BAR")] }.to_json
1620
)
1721

1822
result = secrets.list(project_id: "proj-1", environment: "dev")
1923

20-
expect(result.size).to eq(1)
21-
expect(result.first).to be_a(Infisical::Models::Secret)
22-
expect(result.first.secret_key).to eq("FOO")
23-
expect(result.first.secret_value).to eq("bar")
24+
expect(result.map(&:secret_key)).to eq(%w[BAR FOO])
25+
expect(result).to all(be_a(Infisical::Models::Secret))
26+
expect(result.last.secret_value).to eq("bar")
27+
end
28+
29+
it "does not send expandSecretReferences (the v4 API defaults it to true)" do
30+
stub = stub_request(:get, "#{base_url}/api/v4/secrets")
31+
.with(query: hash_excluding("expandSecretReferences"))
32+
.to_return(status: 200, body: { secrets: [] }.to_json)
33+
34+
secrets.list(project_id: "proj-1", environment: "dev")
35+
36+
expect(stub).to have_been_requested
37+
end
38+
39+
context "when recursive" do
40+
it "collapses duplicate keys from different paths, keeping the last occurrence" do
41+
stub_request(:get, "#{base_url}/api/v4/secrets")
42+
.with(query: hash_including("recursive" => "true"))
43+
.to_return(
44+
status: 200,
45+
body: { secrets: [secret_payload("FOO", value: "root", path: "/"),
46+
secret_payload("FOO", value: "nested", path: "/app"),
47+
secret_payload("BAR")] }.to_json
48+
)
49+
50+
result = secrets.list(project_id: "proj-1", environment: "dev", recursive: true)
51+
52+
expect(result.map(&:secret_key)).to eq(%w[BAR FOO])
53+
expect(result.last.secret_value).to eq("nested")
54+
end
55+
56+
it "keeps same-named secrets from different paths with skip_unique_validation" do
57+
stub_request(:get, "#{base_url}/api/v4/secrets")
58+
.with(query: hash_including("recursive" => "true"))
59+
.to_return(
60+
status: 200,
61+
body: { secrets: [secret_payload("FOO", value: "root", path: "/"),
62+
secret_payload("FOO", value: "nested", path: "/app")] }.to_json
63+
)
64+
65+
result = secrets.list(project_id: "proj-1", environment: "dev",
66+
recursive: true, skip_unique_validation: true)
67+
68+
expect(result.map(&:secret_value)).to contain_exactly("root", "nested")
69+
end
70+
end
71+
72+
context "when include_imports" do
73+
it "appends imported secrets, with direct secrets taking precedence on key conflicts" do
74+
stub_request(:get, "#{base_url}/api/v4/secrets")
75+
.with(query: hash_including("includeImports" => "true"))
76+
.to_return(
77+
status: 200,
78+
body: {
79+
secrets: [secret_payload("FOO", value: "direct")],
80+
imports: [
81+
{ secretPath: "/shared", environment: "dev",
82+
secrets: [secret_payload("FOO", value: "imported"), secret_payload("DB_URL")] },
83+
{ secretPath: "/other", environment: "dev",
84+
secrets: [secret_payload("DB_URL", value: "later-import")] }
85+
]
86+
}.to_json
87+
)
88+
89+
result = secrets.list(project_id: "proj-1", environment: "dev", include_imports: true)
90+
91+
expect(result.map(&:secret_key)).to eq(%w[DB_URL FOO])
92+
expect(result.find { |s| s.secret_key == "FOO" }.secret_value).to eq("direct")
93+
expect(result.find { |s| s.secret_key == "DB_URL" }.secret_value).to eq("v")
94+
end
2495
end
2596
end
2697

2798
describe "#get" do
2899
it "fetches a single secret by name" do
29-
stub_request(:get, "#{base_url}/api/v3/secrets/raw/FOO")
30-
.with(query: hash_including("workspaceId" => "proj-1", "environment" => "dev"))
100+
stub_request(:get, "#{base_url}/api/v4/secrets/FOO")
101+
.with(query: hash_including("projectId" => "proj-1", "environment" => "dev"))
31102
.to_return(status: 200, body: { secret: { id: "1", secretKey: "FOO", secretValue: "bar" } }.to_json)
32103

33104
secret = secrets.get("FOO", project_id: "proj-1", environment: "dev")
@@ -37,8 +108,8 @@
37108
end
38109

39110
it "URL-encodes secret names containing reserved characters" do
40-
stub = stub_request(:get, "#{base_url}/api/v3/secrets/raw/FOO%2FBAR")
41-
.with(query: hash_including("workspaceId" => "proj-1"))
111+
stub = stub_request(:get, "#{base_url}/api/v4/secrets/FOO%2FBAR")
112+
.with(query: hash_including("projectId" => "proj-1"))
42113
.to_return(status: 200, body: { secret: { id: "1", secretKey: "FOO/BAR" } }.to_json)
43114

44115
secrets.get("FOO/BAR", project_id: "proj-1", environment: "dev")
@@ -49,8 +120,8 @@
49120

50121
describe "#create" do
51122
it "creates a secret with the given value" do
52-
stub = stub_request(:post, "#{base_url}/api/v3/secrets/raw/FOO")
53-
.with(body: hash_including("workspaceId" => "proj-1", "environment" => "dev", "secretValue" => "bar"))
123+
stub = stub_request(:post, "#{base_url}/api/v4/secrets/FOO")
124+
.with(body: hash_including("projectId" => "proj-1", "environment" => "dev", "secretValue" => "bar"))
54125
.to_return(status: 200, body: { secret: { id: "1", secretKey: "FOO", secretValue: "bar" } }.to_json)
55126

56127
secret = secrets.create("FOO", "bar", project_id: "proj-1", environment: "dev")
@@ -62,8 +133,8 @@
62133

63134
describe "#update" do
64135
it "updates a secret's value" do
65-
stub = stub_request(:patch, "#{base_url}/api/v3/secrets/raw/FOO")
66-
.with(body: hash_including("secretValue" => "new-val"))
136+
stub = stub_request(:patch, "#{base_url}/api/v4/secrets/FOO")
137+
.with(body: hash_including("projectId" => "proj-1", "secretValue" => "new-val"))
67138
.to_return(status: 200, body: { secret: { id: "1", secretKey: "FOO", secretValue: "new-val" } }.to_json)
68139

69140
secret = secrets.update("FOO", project_id: "proj-1", environment: "dev", secret_value: "new-val")
@@ -73,7 +144,7 @@
73144
end
74145

75146
it "supports renaming via new_secret_name" do
76-
stub = stub_request(:patch, "#{base_url}/api/v3/secrets/raw/FOO")
147+
stub = stub_request(:patch, "#{base_url}/api/v4/secrets/FOO")
77148
.with(body: hash_including("newSecretName" => "BAR"))
78149
.to_return(status: 200, body: { secret: { id: "1", secretKey: "BAR" } }.to_json)
79150

@@ -90,8 +161,8 @@
90161

91162
describe "#delete" do
92163
it "deletes a secret by name" do
93-
stub = stub_request(:delete, "#{base_url}/api/v3/secrets/raw/FOO")
94-
.with(body: hash_including("workspaceId" => "proj-1", "environment" => "dev"))
164+
stub = stub_request(:delete, "#{base_url}/api/v4/secrets/FOO")
165+
.with(body: hash_including("projectId" => "proj-1", "environment" => "dev"))
95166
.to_return(status: 200, body: { secret: { id: "1", secretKey: "FOO" } }.to_json)
96167

97168
secret = secrets.delete("FOO", project_id: "proj-1", environment: "dev")

0 commit comments

Comments
 (0)