Skip to content

feat: file upload #922

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
[
{
"alias": [],
"command": "data:create:file",
"flagAliases": [],
"flagChars": ["f", "i", "o", "t"],
"flags": ["api-version", "file", "flags-dir", "json", "parent-id", "target-org", "title"],
"plugin": "@salesforce/plugin-data"
},
{
"alias": ["force:data:record:create"],
"command": "data:create:record",
Expand Down
53 changes: 53 additions & 0 deletions messages/data.create.file.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# summary

Upload a local file to an org.

# description

This command always creates a new file in the org; you can't update an existing file. After a successful upload, the command displays the ID of the new ContentDocument record which represents the uploaded file.

By default, the uploaded file isn't attached to a record; in the Salesforce UI the file shows up in the Files tab. You can optionally attach the file to an existing record, such as an account, as long as you know its record ID.

You can also give the file a new name after it's been uploaded; by default its name in the org is the same as the local file name.

# flags.title.summary

New title given to the file (ContentDocument) after it's uploaded.

# examples

- Upload the local file "resources/astro.png" to your default org:

<%= config.bin %> <%= command.id %> --file resources/astro.png

- Give the file a different filename after it's uploaded to the org with alias "my-scratch":

<%= config.bin %> <%= command.id %> --file resources/astro.png --title AstroOnABoat.png --target-org my-scratch

- Attach the file to a record in the org:

<%= config.bin %> <%= command.id %> --file path/to/astro.png --parent-id a03fakeLoJWPIA3

# flags.file.summary

Path of file to upload.

# flags.parent-id.summary

ID of the record to attach the file to.

# createSuccess

Created file with ContentDocumentId %s.

# attachSuccess

File attached to record with ID %s.

# attachFailure

The file was successfully uploaded, but we weren't able to attach it to the record.

# insufficientAccessActions

- Check that the record ID is correct and that you have access to it.
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"longDescription": "Use the data commands to manipulate records in your org. Commands are available to help you work with various APIs. Import CSV files with the Bulk API V2. Export and import data with the SObject Tree Save API. Perform simple CRUD operations on individual records with the REST API.",
"subtopics": {
"create": {
"description": "Create a record."
"description": "Create a record or a file."
},
"delete": {
"description": "Delete a single record or multiple records in bulk."
Expand All @@ -70,7 +70,8 @@
"upsert": {
"description": "Upsert many records."
}
}
},
"external": true
}
},
"flexibleTaxonomy": true,
Expand Down Expand Up @@ -115,13 +116,15 @@
"chalk": "^5.3.0",
"change-case": "^5.4.4",
"csv-parse": "^4.16.3",
"csv-stringify": "^6.4.6"
"csv-stringify": "^6.4.6",
"form-data": "^4.0.0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3PP approved?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used by jsforce

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, yes.

},
"devDependencies": {
"@oclif/plugin-command-snapshot": "^5.1.9",
"@salesforce/cli-plugins-testkit": "^5.3.4",
"@salesforce/dev-scripts": "^9.1.1",
"@salesforce/plugin-command-reference": "^3.0.83",
"@salesforce/types": "^1.1.0",
"eslint-plugin-sf-plugin": "^1.18.3",
"oclif": "^4.10.2",
"ts-node": "^10.9.2",
Expand Down
4 changes: 2 additions & 2 deletions src/api/data/tree/exportApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ export type ExportConfig = {
outputDir?: string;
plan?: boolean;
prefix?: string;
}
};

type ParentRef = {
id: string;
fieldName: string;
}
};

/**
* Exports data from an org into sObject tree format.
Expand Down
6 changes: 3 additions & 3 deletions src/api/data/tree/importApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,19 @@ type DataImportComponents = {
refMap: Map<string, string>;
filepath: string;
contentType?: string;
}
};

export type ImportConfig = {
contentType?: string;
sobjectTreeFiles?: string[];
plan?: string;
}
};

type RequestMeta = {
refRegex: RegExp;
isJson: boolean;
headers: Dictionary;
}
};

/**
* Imports data into an org that was exported to files using the export API.
Expand Down
4 changes: 2 additions & 2 deletions src/api/data/tree/importTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ type TreeResponseError = {
export type ResponseRefs = {
referenceId: string;
id: string;
}
};
export type ImportResults = {
responseRefs?: ResponseRefs[];
sobjectTypes?: Dictionary;
errors?: string[];
}
};

export type ImportResult = {
refId: string;
Expand Down
48 changes: 48 additions & 0 deletions src/api/file/fileToContentVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { Connection } from '@salesforce/core';
import { Record, SaveResult } from '@jsforce/jsforce-node';
import FormData from 'form-data';

export type ContentVersion = {
Title: string;
FileExtension: string;
VersionData: string;
/** this could be undefined outside of our narrow use case (created files) */
ContentDocumentId: string;
} & Record;

