diff --git a/flake.nix b/flake.nix index 73b9488..f1627fd 100644 --- a/flake.nix +++ b/flake.nix @@ -42,7 +42,7 @@ deno nixpkgs-fmt # can't use slim here as long as we still publish with npm. - # TODO(@joscha) change this once we only publish to jsr + # TODO(@joscha) change this once we only publish to jsr only nodejs_20 # For openapi-generator # If more dependencies are needed, investigate whether to load https://github.com/OpenAPITools/openapi-generator/blob/master/flake.nix diff --git a/package.json b/package.json index bec163d..bfa5b3f 100644 --- a/package.json +++ b/package.json @@ -14,5 +14,8 @@ "author": "Planet A Ventures ", "license": "MIT", "private": true, - "type": "module" + "type": "module", + "engines": { + "node": ">=20" + } } diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index 8a65863..25323a9 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -54,6 +54,9 @@ await build({ access: 'public', }, keywords: ['affinity', 'crm', 'node', 'api', 'sdk'], + engines: { + node: '>=20', + }, }, postBuild() { // steps to run after building and before running the tests diff --git a/src/v1/entity_files.ts b/src/v1/entity_files.ts index cb9275a..34b5c9a 100644 --- a/src/v1/entity_files.ts +++ b/src/v1/entity_files.ts @@ -1,5 +1,7 @@ import { assert } from '@std/assert' import type { AxiosInstance } from 'axios' +import fs from 'node:fs' +import path from 'node:path' import type { Readable } from 'node:stream' import { defaultTransformers } from './axios_default_transformers.ts' import { createSearchIteratorFn } from './create_search_iterator_fn.ts' @@ -63,9 +65,11 @@ export type PagedEntityFileResponse = Replace +export type SupportedFileType = File | string + export type UploadEntityFileRequest = & { - files: File[] + files: SupportedFileType[] } & RequireOnlyOne @@ -188,18 +192,9 @@ export class EntityFiles { const formData = new FormData() const { files } = params assert(files.length, 'At least one file must be provided') - if (files.length === 1) { - // Append the file as 'file' if only one file is provided - // it's a bit odd that the Affinity API expects the file to be sent in a different - // parameter, but maybe there is an implementation detail that treats multiple files - // differently to a single one, so we're complying with the API here - const [file] = files - formData.append('file', file, file.name) - } else { - files.forEach((file) => { - formData.append('files[]', file, file.name) - }) - } + + await Promise.all(files.map((file) => appendToFormData(formData, file))) + if (params.person_id) { formData.append('person_id', params.person_id.toString()) } else if (params.organization_id) { @@ -227,3 +222,32 @@ export class EntityFiles { return response.data.success === true } } + +function isFile(file: unknown): file is File { + return file instanceof File +} + +async function appendToFormData(formData: FormData, file: SupportedFileType) { + if (typeof file === 'string') { + formData.append('files[]', await openAsBlob(file), path.basename(file)) + } else if (isFile(file)) { + formData.append('files[]', file) + } else { + throw new Error('Unsupported file type') + } +} + +// TODO(@joscha): replace with `import { openAsBlob } from "node:fs";` ASAP +function openAsBlob(filePath: string): Promise { + return new Promise((resolve, reject) => { + const fileStream = fs.createReadStream(filePath) + const chunks: (string | ArrayBuffer)[] = [] + fileStream.on('data', (chunk) => { + chunks.push(typeof chunk === 'string' ? chunk : chunk.buffer) + }) + fileStream.on('end', () => { + resolve(new Blob(chunks)) + }) + fileStream.on('error', reject) + }) +} diff --git a/src/v1/tests/__snapshots__/entity_files_test.ts.snap b/src/v1/tests/__snapshots__/entity_files_test.ts.snap index c46efcf..46d718d 100644 --- a/src/v1/tests/__snapshots__/entity_files_test.ts.snap +++ b/src/v1/tests/__snapshots__/entity_files_test.ts.snap @@ -41,13 +41,28 @@ snapshot[`entityFiles > can get a single file 1`] = ` } `; +snapshot[`entityFiles > can upload files from path 1`] = ` +{ + "files[]": [ + File { + name: "test.pdf", + size: 16374, + type: "", + }, + ], + person_id: "170614434", +} +`; + snapshot[`entityFiles > can upload a file 1`] = ` { - file: File { - name: "test.pdf", - size: 16374, - type: "", - }, + "files[]": [ + File { + name: "test.pdf", + size: 16374, + type: "", + }, + ], person_id: "170614434", } `; diff --git a/src/v1/tests/entity_files_test.ts b/src/v1/tests/entity_files_test.ts index 3a1ff25..5e9b963 100644 --- a/src/v1/tests/entity_files_test.ts +++ b/src/v1/tests/entity_files_test.ts @@ -11,6 +11,10 @@ import { Affinity } from '../index.ts' import { entityFilesUrl } from '../urls.ts' import { apiKey, isLiveRun } from './env.ts' import { getRawFixture, readFixtureFile } from './get_raw_fixture.ts' +import * as path from '@std/path' +import fs from 'node:fs' + +const __dirname = path.dirname(path.fromFileUrl(import.meta.url)) const multipartFormDataHeaderMatcher = { asymmetricMatch: (headers: Record) => { @@ -67,6 +71,32 @@ describe('entityFiles', () => { await assertSnapshot(t, res) }) + it('can upload files from path', async (t) => { + mock + ?.onPost( + entityFilesUrl(), + createSnapshotBodyMatcher(t), + multipartFormDataHeaderMatcher, + ) + .reply( + 200, + { success: true }, + ) + const localPath = path.join( + __dirname, + 'fixtures', + 'entity_files', + 'test.pdf', + ) + const res = await affinity.entityFiles.upload({ + person_id: 170614434, + files: [ + localPath, + ], + }) + assert(res) + }) + it('can upload a file', async (t) => { mock ?.onPost(