Skip to content

Commit 6e8c1d2

Browse files
Add createSignedUrl to YepCodeApi and YepCodeStorage
Wraps the new POST /storage/signed-urls endpoint that returns a short-lived GCS V4 signed read URL for an existing file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fdc23fc commit 6e8c1d2

5 files changed

Lines changed: 144 additions & 4 deletions

File tree

src/api/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,17 @@ export type CreateStorageObjectInput = {
463463
file: File | Blob | Readable;
464464
};
465465

466+
export type CreateSignedUrlInput = {
467+
path: string;
468+
expiresInSeconds?: number;
469+
};
470+
471+
export type SignedUrl = {
472+
url: string;
473+
path: string;
474+
expiresAt: string;
475+
};
476+
466477
/**
467478
* Auth
468479
*/

src/api/yepcodeApi.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
VersionedModuleAliasesPaginatedResult,
3535
StorageObject,
3636
CreateStorageObjectInput,
37+
CreateSignedUrlInput,
38+
SignedUrl,
3739
Token,
3840
Sandbox,
3941
CreateSandboxInput,
@@ -796,6 +798,10 @@ export class YepCodeApi {
796798
);
797799
}
798800

801+
async createSignedUrl(data: CreateSignedUrlInput): Promise<SignedUrl> {
802+
return this.request("POST", "/storage/signed-urls", { data });
803+
}
804+
799805
// Auth endpoints
800806
async getToken(apiToken: string): Promise<Token> {
801807
return this.request("POST", "/auth/token", {

src/storage/yepcodeStorage.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { YepCodeApi } from "../api";
2-
import { StorageObject, YepCodeApiConfig } from "../api/types";
2+
import { SignedUrl, StorageObject, YepCodeApiConfig } from "../api/types";
33
import { Readable } from "stream";
44

55
export class YepCodeStorage {
@@ -27,4 +27,11 @@ export class YepCodeStorage {
2727
async delete(filename: string): Promise<void> {
2828
await this.api.deleteObject(filename);
2929
}
30+
31+
async createSignedUrl(
32+
filename: string,
33+
options: { expiresInSeconds?: number } = {}
34+
): Promise<SignedUrl> {
35+
return this.api.createSignedUrl({ path: filename, ...options });
36+
}
3037
}

tests/api/yepcodeApi.storage.test.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { YepCodeApi } from "../../src/api/yepcodeApi";
2-
import { StorageObject } from "../../src/api/types";
1+
import { YepCodeApi, YepCodeApiError } from "../../src/api/yepcodeApi";
2+
import { SignedUrl, StorageObject } from "../../src/api/types";
33
import fs, { createWriteStream, readFileSync } from "fs";
44
import path from "path";
55
import { Readable } from "stream";
@@ -113,4 +113,68 @@ describe.skip("YepCodeApi", () => {
113113
await verifyDownloadedFile(result, downloadedFile, testFilePath);
114114
});
115115
});
116+
117+
describe("createSignedUrl", () => {
118+
it("should return a signed url with the default expiry", async () => {
119+
const file: File = new File([readFileSync(testFilePath)], testName);
120+
await api.createObject({ name: testName, file });
121+
122+
const result: SignedUrl = await api.createSignedUrl({ path: testName });
123+
124+
expect(typeof result.url).toBe("string");
125+
expect(result.url.length).toBeGreaterThan(0);
126+
expect(result.path).toBe(testName);
127+
128+
const expiresAt = new Date(result.expiresAt).getTime();
129+
const expectedExpiry = Date.now() + 3600 * 1000;
130+
expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(60 * 1000);
131+
});
132+
133+
it("should return a signed url with a custom expiry", async () => {
134+
const file: File = new File([readFileSync(testFilePath)], testName);
135+
await api.createObject({ name: testName, file });
136+
137+
const result: SignedUrl = await api.createSignedUrl({
138+
path: testName,
139+
expiresInSeconds: 60,
140+
});
141+
142+
const expiresAt = new Date(result.expiresAt).getTime();
143+
const expectedExpiry = Date.now() + 60 * 1000;
144+
expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(30 * 1000);
145+
});
146+
147+
it("should return content matching the original file when fetched", async () => {
148+
const file: File = new File([readFileSync(testFilePath)], testName);
149+
await api.createObject({ name: testName, file });
150+
151+
const { url } = await api.createSignedUrl({ path: testName });
152+
153+
const response = await fetch(url);
154+
expect(response.ok).toBe(true);
155+
const body = await response.text();
156+
expect(body).toBe(readFileSync(testFilePath, "utf8"));
157+
});
158+
159+
it("should throw a 404 when the file does not exist", async () => {
160+
await expect(
161+
api.createSignedUrl({ path: "does-not-exist.txt" })
162+
).rejects.toMatchObject({
163+
name: "YepCodeApiError",
164+
status: 404,
165+
} as Partial<YepCodeApiError>);
166+
});
167+
168+
it("should throw a 400 when expiresInSeconds is out of range", async () => {
169+
const file: File = new File([readFileSync(testFilePath)], testName);
170+
await api.createObject({ name: testName, file });
171+
172+
await expect(
173+
api.createSignedUrl({ path: testName, expiresInSeconds: 999999 })
174+
).rejects.toMatchObject({
175+
name: "YepCodeApiError",
176+
status: 400,
177+
} as Partial<YepCodeApiError>);
178+
});
179+
});
116180
});

