diff --git a/README.md b/README.md index 5a914cd..50feb74 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,31 @@ const api = new YepCodeApi({ apiToken: '****' }); const processes = await api.getProcesses(); ``` +### 6. Storage Objects + +You can manage files in your YepCode workspace using the `YepCodeStorage` class. This allows you to upload, list, download, and delete files easily. + +```js +const { YepCodeStorage } = require('@yepcode/run'); +const fs = require('fs'); + +const storage = new YepCodeStorage({ apiToken: '****' }); + +// Upload a file (using Node.js stream) +await storage.upload('path/myfile.txt', fs.createReadStream('./myfile.txt')); + +// List files +const files = await storage.list(); +console.log(files); + +// Download a file +const stream = await storage.download('path/myfile.txt'); +stream.pipe(fs.createWriteStream('./downloaded.txt')); + +// Delete a file +await storage.delete('myfile.txt'); +``` + ## SDK API Reference ### YepCodeRun @@ -291,6 +316,55 @@ interface Process { } ``` +### YepCodeStorage + +Manages file storage in your YepCode workspace. Allows you to upload, list, download, and delete files using the YepCode API. + +#### Constructor + +```typescript +constructor(options?: { + apiToken?: string; // Optional if YEPCODE_API_TOKEN env var is set +}) +``` + +#### Methods + +##### `upload(filename: string, file: File | Blob | Readable): Promise` +Uploads a file to YepCode storage. + +- `filename`: Name to assign to the uploaded file +- `file`: The file to upload (can be a File, Blob, or Node.js Readable stream) +- **Returns:** Promise + +##### `list(): Promise` +Lists all files in YepCode storage. + +- **Returns:** Promise + +##### `download(filename: string): Promise` +Downloads a file from YepCode storage as a Node.js Readable stream. + +- `filename`: Name of the file to download +- **Returns:** Promise + +##### `delete(filename: string): Promise` +Deletes a file from YepCode storage. + +- `filename`: Name of the file to delete +- **Returns:** Promise + +##### `StorageObject` +```typescript +interface StorageObject { + name: string; + url: string; + size?: number; + contentType?: string; + createdAt?: string; +} +``` + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/src/api/types.ts b/src/api/types.ts index 9c4c24b..30b0521 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,3 +1,5 @@ +import { Readable } from "stream"; + export interface YepCodeApiConfig { authUrl?: string; apiHost?: string; @@ -380,3 +382,21 @@ export interface VersionedModuleAliasesPaginatedResult { total?: number; data?: VersionedModuleAlias[]; } + +/** + * Storage + */ +export type StorageObject = { + name: string; + size: number; + md5Hash: string; + contentType: string; + createdAt: string; + updatedAt: string; + link: URL; +}; + +export type CreateStorageObjectInput = { + name: string; + file: File | Blob | Readable; +}; diff --git a/src/api/yepcodeApi.ts b/src/api/yepcodeApi.ts index 6ee0ade..38ddb9b 100644 --- a/src/api/yepcodeApi.ts +++ b/src/api/yepcodeApi.ts @@ -30,7 +30,10 @@ import { VersionedModuleAlias, VersionedModuleAliasInput, VersionedModuleAliasesPaginatedResult, + StorageObject, + CreateStorageObjectInput, } from "./types"; +import { Readable } from "stream"; export class YepCodeApiError extends Error { constructor(message: string, public status: number) { @@ -39,6 +42,14 @@ export class YepCodeApiError extends Error { } } +type RequestOptions = { + headers?: Record; + data?: any; + params?: Record; + duplex?: "half" | "full"; + responseType?: "stream"; +}; + export class YepCodeApi { private apiHost: string; private clientId?: string; @@ -197,28 +208,33 @@ export class YepCodeApi { private async request( method: string, endpoint: string, - options: { - headers?: Record; - data?: any; - params?: Record; - } = {} + options: RequestOptions = {} ): Promise { if (!this.accessToken) { await this.getAccessToken(); } + const isFormData = + typeof FormData !== "undefined" && options.data instanceof FormData; + const isStream = + typeof Readable !== "undefined" && options.data instanceof Readable; + const fetchOptions: RequestInit = { method, headers: { Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", + ...(isFormData || isStream || !options.data + ? {} + : { "Content-Type": "application/json" }), ...(options.headers || {}), }, signal: AbortSignal.timeout(this.timeout), + ...(options.duplex && { duplex: options.duplex }), }; if (options.data) { - fetchOptions.body = JSON.stringify(options.data); + fetchOptions.body = + isFormData || isStream ? options.data : JSON.stringify(options.data); } const url = new URL(`${this.getBaseURL()}${endpoint}`); @@ -251,6 +267,15 @@ export class YepCodeApi { ); } + if (options.responseType === "stream") { + if (typeof response.body?.getReader === "function") { + // @ts-ignore + return Readable.fromWeb(response.body) as T; + } + // @ts-ignore + return response.body as T; + } + const responseText = await response.text(); try { return JSON.parse(responseText); @@ -548,4 +573,58 @@ export class YepCodeApi { ): Promise { return this.request("POST", `/modules/${moduleId}/aliases`, { data }); } + + async getObjects(): Promise { + return this.request("GET", "/storage/objects"); + } + + async getObject(name: string): Promise { + return this.request("GET", `/storage/objects/${name}`, { + responseType: "stream", + }); + } + + async createObject(data: CreateStorageObjectInput): Promise { + const file = data.file; + if (!file) { + throw new Error("File or stream is required"); + } + + const isReadable = + typeof Readable !== "undefined" && file instanceof Readable; + const isFile = typeof File !== "undefined" && file instanceof File; + const isBlob = typeof Blob !== "undefined" && file instanceof Blob; + if (!isReadable && !isFile && !isBlob) { + throw new Error( + "Unsupported file type. Must be File, Blob or Readable stream." + ); + } + + const options: RequestOptions = {}; + if (isFile || isBlob) { + const formData = new FormData(); + formData.append("file", file); + options.data = formData; + } + if (isReadable) { + options.data = file; + options.duplex = "half"; + options.headers = { + "Content-Type": "application/octet-stream", + }; + } + + return this.request( + "POST", + `/storage/objects?name=${encodeURIComponent(data.name)}`, + options + ); + } + + async deleteObject(name: string): Promise { + return this.request( + "DELETE", + `/storage/objects/${encodeURIComponent(name)}` + ); + } } diff --git a/src/index.ts b/src/index.ts index 874995f..edf7791 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { YepCodeRun, Execution } from "./run"; export { YepCodeEnv } from "./env"; export * from "./api"; +export * from "./storage"; export * from "./types"; diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000..38e20cf --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1 @@ +export * from "./yepcodeStorage"; diff --git a/src/storage/yepcodeStorage.ts b/src/storage/yepcodeStorage.ts new file mode 100644 index 0000000..28f2920 --- /dev/null +++ b/src/storage/yepcodeStorage.ts @@ -0,0 +1,30 @@ +import { YepCodeApi, YepCodeApiManager } from "../api"; +import { StorageObject, YepCodeApiConfig } from "../api/types"; +import { Readable } from "stream"; + +export class YepCodeStorage { + private api: YepCodeApi; + + constructor(config: YepCodeApiConfig = {}) { + this.api = YepCodeApiManager.getInstance(config); + } + + async upload( + filename: string, + file: File | Blob | Readable + ): Promise { + return this.api.createObject({ name: filename, file }); + } + + async list(): Promise { + return this.api.getObjects(); + } + + async download(filename: string): Promise { + return this.api.getObject(filename); + } + + async delete(filename: string): Promise { + await this.api.deleteObject(filename); + } +} diff --git a/tests/api/test-run-sdk.txt b/tests/api/test-run-sdk.txt new file mode 100644 index 0000000..ff31d02 --- /dev/null +++ b/tests/api/test-run-sdk.txt @@ -0,0 +1 @@ +Hello, YepCode! This is a test. diff --git a/tests/api/yepcodeApi.storage.test.ts b/tests/api/yepcodeApi.storage.test.ts new file mode 100644 index 0000000..8da751b --- /dev/null +++ b/tests/api/yepcodeApi.storage.test.ts @@ -0,0 +1,117 @@ +import { YepCodeApi } from "../../src/api/yepcodeApi"; +import { StorageObject } from "../../src/api/types"; +import fs, { createWriteStream, readFileSync } from "fs"; +import path from "path"; +import { Readable } from "stream"; + +const testName = "test-run-sdk.txt"; +const testFilePath = path.join(__dirname, testName); +const downloadedFile = path.join(__dirname, "./downloaded_test.json"); + +const apiHost = process.env.YEPCODE_API_HOST; +const authUrl = process.env.YEPCODE_AUTH_URL; +const apiToken = process.env.YEPCODE_API_TOKEN; + +let api: YepCodeApi; + +const verifyDownloadedFile = async ( + result: Readable, + downloadedFile: string, + testFilePath: string +) => { + const fileStream = createWriteStream(downloadedFile); + await new Promise((resolve, reject) => { + result.pipe(fileStream).on("finish", resolve).on("error", reject); + }); + expect(fs.existsSync(downloadedFile)).toBe(true); + const downloadedContent = readFileSync(downloadedFile, "utf8"); + const originalContent = readFileSync(testFilePath, "utf8"); + expect(downloadedContent).toBe(originalContent); +}; + +describe.skip("YepCodeApi", () => { + beforeAll(async () => { + api = new YepCodeApi({ apiHost, authUrl, apiToken }); + }); + + afterEach(async () => { + await api.deleteObject(testName).catch(() => {}); + if (fs.existsSync(downloadedFile)) { + fs.unlinkSync(downloadedFile); + } + }); + + describe("getObjects", () => { + it("should return a list of storage objects", async () => { + const result: StorageObject[] = await api.getObjects(); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe("createObject", () => { + it("should create a storage object with a File", async () => { + const file: File = new File([readFileSync(testFilePath)], testName); + const result: StorageObject = await api.createObject({ + name: testName, + file, + }); + expect(result).toBeDefined(); + }); + + it("should create a storage object with a Blob", async () => { + const fileBlob: Blob = new Blob([readFileSync(testFilePath)]); + const result: StorageObject = await api.createObject({ + name: testName, + file: fileBlob, + }); + expect(result).toBeDefined(); + }); + + it("should create a storage object with a stream", async () => { + const stream = fs.createReadStream(testFilePath); + const result: StorageObject = await api.createObject({ + name: testName, + file: stream, + }); + expect(result).toBeDefined(); + }); + }); + + describe("deleteObject", () => { + it("should delete a storage object", async () => { + const file: File = new File([readFileSync(testFilePath)], testName); + await api.createObject({ name: testName, file }); + + await api.deleteObject(testName); + }); + }); + + describe("getObject", () => { + it("should get a storage object uploaded as File", async () => { + const file: File = new File([readFileSync(testFilePath)], testName); + await api.createObject({ name: testName, file }); + + const result: Readable = await api.getObject(testName); + + await verifyDownloadedFile(result, downloadedFile, testFilePath); + }); + + it("should get a storage object uploaded as Blob", async () => { + const blob: Blob = new Blob([readFileSync(testFilePath)]); + await api.createObject({ name: testName, file: blob }); + + const result: Readable = await api.getObject(testName); + + await verifyDownloadedFile(result, downloadedFile, testFilePath); + }); + + it("should get a storage object uploaded as stream", async () => { + const stream = fs.createReadStream(testFilePath); + await api.createObject({ name: testName, file: stream }); + + const result: Readable = await api.getObject(testName); + + await verifyDownloadedFile(result, downloadedFile, testFilePath); + }); + }); +}); diff --git a/tests/storage/test-run-sdk.txt b/tests/storage/test-run-sdk.txt new file mode 100644 index 0000000..926d421 --- /dev/null +++ b/tests/storage/test-run-sdk.txt @@ -0,0 +1 @@ +Hello, YepCode! This is a storage test. diff --git a/tests/storage/yepcodeStorage.test.ts b/tests/storage/yepcodeStorage.test.ts new file mode 100644 index 0000000..56e9720 --- /dev/null +++ b/tests/storage/yepcodeStorage.test.ts @@ -0,0 +1,108 @@ +import { YepCodeStorage } from "../../src/storage"; +import { StorageObject } from "../../src/api/types"; +import fs, { createWriteStream, readFileSync } from "fs"; +import path from "path"; +import { Readable } from "stream"; + +const testName = "test-run-sdk.txt"; +const testFilePath = path.join(__dirname, testName); +const downloadedFile = path.join(__dirname, "./downloaded_test.json"); + +const apiHost = process.env.YEPCODE_API_HOST; +const authUrl = process.env.YEPCODE_AUTH_URL; +const apiToken = process.env.YEPCODE_API_TOKEN; + +let storage: YepCodeStorage; + +const verifyDownloadedFile = async ( + result: Readable, + downloadedFile: string, + testFilePath: string +) => { + const fileStream = createWriteStream(downloadedFile); + await new Promise((resolve, reject) => { + result.pipe(fileStream).on("finish", resolve).on("error", reject); + }); + expect(fs.existsSync(downloadedFile)).toBe(true); + const downloadedContent = readFileSync(downloadedFile, "utf8"); + const originalContent = readFileSync(testFilePath, "utf8"); + expect(downloadedContent).toBe(originalContent); +}; + +describe.skip("YepCodeStorage", () => { + beforeAll(async () => { + storage = new YepCodeStorage({ apiHost, authUrl, apiToken }); + }); + + afterEach(async () => { + await storage.delete(testName).catch(() => {}); + if (fs.existsSync(downloadedFile)) { + fs.unlinkSync(downloadedFile); + } + }); + + describe("getObjects", () => { + it("should return a list of storage objects", async () => { + const result: StorageObject[] = await storage.list(); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe("createObject", () => { + it("should create a storage object with a File", async () => { + const file: File = new File([readFileSync(testFilePath)], testName); + const result: StorageObject = await storage.upload(testName, file); + expect(result).toBeDefined(); + }); + + it("should create a storage object with a Blob", async () => { + const fileBlob: Blob = new Blob([readFileSync(testFilePath)]); + const result: StorageObject = await storage.upload(testName, fileBlob); + expect(result).toBeDefined(); + }); + + it("should create a storage object with a stream", async () => { + const stream = fs.createReadStream(testFilePath); + const result: StorageObject = await storage.upload(testName, stream); + expect(result).toBeDefined(); + }); + }); + + describe("deleteObject", () => { + it("should delete a storage object", async () => { + const file: File = new File([readFileSync(testFilePath)], testName); + await storage.upload(testName, file); + + await storage.delete(testName); + }); + }); + + describe("getObject", () => { + it("should get a storage object uploaded as File", async () => { + const file: File = new File([readFileSync(testFilePath)], testName); + await storage.upload(testName, file); + + const result: Readable = await storage.download(testName); + + await verifyDownloadedFile(result, downloadedFile, testFilePath); + }); + + it("should get a storage object uploaded as Blob", async () => { + const blob: Blob = new Blob([readFileSync(testFilePath)]); + await storage.upload(testName, blob); + + const result: Readable = await storage.download(testName); + + await verifyDownloadedFile(result, downloadedFile, testFilePath); + }); + + it("should get a storage object uploaded as stream", async () => { + const stream = fs.createReadStream(testFilePath); + await storage.upload(testName, stream); + + const result: Readable = await storage.download(testName); + + await verifyDownloadedFile(result, downloadedFile, testFilePath); + }); + }); +});