type ContentVersionCreateRequest = {
PathOnClient: string;
Title?: string;
};

export async function file2CV(conn: Connection, filepath: string, title?: string): Promise<ContentVersion> {
const req: ContentVersionCreateRequest = {
PathOnClient: filepath,
Title: title,
};

const form = new FormData();
form.append('VersionData', await readFile(filepath), { filename: title ?? basename(filepath) });
form.append('entity_content', JSON.stringify(req), { contentType: 'application/json' });

// POST the multipart form to Salesforce's API, can't use the normal "create" action because it doesn't support multipart
const CV = await conn.request<SaveResult>({
url: '/sobjects/ContentVersion',
headers: { ...form.getHeaders() },
body: form.getBuffer(),
method: 'POST',
});

return conn.singleRecordQuery<ContentVersion>(
`Select Id, ContentDocumentId, Title, FileExtension from ContentVersion where Id='${CV.id}'`
);
}
84 changes: 84 additions & 0 deletions src/commands/data/create/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages, SfError } from '@salesforce/core';
import { ContentVersion, file2CV } from '../../../api/file/fileToContentVersion.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-data', 'data.create.file');

type CDLCreateRequest = {
ContentDocumentId: string;
LinkedEntityId: string;
ShareType: string;
};

export default class DataCreateFile extends SfCommand<ContentVersion> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');

public static readonly flags = {
'target-org': Flags.requiredOrg(),
'api-version': Flags.orgApiVersion(),
title: Flags.string({
summary: messages.getMessage('flags.title.summary'),
char: 't',
required: false,
}),
file: Flags.file({
summary: messages.getMessage('flags.file.summary'),
char: 'f',
required: true,
exists: true,
}),
// it really could be most any valid ID
// eslint-disable-next-line sf-plugin/id-flag-suggestions
'parent-id': Flags.salesforceId({
summary: messages.getMessage('flags.parent-id.summary'),
char: 'i',
length: 'both',
}),
};

public async run(): Promise<ContentVersion> {
const { flags } = await this.parse(DataCreateFile);
const conn = flags['target-org'].getConnection(flags['api-version']);
const cv = await file2CV(conn, flags.file, flags.title);
this.logSuccess(messages.getMessage('createSuccess', [cv.ContentDocumentId]));

if (!flags['parent-id']) {
return cv;
}

const CDLReq = {
ContentDocumentId: cv.ContentDocumentId,
LinkedEntityId: flags['parent-id'],
ShareType: 'V',
} satisfies CDLCreateRequest;
try {
const CDLCreateResult = await conn.sobject('ContentDocumentLink').create(CDLReq);

if (CDLCreateResult.success) {
this.logSuccess(messages.getMessage('attachSuccess', [flags['parent-id']]));
return cv;
} else {
throw SfError.create({
message: messages.getMessage('attachFailure'),
data: CDLCreateResult.errors,
});
}
} catch (e) {
const error = SfError.wrap(e);
if (error.name === 'INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY') {
error.actions = messages.getMessages('insufficientAccessActions');
}
throw error;
}
}
}
4 changes: 2 additions & 2 deletions src/dataSoqlQueryTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type Field = {
name: string;
fields?: Field[];
alias?: Optional<string>;
}
};

/**
* Type to define SoqlQuery results
Expand Down Expand Up @@ -55,7 +55,7 @@ export type DataPlanPart = {
saveRefs: boolean;
resolveRefs: boolean;
files: Array<string | (DataPlanPart & { file: string })>;
}
};

export type SObjectTreeFileContents = {
records: SObjectTreeInput[];
Expand Down
2 changes: 1 addition & 1 deletion src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export type ExportConfig = {
prefix?: string;
conn: Connection;
ux: Ux;
}
};

/** refFromIdByType.get('account').get(someAccountId) => AccountRef1 */
export type RefFromIdByType = Map<string, Map<string, string>>;
Expand Down
3 changes: 1 addition & 2 deletions test/api/data/tree/exportApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

import fs from 'node:fs';


import sinon from 'sinon';
import { Connection, Messages, Org } from '@salesforce/core';
import { AnyJson } from '@salesforce/ts-types';
Expand Down Expand Up @@ -222,7 +221,7 @@ function deepClone(obj: AnyJson) {
describe('Export API', () => {
const sandbox = sinon.createSandbox();

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url)
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-data', 'exportApi');
const testUsername = 'user@my.test';

Expand Down
Loading