tests/storage/yepcodeStorage.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { YepCodeStorage } from "../../src/storage";
2-
import { StorageObject } from "../../src/api/types";
2+
import { YepCodeApiError } from "../../src/api/yepcodeApi";
3+
import { SignedUrl, StorageObject } from "../../src/api/types";
34
import fs, { createWriteStream, readFileSync } from "fs";
45
import path from "path";
56
import { Readable } from "stream";
@@ -117,4 +118,55 @@ describe.skip("YepCodeStorage", () => {
117118
await verifyDownloadedFile(result, downloadedFile, testFilePath);
118119
});
119120
});
121+
122+
describe("createSignedUrl", () => {
123+
it("should return a signed url with the default expiry", async () => {
124+
const file: File = new File([readFileSync(testFilePath)], testName);
125+
await storage.upload(testName, file);
126+
127+
const result: SignedUrl = await storage.createSignedUrl(testName);
128+
129+
expect(typeof result.url).toBe("string");
130+
expect(result.url.length).toBeGreaterThan(0);
131+
expect(result.path).toBe(testName);
132+
133+
const expiresAt = new Date(result.expiresAt).getTime();
134+
const expectedExpiry = Date.now() + 3600 * 1000;
135+
expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(60 * 1000);
136+
});
137+
138+
it("should return a signed url with a custom expiry", async () => {
139+
const file: File = new File([readFileSync(testFilePath)], testName);
140+
await storage.upload(testName, file);
141+
142+
const result: SignedUrl = await storage.createSignedUrl(testName, {
143+
expiresInSeconds: 60,
144+
});
145+
146+
const expiresAt = new Date(result.expiresAt).getTime();
147+
const expectedExpiry = Date.now() + 60 * 1000;
148+
expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(30 * 1000);
149+
});
150+
151+
it("should throw a 404 when the file does not exist", async () => {
152+
await expect(
153+
storage.createSignedUrl("does-not-exist.txt")
154+
).rejects.toMatchObject({
155+
name: "YepCodeApiError",
156+
status: 404,
157+
} as Partial<YepCodeApiError>);
158+
});
159+
160+
it("should throw a 400 when expiresInSeconds is out of range", async () => {
161+
const file: File = new File([readFileSync(testFilePath)], testName);
162+
await storage.upload(testName, file);
163+
164+
await expect(
165+
storage.createSignedUrl(testName, { expiresInSeconds: 999999 })
166+
).rejects.toMatchObject({
167+
name: "YepCodeApiError",
168+
status: 400,
169+
} as Partial<YepCodeApiError>);
170+
});
171+
});
120172
});

0 commit comments

Comments
 (0)