diff --git a/.github/workflows/build-unit-tests.yml b/.github/workflows/build-unit-tests.yml index 1efdf42..b0c8e64 100644 --- a/.github/workflows/build-unit-tests.yml +++ b/.github/workflows/build-unit-tests.yml @@ -20,7 +20,16 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - cache: npm + + # 2. Restore npm cache manually + - name: Restore npm cache + uses: actions/cache@v3 + id: npm-cache + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- - name: Check npm audit run: npm audit --production --audit-level=low diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 52dee41..687f4bc 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -20,7 +20,16 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - cache: npm + + # 2. Restore npm cache manually + - name: Restore npm cache + uses: actions/cache@v3 + id: npm-cache + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- - name: Install Dependencies run: npm ci diff --git a/src/SASViyaApiClient.ts b/src/SASViyaApiClient.ts index ad35aea..a8316b9 100644 --- a/src/SASViyaApiClient.ts +++ b/src/SASViyaApiClient.ts @@ -28,6 +28,7 @@ import { uploadTables } from './api/viya/uploadTables' import { executeOnComputeApi } from './api/viya/executeOnComputeApi' import { getAccessTokenForViya } from './auth/getAccessTokenForViya' import { refreshTokensForViya } from './auth/refreshTokensForViya' +import { FileResource } from './types/FileResource' interface JobExecutionResult { result?: { result: object } @@ -311,6 +312,84 @@ export class SASViyaApiClient { ) } + /** + * Fetches the file content for a file in the specified folder. + * + * @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2 + * @param fileName - the name of the file in the `folderPath` + * @param accessToken - an access token for authorizing the request + */ + public async getFileContent( + folderPath: string, + fileName: string, + accessToken?: string + ) { + const fileUri = await this.getFileUri( + folderPath, + fileName, + accessToken + ).catch((err) => { + throw prefixMessage( + err, + `Error while getting file URI for: ${fileName} in folder: ${folderPath}. ` + ) + }) + + return await this.requestClient + .get(`${this.serverUrl}${fileUri}/content`, accessToken) + .then((res) => res.result) + } + + /** + * Updates the file content for a file in the specified folder. + * + * @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2 + * @param fileName - the name of the file in the `folderPath` + * @param content - the new content to be written to the file + * @param accessToken - an access token for authorizing the request + */ + public async updateFileContent( + folderPath: string, + fileName: string, + content: string, + accessToken?: string + ) { + const fileUri = await this.getFileUri( + folderPath, + fileName, + accessToken + ).catch((err) => { + throw prefixMessage( + err, + `Error while getting file URI for: ${fileName} in folder: ${folderPath}. ` + ) + }) + + // Fetch the file resource details to get the Etag and content type + const { result: originalFileResource, etag } = + await this.requestClient.get( + `${this.serverUrl}${fileUri}`, + accessToken + ) + + if (!originalFileResource || !etag) + throw new Error( + `File ${fileName} does not have an ETag, or request failed.` + ) + + return await this.requestClient + .put( + `${this.serverUrl}${fileUri}/content`, + content, + accessToken, + { + 'If-Match': etag, + 'Content-Type': originalFileResource.contentType + } + ) + .then((res) => res.result) + } + /** * Fetches a folder. Path to the folder is required. * @param folderPath - the absolute path to the folder. @@ -941,6 +1020,7 @@ export class SASViyaApiClient { }) if (!folder) return undefined + return folder } @@ -952,6 +1032,30 @@ export class SASViyaApiClient { return `/folders/folders/${folderDetails.id}` } + private async getFileUri( + folderPath: string, + fileName: string, + accessToken?: string + ): Promise { + const folderMembers = await this.listFolder(folderPath, accessToken, 1000, { + returnDetails: true + }).catch((err) => { + throw prefixMessage(err, `Error while listing folder: ${folderPath}. `) + }) + + if (!folderMembers || !folderMembers.length) + throw new Error(`No members found in folder: ${folderPath}`) + + const fileUri = folderMembers.find( + (member) => member.name === fileName + )?.uri + + if (!fileUri) + throw new Error(`File ${fileName} not found in folder: ${folderPath}`) + + return fileUri + } + private async getRecycleBinUri(accessToken?: string) { const url = '/folders/folders/@myRecycleBin' @@ -999,14 +1103,19 @@ export class SASViyaApiClient { } /** - * Lists children folders for given Viya folder. + * Lists children folders/files for given Viya folder. * @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request. * @param accessToken - an access token for authorizing the request. + * @param {Object} [options] - Additional options. + * @param {boolean} [options.returnDetails=false] - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names. */ public async listFolder( sourceFolder: string, accessToken?: string, - limit: number = 20 + limit: number = 20, + options?: { + returnDetails?: boolean + } ) { // checks if 'sourceFolder' is already a URI const sourceFolderUri = isUri(sourceFolder) @@ -1018,11 +1127,20 @@ export class SASViyaApiClient { accessToken ) + let membersToReturn = [] + if (members && members.items) { - return members.items.map((item: any) => item.name) - } else { - return [] + // If returnDetails is true, return full member details + if (options?.returnDetails) { + membersToReturn = members.items + } else { + // If returnDetails is false, return only member names + membersToReturn = members.items.map((item: any) => item.name) + } } + + // Return members without Etag + return membersToReturn } /** diff --git a/src/SASjs.ts b/src/SASjs.ts index 5a4a5c2..33f05d1 100644 --- a/src/SASjs.ts +++ b/src/SASjs.ts @@ -411,6 +411,51 @@ export default class SASjs { ) } + /** + * Fetches the file content for a file in the specified folder. + * + * @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2 + * @param fileName - the name of the file in the `folderPath` + * @param accessToken - an access token for authorizing the request + */ + public async getFileContent( + folderPath: string, + fileName: string, + accessToken?: string + ) { + this.isMethodSupported('getFileContent', [ServerType.SasViya]) + + return await this.sasViyaApiClient!.getFileContent( + folderPath, + fileName, + accessToken + ) + } + + /** + * Updates the file content for a file in the specified folder. + * + * @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2 + * @param fileName - the name of the file in the `folderPath` + * @param content - the new content to be written to the file + * @param accessToken - an access token for authorizing the request + */ + public async updateFileContent( + folderPath: string, + fileName: string, + content: string, + accessToken?: string + ) { + this.isMethodSupported('updateFileContent', [ServerType.SasViya]) + + return await this.sasViyaApiClient!.updateFileContent( + folderPath, + fileName, + content, + accessToken + ) + } + /** * Fetches a folder from the SAS file system. * @param folderPath - path of the folder to be fetched. @@ -436,18 +481,23 @@ export default class SASjs { * Lists children folders for given Viya folder. * @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request. * @param accessToken - an access token for authorizing the request. + * @param returnDetails - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names. */ public async listFolder( sourceFolder: string, accessToken?: string, - limit?: number + limit?: number, + returnDetails = false ) { this.isMethodSupported('listFolder', [ServerType.SasViya]) return await this.sasViyaApiClient?.listFolder( sourceFolder, accessToken, - limit + limit, + { + returnDetails + } ) } diff --git a/src/types/FileResource.ts b/src/types/FileResource.ts new file mode 100644 index 0000000..5b1bd63 --- /dev/null +++ b/src/types/FileResource.ts @@ -0,0 +1,33 @@ +export interface FileResource { + creationTimeStamp: string + modifiedTimeStamp: string + createdBy: string + modifiedBy: string + id: string + properties: Properties + contentDisposition: string + contentType: string + encoding: string + links: Link[] + name: string + size: number + searchable: boolean + fileStatus: string + fileVersion: number + typeDefName: string + version: number + virusDetected: boolean + urlDetected: boolean + quarantine: boolean +} + +export interface Link { + method: string + rel: string + href: string + uri: string + type?: string + responseType?: string +} + +export interface Properties {}