Skip to content

Commit 77a0439

Browse files
authored
feat: add support for urls in .generate function (#132)
1 parent fd03948 commit 77a0439

File tree

2 files changed

+128
-3
lines changed

2 files changed

+128
-3
lines changed

src/resources/v1/files/resource-client.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,71 @@ describe("FilesClient.uploadFile", () => {
6969
mockFs.readFileSync.mockReturnValue(Buffer.from("mock file content"));
7070
});
7171

72+
describe("URL and already-uploaded path handling", () => {
73+
test("should return HTTP URL as-is without uploading", async () => {
74+
const url = "http://example.com/image.jpg";
75+
const createSpy = jest.spyOn(client.v1.files.uploadUrls, "create");
76+
77+
const result = await client.v1.files.uploadFile(url);
78+
79+
expect(result).toBe(url);
80+
expect(createSpy).not.toHaveBeenCalled();
81+
expect(mockFs.existsSync).not.toHaveBeenCalled();
82+
83+
createSpy.mockRestore();
84+
});
85+
86+
test("should return HTTPS URL as-is without uploading", async () => {
87+
const url = "https://example.com/path/to/video.mp4";
88+
const createSpy = jest.spyOn(client.v1.files.uploadUrls, "create");
89+
90+
const result = await client.v1.files.uploadFile(url);
91+
92+
expect(result).toBe(url);
93+
expect(createSpy).not.toHaveBeenCalled();
94+
expect(mockFs.existsSync).not.toHaveBeenCalled();
95+
96+
createSpy.mockRestore();
97+
});
98+
99+
test("should return already-uploaded api-assets path as-is without uploading", async () => {
100+
const apiAssetsPath = "api-assets/12345/image.jpg";
101+
const createSpy = jest.spyOn(client.v1.files.uploadUrls, "create");
102+
103+
const result = await client.v1.files.uploadFile(apiAssetsPath);
104+
105+
expect(result).toBe(apiAssetsPath);
106+
expect(createSpy).not.toHaveBeenCalled();
107+
expect(mockFs.existsSync).not.toHaveBeenCalled();
108+
109+
createSpy.mockRestore();
110+
});
111+
112+
test("should handle URLs with query parameters", async () => {
113+
const url = "https://example.com/image.jpg?token=abc123&expires=12345";
114+
const createSpy = jest.spyOn(client.v1.files.uploadUrls, "create");
115+
116+
const result = await client.v1.files.uploadFile(url);
117+
118+
expect(result).toBe(url);
119+
expect(createSpy).not.toHaveBeenCalled();
120+
121+
createSpy.mockRestore();
122+
});
123+
124+
test("should upload local file paths that look similar to URLs but are not", async () => {
125+
const filePath = "/http/example.com/image.jpg";
126+
const createSpy = jest.spyOn(client.v1.files.uploadUrls, "create");
127+
128+
const result = await client.v1.files.uploadFile(filePath);
129+
130+
expect(result).toBe("api-assets/12345/image.jpg");
131+
expect(createSpy).toHaveBeenCalled();
132+
133+
createSpy.mockRestore();
134+
});
135+
});
136+
72137
describe("File type detection", () => {
73138
// MSW handles the uploadUrls.create call automatically
74139

src/resources/v1/files/resource-client.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import * as path from "path";
99
import { Readable } from "stream";
1010

11+
import { getLogger } from "magic-hour/logger";
1112
import { UploadUrlsClient } from "magic-hour/resources/v1/files/upload-urls";
1213

1314
export type FileInput =
@@ -17,6 +18,25 @@ export type FileInput =
1718
| File
1819
| NodeJS.ReadableStream;
1920

21+
/**
22+
* Check if the given string is a valid HTTP/HTTPS URL.
23+
*/
24+
function isUrl(str: string): boolean {
25+
try {
26+
const url = new URL(str);
27+
return url.protocol === "http:" || url.protocol === "https:";
28+
} catch {
29+
return false;
30+
}
31+
}
32+
33+
/**
34+
* Check if the given string is an already-uploaded path (api-assets/).
35+
*/
36+
function isAlreadyUploaded(str: string): boolean {
37+
return str.startsWith("api-assets/");
38+
}
39+
2040
/**
2141
* Determine file type and extension from file path or name.
2242
*/
@@ -128,14 +148,20 @@ export class FilesClient extends CoreResourceClient {
128148
* a file path that can be used as input for other Magic Hour API endpoints.
129149
* The file type is automatically detected from the file extension.
130150
*
151+
* If a URL (http:// or https://) or an already-uploaded path (api-assets/...)
152+
* is provided, it will be returned as-is without uploading.
153+
*
131154
* @param file - The file to upload. Can be:
132-
* - **string**: Path to a local file (e.g., "/path/to/image.jpg")
155+
* - **string**: Path to a local file (e.g., "/path/to/image.jpg"),
156+
* a URL (e.g., "https://example.com/image.jpg"), or
157+
* an already-uploaded path (e.g., "api-assets/id/1234.png")
133158
* - **Buffer**: File content as buffer (requires extension detection via other means)
134159
* - **Readable**: Node.js readable stream (must have a 'path' property)
135160
* - **File**: File object (browser environment)
136161
*
137-
* @returns The uploaded file's path in Magic Hour's storage system.
138-
* This path can be used as input for other API endpoints.
162+
* @returns The file path that can be used as input for other API endpoints.
163+
* For local files, this will be the uploaded path in Magic Hour's storage.
164+
* For URLs and already-uploaded paths, this will be the input string as-is.
139165
*
140166
* @throws {Error} If the specified local file doesn't exist.
141167
* @throws {Error} If the file type is not supported.
@@ -151,6 +177,10 @@ export class FilesClient extends CoreResourceClient {
151177
* const filePath = await client.v1.files.uploadFile("/path/to/your/image.jpg");
152178
* console.log(`Uploaded file: ${filePath}`);
153179
*
180+
* // URLs are returned as-is (no upload needed)
181+
* const urlPath = await client.v1.files.uploadFile("https://example.com/image.jpg");
182+
* console.log(urlPath); // "https://example.com/image.jpg"
183+
*
154184
* // Use the uploaded file in other API calls
155185
* const result = await client.v1.aiImageUpscaler.create({
156186
* assets: { imageFilePath: filePath },
@@ -159,7 +189,29 @@ export class FilesClient extends CoreResourceClient {
159189
* ```
160190
*/
161191
async uploadFile(file: FileInput): Promise<string> {
192+
const logger = getLogger();
193+
194+
// If the input is a URL or already-uploaded path, return it as-is
195+
if (typeof file === "string") {
196+
if (isUrl(file)) {
197+
logger.debug(`Skipping upload for ${file} since it is a valid URL`);
198+
return file;
199+
} else if (isAlreadyUploaded(file)) {
200+
logger.debug(
201+
`Skipping upload for ${file} since it is a valid already-uploaded path`,
202+
);
203+
return file;
204+
} else {
205+
logger.debug(
206+
`Processing file input for upload ${file} as a local file`,
207+
);
208+
}
209+
}
210+
162211
const { filePath, fileData, fileType, extension } = processFileInput(file);
212+
logger.debug(
213+
`File processed: type=${fileType}, extension=${extension}, source=${filePath ? "path" : "data"}`,
214+
);
163215

164216
// Create upload URL
165217
const response = await this.uploadUrls.create({
@@ -179,25 +231,32 @@ export class FilesClient extends CoreResourceClient {
179231
if (!uploadInfo) {
180232
throw new Error("Upload info is missing from server response");
181233
}
234+
logger.debug(`Received upload URL, target path: ${uploadInfo.filePath}`);
182235

183236
// Prepare file content
184237
let content: Buffer;
185238
if (filePath) {
186239
content = fs.readFileSync(filePath);
240+
logger.debug(`Read ${content.length} bytes from local file: ${filePath}`);
187241
} else if (fileData) {
188242
if (Buffer.isBuffer(fileData)) {
189243
content = fileData;
244+
logger.debug(`Using buffer data: ${content.length} bytes`);
190245
} else if (fileData instanceof Readable) {
191246
// For streams, read all data into buffer
192247
const chunks: Buffer[] = [];
193248
for await (const chunk of fileData) {
194249
chunks.push(chunk);
195250
}
196251
content = Buffer.concat(chunks);
252+
logger.debug(`Read ${content.length} bytes from stream`);
197253
} else if (typeof File !== "undefined" && fileData instanceof File) {
198254
// File object - convert to buffer
199255
const arrayBuffer = await fileData.arrayBuffer();
200256
content = Buffer.from(arrayBuffer);
257+
logger.debug(
258+
`Read ${content.length} bytes from File object: ${fileData.name}`,
259+
);
201260
} else {
202261
throw new Error("Unsupported file data type");
203262
}
@@ -222,6 +281,7 @@ export class FilesClient extends CoreResourceClient {
222281
);
223282
}
224283

284+
logger.debug(`Upload complete: ${uploadInfo.filePath}`);
225285
return uploadInfo.filePath;
226286
}
227287
}

0 commit comments

Comments
 (0)