From 76d586cd40d2be951e3eb3f1861c9d0f7ab45aae Mon Sep 17 00:00:00 2001 From: iammatthias Date: Mon, 22 Dec 2025 11:05:00 -0800 Subject: [PATCH 1/2] feat: add CID version support to upload options and update uploadFile function --- src/core/classes/uploads/UploadBuilder.ts | 14 +- src/core/functions/uploads/file.ts | 19 ++- src/core/types/uploads.ts | 10 ++ tests/uploads/file.test.ts | 157 ++++++++++++++++++++++ 4 files changed, 191 insertions(+), 9 deletions(-) diff --git a/src/core/classes/uploads/UploadBuilder.ts b/src/core/classes/uploads/UploadBuilder.ts index d7159d7..999eb72 100644 --- a/src/core/classes/uploads/UploadBuilder.ts +++ b/src/core/classes/uploads/UploadBuilder.ts @@ -1,4 +1,4 @@ -import type { PinataConfig, PinataMetadata, UploadOptions } from "../../types"; +import type { PinataConfig, PinataMetadata, UploadOptions, CidVersion } from "../../types"; export class UploadBuilder { private config: PinataConfig | undefined; @@ -15,6 +15,7 @@ export class UploadBuilder { private isStreamable: boolean | undefined; private peerAddresses: string[] | undefined; private carFormat: boolean | undefined; + private _cidVersion: CidVersion | undefined; constructor( config: PinataConfig | undefined, @@ -61,10 +62,10 @@ export class UploadBuilder { return this; } - // cidVersion(v: 0 | 1): UploadBuilder { - // this.version = v; - // return this; - // } + cidVersion(v: CidVersion): UploadBuilder { + this._cidVersion = v; + return this; + } group(groupId: string): UploadBuilder { this.groupId = groupId; @@ -121,6 +122,9 @@ export class UploadBuilder { if (this.carFormat) { options.car = this.carFormat; } + if (this._cidVersion !== undefined) { + options.cid_version = this._cidVersion; + } this.args[this.args.length - 1] = options; return this.uploadFunction(this.config, ...this.args).then( onfulfilled, diff --git a/src/core/functions/uploads/file.ts b/src/core/functions/uploads/file.ts index fbca94c..3f17a15 100644 --- a/src/core/functions/uploads/file.ts +++ b/src/core/functions/uploads/file.ts @@ -59,13 +59,18 @@ export const uploadFile = async ( metadata += `,car ${btoa("true")}`; } + // Build URL with query parameters for chunked uploads let updatedEndpoint: string = `${endpoint}/files`; - if (options?.url) { updatedEndpoint = options.url; } + + const requestUrl = new URL(updatedEndpoint); + if (options?.cid_version !== undefined) { + requestUrl.searchParams.set('X-Upload-Option-Cid-Version', options.cid_version.toString()); + } - const urlReq = await fetch(updatedEndpoint, { + const urlReq = await fetch(requestUrl.toString(), { method: "POST", headers: { "Upload-Length": `${file.size}`, @@ -98,7 +103,7 @@ export const uploadFile = async ( while (retryCount <= maxRetries) { try { - uploadReq = await fetch(url as string, { + uploadReq = await fetch(url!, { method: "PATCH", headers: { "Content-Type": "application/offset+octet-stream", @@ -248,9 +253,15 @@ export const uploadFile = async ( data.append("car", "true"); } + if (options?.cid_version !== undefined) { + data.append("cid_version", options.cid_version.toString()); + } + if (options?.url) { try { - const request = await fetch(options.url, { + const url = new URL(options.url); + + const request = await fetch(url.toString(), { method: "POST", headers: headers, body: data, diff --git a/src/core/types/uploads.ts b/src/core/types/uploads.ts index 6bdc2ee..100159d 100644 --- a/src/core/types/uploads.ts +++ b/src/core/types/uploads.ts @@ -1,5 +1,7 @@ import { PinataMetadata } from "./"; +export type CidVersion = "v0" | "v1"; + export type UploadResponse = { id: string; name: string; @@ -26,6 +28,10 @@ export type UploadOptions = { streamable?: boolean; peerAddresses?: string[]; car?: boolean; + /** + * CID version "v1" or "v0" (defaults to v1 if falsy) + */ + cid_version?: CidVersion; }; export type SignedUploadUrlOptions = { @@ -39,6 +45,10 @@ export type SignedUploadUrlOptions = { mimeTypes?: string[]; streamable?: boolean; car?: boolean; + /** + * CID version "v1" or "v0" (defaults to v1 if falsy) + */ + cid_version?: CidVersion; }; export type UploadCIDOptions = { diff --git a/tests/uploads/file.test.ts b/tests/uploads/file.test.ts index 925bd1c..f46e119 100644 --- a/tests/uploads/file.test.ts +++ b/tests/uploads/file.test.ts @@ -4,6 +4,7 @@ import type { UploadOptions, UploadResponse, PinataMetadata, + CidVersion, } from "../../src"; import { PinataError, @@ -203,4 +204,160 @@ describe("uploadFile function", () => { }), ); }); + + it("should upload file with CID version 0", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const mockOptions: UploadOptions = { + cid_version: "v0" as CidVersion, + }; + + await uploadFile(mockConfig, mockFile, "public", mockOptions); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const formData = fetchCall[1].body as FormData; + + expect(formData.get("cid_version")).toBe("v0"); + expect(global.fetch).toHaveBeenCalledWith( + "https://uploads.pinata.cloud/v3/files", + expect.objectContaining({ + method: "POST", + headers: { + Source: "sdk/file", + Authorization: "Bearer test-jwt", + }, + body: expect.any(FormData), + }), + ); + }); + + it("should upload file with CID version 1", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const mockOptions: UploadOptions = { + cid_version: "v1" as CidVersion, + }; + + await uploadFile(mockConfig, mockFile, "public", mockOptions); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const formData = fetchCall[1].body as FormData; + + expect(formData.get("cid_version")).toBe("v1"); + expect(global.fetch).toHaveBeenCalledWith( + "https://uploads.pinata.cloud/v3/files", + expect.objectContaining({ + method: "POST", + headers: { + Source: "sdk/file", + Authorization: "Bearer test-jwt", + }, + body: expect.any(FormData), + }), + ); + }); + + it("should upload same file with both CID versions", async () => { + const testFile = new File(["test content for cid versions"], "test.txt", { + type: "text/plain", + }); + + // Mock for CID version 0 + global.fetch = jest.fn() + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: { ...mockResponse, cid: "QmTestVersion0" } }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: { ...mockResponse, cid: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" } }), + }); + + // Upload with CID version 0 + const result0 = await uploadFile(mockConfig, testFile, "public", { cid_version: "v0" as CidVersion }); + + // Upload with CID version 1 + const result1 = await uploadFile(mockConfig, testFile, "public", { cid_version: "v1" as CidVersion }); + + // Verify both uploads + expect(result0.cid).toBe("QmTestVersion0"); + expect(result1.cid).toBe("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"); + + // Verify fetch was called twice + expect(global.fetch).toHaveBeenCalledTimes(2); + + // Check CID version 0 FormData + const fetchCall0 = (global.fetch as jest.Mock).mock.calls[0]; + const formData0 = fetchCall0[1].body as FormData; + expect(formData0.get("cid_version")).toBe("v0"); + + // Check CID version 1 FormData + const fetchCall1 = (global.fetch as jest.Mock).mock.calls[1]; + const formData1 = fetchCall1[1].body as FormData; + expect(formData1.get("cid_version")).toBe("v1"); + }); + + it("should upload file with CID version combined with other options", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const mockOptions: UploadOptions = { + cid_version: "v1" as CidVersion, + car: true, + groupId: "test-group", + streamable: true, + }; + + await uploadFile(mockConfig, mockFile, "public", mockOptions); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const formData = fetchCall[1].body as FormData; + + expect(formData.get("cid_version")).toBe("v1"); + expect(formData.get("car")).toBe("true"); + expect(formData.get("group_id")).toBe("test-group"); + expect(formData.get("streamable")).toBe("true"); + }); + + it("should include CID version in metadata for large file upload", async () => { + // Create a large file (over 90MB) to trigger chunked upload metadata path + const largeFileSize = 94371841; // Just over the threshold + const largeFile = new File([new ArrayBuffer(largeFileSize)], "large-test.txt", { + type: "text/plain", + }); + + // Mock the first call (initial upload request) to verify metadata + global.fetch = jest.fn().mockImplementationOnce(() => { + // We're just testing that the request is made with correct metadata + // Return a promise that we can inspect the call and then reject to avoid complex mocking + return Promise.reject(new Error("Test stopped after metadata check")); + }); + + const mockOptions: UploadOptions = { + cid_version: "v1" as CidVersion, + }; + + try { + await uploadFile(mockConfig, largeFile, "private", mockOptions); + } catch (error) { + // Expected to fail, we just want to check the metadata + } + + // Check that the initial request contains cid_version in Upload-Metadata + expect(global.fetch).toHaveBeenCalledTimes(1); + const initialCall = (global.fetch as jest.Mock).mock.calls[0]; + const uploadMetadata = initialCall[1].headers["Upload-Metadata"]; + + // The metadata should contain base64 encoded cid_version + expect(uploadMetadata).toContain("cid_version"); + expect(uploadMetadata).toContain(btoa("v1")); + }); }); From f4e07277feccf17069a1788cfcb10c1da86e0937 Mon Sep 17 00:00:00 2001 From: iammatthias Date: Wed, 24 Dec 2025 07:50:17 -0800 Subject: [PATCH 2/2] refactor: improve code formatting and enhance uploadFile function to include CID version in metadata --- src/core/classes/uploads/UploadBuilder.ts | 7 +++- src/core/functions/uploads/file.ts | 11 +++++-- tests/uploads/file.test.ts | 40 ++++++++++++++++------- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/core/classes/uploads/UploadBuilder.ts b/src/core/classes/uploads/UploadBuilder.ts index 999eb72..64d6a37 100644 --- a/src/core/classes/uploads/UploadBuilder.ts +++ b/src/core/classes/uploads/UploadBuilder.ts @@ -1,4 +1,9 @@ -import type { PinataConfig, PinataMetadata, UploadOptions, CidVersion } from "../../types"; +import type { + PinataConfig, + PinataMetadata, + UploadOptions, + CidVersion, +} from "../../types"; export class UploadBuilder { private config: PinataConfig | undefined; diff --git a/src/core/functions/uploads/file.ts b/src/core/functions/uploads/file.ts index 3f17a15..e7fb40d 100644 --- a/src/core/functions/uploads/file.ts +++ b/src/core/functions/uploads/file.ts @@ -59,15 +59,22 @@ export const uploadFile = async ( metadata += `,car ${btoa("true")}`; } + if (options?.cid_version !== undefined) { + metadata += `,cid_version ${btoa(options.cid_version)}`; + } + // Build URL with query parameters for chunked uploads let updatedEndpoint: string = `${endpoint}/files`; if (options?.url) { updatedEndpoint = options.url; } - + const requestUrl = new URL(updatedEndpoint); if (options?.cid_version !== undefined) { - requestUrl.searchParams.set('X-Upload-Option-Cid-Version', options.cid_version.toString()); + requestUrl.searchParams.set( + "X-Upload-Option-Cid-Version", + options.cid_version.toString(), + ); } const urlReq = await fetch(requestUrl.toString(), { diff --git a/tests/uploads/file.test.ts b/tests/uploads/file.test.ts index f46e119..b69b0a3 100644 --- a/tests/uploads/file.test.ts +++ b/tests/uploads/file.test.ts @@ -269,25 +269,39 @@ describe("uploadFile function", () => { }); // Mock for CID version 0 - global.fetch = jest.fn() + global.fetch = jest + .fn() .mockResolvedValueOnce({ ok: true, - json: jest.fn().mockResolvedValueOnce({ data: { ...mockResponse, cid: "QmTestVersion0" } }), + json: jest.fn().mockResolvedValueOnce({ + data: { ...mockResponse, cid: "QmTestVersion0" }, + }), }) .mockResolvedValueOnce({ ok: true, - json: jest.fn().mockResolvedValueOnce({ data: { ...mockResponse, cid: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" } }), + json: jest.fn().mockResolvedValueOnce({ + data: { + ...mockResponse, + cid: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + }, + }), }); // Upload with CID version 0 - const result0 = await uploadFile(mockConfig, testFile, "public", { cid_version: "v0" as CidVersion }); - + const result0 = await uploadFile(mockConfig, testFile, "public", { + cid_version: "v0" as CidVersion, + }); + // Upload with CID version 1 - const result1 = await uploadFile(mockConfig, testFile, "public", { cid_version: "v1" as CidVersion }); + const result1 = await uploadFile(mockConfig, testFile, "public", { + cid_version: "v1" as CidVersion, + }); // Verify both uploads expect(result0.cid).toBe("QmTestVersion0"); - expect(result1.cid).toBe("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"); + expect(result1.cid).toBe( + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + ); // Verify fetch was called twice expect(global.fetch).toHaveBeenCalledTimes(2); @@ -330,9 +344,13 @@ describe("uploadFile function", () => { it("should include CID version in metadata for large file upload", async () => { // Create a large file (over 90MB) to trigger chunked upload metadata path const largeFileSize = 94371841; // Just over the threshold - const largeFile = new File([new ArrayBuffer(largeFileSize)], "large-test.txt", { - type: "text/plain", - }); + const largeFile = new File( + [new ArrayBuffer(largeFileSize)], + "large-test.txt", + { + type: "text/plain", + }, + ); // Mock the first call (initial upload request) to verify metadata global.fetch = jest.fn().mockImplementationOnce(() => { @@ -355,7 +373,7 @@ describe("uploadFile function", () => { expect(global.fetch).toHaveBeenCalledTimes(1); const initialCall = (global.fetch as jest.Mock).mock.calls[0]; const uploadMetadata = initialCall[1].headers["Upload-Metadata"]; - + // The metadata should contain base64 encoded cid_version expect(uploadMetadata).toContain("cid_version"); expect(uploadMetadata).toContain(btoa("v1"));