From 0ac9e4af7d67c4431053e80eb2384bf5bdc3f8b3 Mon Sep 17 00:00:00 2001 From: Saad Jutt Date: Tue, 29 Mar 2022 23:27:44 +0500 Subject: [PATCH] feat(drive): GET folder contents API added --- api/public/swagger.yaml | 42 ++++-- api/src/app.ts | 4 +- api/src/controllers/drive.ts | 59 ++++++-- api/src/routes/api/drive.ts | 21 ++- api/src/routes/api/spec/drive.spec.ts | 205 ++++++++++++++++++++++++-- api/src/utils/index.ts | 1 + api/src/utils/setupFolders.ts | 7 + api/src/utils/validation.ts | 5 + restClient/drive.rest | 3 + 9 files changed, 313 insertions(+), 34 deletions(-) create mode 100644 api/src/utils/setupFolders.ts diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index 8367ae1..c3f1787 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -612,7 +612,6 @@ paths: responses: '204': description: 'No content' - description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." summary: 'Get file from SASjs Drive' tags: - Drive @@ -623,19 +622,10 @@ paths: - in: query name: _filePath - required: false + required: true schema: type: string example: /Public/somefolder/some.file - requestBody: - required: false - content: - multipart/form-data: - schema: - type: object - properties: - filePath: - type: string delete: operationId: DeleteFile responses: @@ -765,6 +755,36 @@ paths: type: string required: - file + /SASjsApi/drive/folder: + get: + operationId: GetFolder + responses: + '200': + description: Ok + content: + application/json: + schema: + properties: + folders: {items: {type: string}, type: array} + files: {items: {type: string}, type: array} + required: + - folders + - files + type: object + summary: 'Get folder contents from SASjs Drive' + tags: + - Drive + security: + - + bearerAuth: [] + parameters: + - + in: query + name: _folderPath + required: false + schema: + type: string + example: /Public/somefolder /SASjsApi/drive/filetree: get: operationId: GetFileTree diff --git a/api/src/app.ts b/api/src/app.ts index 0634dca..100b529 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -10,7 +10,8 @@ import { copySASjsCore, getWebBuildFolderPath, loadAppStreamConfig, - setProcessVariables + setProcessVariables, + setupFolders } from './utils' dotenv.config() @@ -42,6 +43,7 @@ const onError: ErrorRequestHandler = (err, req, res, next) => { } export default setProcessVariables().then(async () => { + await setupFolders() await copySASjsCore() // loading these modules after setting up variables due to diff --git a/api/src/controllers/drive.ts b/api/src/controllers/drive.ts index bccd41e..0955b6f 100644 --- a/api/src/controllers/drive.ts +++ b/api/src/controllers/drive.ts @@ -20,7 +20,12 @@ import { fileExists, moveFile, createFolder, - deleteFile as deleteFileOnSystem + deleteFile as deleteFileOnSystem, + folderExists, + listFilesAndSubFoldersInFolder, + listFilesInFolder, + listSubFoldersInFolder, + isFolder } from '@sasjs/utils' import { createFileTree, ExecutionController, getTreeExample } from './internal' @@ -89,9 +94,6 @@ export class DriveController { } /** - * It's optional to either provide `_filePath` in url as query parameter - * Or provide `filePath` in body as form field. - * But it's required to provide else API will respond with Bad Request. * * @summary Get file from SASjs Drive * @query _filePath Location of SAS program @@ -100,11 +102,20 @@ export class DriveController { @Get('/file') public async getFile( @Request() request: express.Request, - - @Query() _filePath?: string, - @FormField() filePath?: string + @Query() _filePath: string ) { - return getFile(request, (_filePath ?? filePath)!) + return getFile(request, _filePath) + } + + /** + * + * @summary Get folder contents from SASjs Drive + * @query _folderPath Location of SAS program + * @example _folderPath "/Public/somefolder" + */ + @Get('/folder') + public async getFolder(@Query() _folderPath?: string) { + return getFolder(_folderPath) } /** @@ -221,7 +232,7 @@ const getFile = async (req: express.Request, filePath: string) => { } if (!(await fileExists(filePathFull))) { - throw new Error('File does not exist.') + throw new Error("File doesn't exist.") } const extension = path.extname(filePathFull).toLowerCase() @@ -232,6 +243,36 @@ const getFile = async (req: express.Request, filePath: string) => { req.res?.sendFile(path.resolve(filePathFull)) } +const getFolder = async (folderPath?: string) => { + const driveFilesPath = getTmpFilesFolderPath() + + if (folderPath) { + const folderPathFull = path + .join(getTmpFilesFolderPath(), folderPath) + .replace(new RegExp('/', 'g'), path.sep) + + if (!folderPathFull.includes(driveFilesPath)) { + throw new Error('Cannot get folder outside drive.') + } + + if (!(await folderExists(folderPathFull))) { + throw new Error("Folder doesn't exist.") + } + + if (!(await isFolder(folderPathFull))) { + throw new Error('Not a Folder.') + } + + const files: string[] = await listFilesInFolder(folderPathFull) + const folders: string[] = await listSubFoldersInFolder(folderPathFull) + return { files, folders } + } + + const files: string[] = await listFilesInFolder(driveFilesPath) + const folders: string[] = await listSubFoldersInFolder(driveFilesPath) + return { files, folders } +} + const deleteFile = async (filePath: string) => { const driveFilesPath = getTmpFilesFolderPath() diff --git a/api/src/routes/api/drive.ts b/api/src/routes/api/drive.ts index db0ce5d..cdd881b 100644 --- a/api/src/routes/api/drive.ts +++ b/api/src/routes/api/drive.ts @@ -8,7 +8,8 @@ import { DriveController } from '../../controllers/' import { deployValidation, fileBodyValidation, - fileParamValidation + fileParamValidation, + folderParamValidation } from '../../utils' const controller = new DriveController() @@ -44,12 +45,24 @@ driveRouter.post('/deploy', async (req, res) => { driveRouter.get('/file', async (req, res) => { const { error: errQ, value: query } = fileParamValidation(req.query) - const { error: errB, value: body } = fileBodyValidation(req.body) - if (errQ && errB) return res.status(400).send(errQ.details[0].message) + if (errQ) return res.status(400).send(errQ.details[0].message) try { - await controller.getFile(req, query._filePath, body.filePath) + await controller.getFile(req, query._filePath) + } catch (err: any) { + res.status(403).send(err.toString()) + } +}) + +driveRouter.get('/folder', async (req, res) => { + const { error: errQ, value: query } = folderParamValidation(req.query) + + if (errQ) return res.status(400).send(errQ.details[0].message) + + try { + const response = await controller.getFolder(query._folderPath) + res.send(response) } catch (err: any) { res.status(403).send(err.toString()) } diff --git a/api/src/routes/api/spec/drive.spec.ts b/api/src/routes/api/spec/drive.spec.ts index d570de6..a236898 100644 --- a/api/src/routes/api/spec/drive.spec.ts +++ b/api/src/routes/api/spec/drive.spec.ts @@ -10,7 +10,9 @@ import { readFile, deleteFolder, generateTimestamp, - copy + copy, + createFolder, + createFile } from '@sasjs/utils' import * as fileUtilModules from '../../../utils/file' @@ -44,7 +46,7 @@ const user = { isActive: true } -describe('files', () => { +describe('drive', () => { let con: Mongoose let mongoServer: MongoMemoryServer const controller = new UserController() @@ -69,6 +71,7 @@ describe('files', () => { await mongoServer.stop() await deleteFolder(tmpFolder) }) + describe('deploy', () => { const shouldFailAssertion = async (payload: any) => { const res = await request(app) @@ -172,17 +175,126 @@ describe('files', () => { await expect(readFile(testJobFile)).resolves.toEqual(exampleService.code) - await deleteFolder(getTmpFilesFolderPath()) + await deleteFolder(path.join(getTmpFilesFolderPath(), 'public')) + }) + }) + + describe('folder', () => { + describe('get', () => { + const getFolderApi = '/SASjsApi/drive/folder' + + it('should get root SAS folder on drive', async () => { + const res = await request(app) + .get(getFolderApi) + .auth(accessToken, { type: 'bearer' }) + + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual({ files: [], folders: [] }) + }) + + it('should get a SAS folder on drive having _folderPath as query param', async () => { + const pathToDrive = fileUtilModules.getTmpFilesFolderPath() + + const dirLevel1 = 'level1' + const dirLevel2 = 'level2' + const fileLevel1 = 'file1' + const fileLevel2 = 'file2' + + await createFolder(path.join(pathToDrive, dirLevel1, dirLevel2)) + await createFile( + path.join(pathToDrive, dirLevel1, fileLevel1), + 'some file content' + ) + await createFile( + path.join(pathToDrive, dirLevel1, dirLevel2, fileLevel2), + 'some file content' + ) + + const res1 = await request(app) + .get(getFolderApi) + .query({ _folderPath: '/' }) + .auth(accessToken, { type: 'bearer' }) + + expect(res1.statusCode).toEqual(200) + expect(res1.body).toEqual({ files: [], folders: [dirLevel1] }) + + const res2 = await request(app) + .get(getFolderApi) + .query({ _folderPath: dirLevel1 }) + .auth(accessToken, { type: 'bearer' }) + + expect(res2.statusCode).toEqual(200) + expect(res2.body).toEqual({ files: [fileLevel1], folders: [dirLevel2] }) + + const res3 = await request(app) + .get(getFolderApi) + .query({ _folderPath: `${dirLevel1}/${dirLevel2}` }) + .auth(accessToken, { type: 'bearer' }) + + expect(res3.statusCode).toEqual(200) + expect(res3.body).toEqual({ files: [fileLevel2], folders: [] }) + }) + + it('should respond with Unauthorized if access token is not present', async () => { + const res = await request(app).get(getFolderApi).expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if folder is not present', async () => { + const res = await request(app) + .get(getFolderApi) + .auth(accessToken, { type: 'bearer' }) + .query({ _folderPath: `/my/path/code-${generateTimestamp()}` }) + .expect(403) + + expect(res.text).toEqual(`Error: Folder doesn't exist.`) + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if folderPath outside Drive', async () => { + const res = await request(app) + .get(getFolderApi) + .auth(accessToken, { type: 'bearer' }) + .query({ _folderPath: '/../path/code.sas' }) + .expect(403) + + expect(res.text).toEqual('Error: Cannot get folder outside drive.') + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if folderPath is of a file', async () => { + const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas') + const filePath = '/my/path/code.sas' + + const pathToCopy = path.join( + fileUtilModules.getTmpFilesFolderPath(), + filePath + ) + await copy(fileToCopyPath, pathToCopy) + + const res = await request(app) + .get(getFolderApi) + .auth(accessToken, { type: 'bearer' }) + .query({ _folderPath: filePath }) + .expect(403) + + expect(res.text).toEqual('Error: Not a Folder.') + expect(res.body).toEqual({}) + }) }) }) describe('file', () => { describe('create', () => { it('should create a SAS file on drive having filePath as form field', async () => { + const pathToUpload = `/my/path/code-1.sas` + const res = await request(app) .post('/SASjsApi/drive/file') .auth(accessToken, { type: 'bearer' }) - .field('filePath', '/my/path/code.sas') + .field('filePath', pathToUpload) .attach('file', path.join(__dirname, 'files', 'sample.sas')) expect(res.statusCode).toEqual(200) @@ -192,10 +304,12 @@ describe('files', () => { }) it('should create a SAS file on drive having _filePath as query param', async () => { + const pathToUpload = `/my/path/code-2.sas` + const res = await request(app) .post('/SASjsApi/drive/file') .auth(accessToken, { type: 'bearer' }) - .query({ _filePath: '/my/path/code1.sas' }) + .query({ _filePath: pathToUpload }) .attach('file', path.join(__dirname, 'files', 'sample.sas')) expect(res.statusCode).toEqual(200) @@ -217,7 +331,7 @@ describe('files', () => { it('should respond with Forbidden if file is already present', async () => { const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas') - const pathToUpload = '/my/path/code.sas' + const pathToUpload = `/my/path/code-${generateTimestamp()}.sas` const pathToCopy = path.join( fileUtilModules.getTmpFilesFolderPath(), @@ -386,7 +500,7 @@ describe('files', () => { const res = await request(app) .patch('/SASjsApi/drive/file') .auth(accessToken, { type: 'bearer' }) - .field('filePath', `/my/path/code-${generateTimestamp()}.sas`) + .field('filePath', `/my/path/code-3.sas`) .attach('file', path.join(__dirname, 'files', 'sample.sas')) .expect(403) @@ -427,9 +541,9 @@ describe('files', () => { const pathToUpload = '/my/path/code.exe' const res = await request(app) - .patch(`/SASjsApi/drive/file?_filePath=${pathToUpload}`) + .patch('/SASjsApi/drive/file') .auth(accessToken, { type: 'bearer' }) - // .field('filePath', pathToUpload) + .query({ _filePath: pathToUpload }) .attach('file', fileToAttachPath) .expect(400) @@ -483,6 +597,79 @@ describe('files', () => { expect(res.body).toEqual({}) }) }) + + describe('get', () => { + it('should get a SAS file on drive having _filePath as query param', async () => { + const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas') + const fileToCopyContent = await readFile(fileToCopyPath) + const filePath = '/my/path/code.sas' + + const pathToCopy = path.join( + fileUtilModules.getTmpFilesFolderPath(), + filePath + ) + await copy(fileToCopyPath, pathToCopy) + + const res = await request(app) + .get('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .query({ _filePath: filePath }) + + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual({}) + expect(res.text).toEqual(fileToCopyContent) + }) + + it('should respond with Unauthorized if access token is not present', async () => { + const res = await request(app).get('/SASjsApi/drive/file').expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if file is not present', async () => { + const res = await request(app) + .get('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .query({ _filePath: `/my/path/code-4.sas` }) + .expect(403) + + expect(res.text).toEqual(`Error: File doesn't exist.`) + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if filePath outside Drive', async () => { + const res = await request(app) + .get('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .query({ _filePath: '/../path/code.sas' }) + .expect(403) + + expect(res.text).toEqual('Error: Cannot get file outside drive.') + expect(res.body).toEqual({}) + }) + + it("should respond with Bad Request if filePath doesn't has correct extension", async () => { + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .query({ _filePath: '/my/path/code.exe' }) + .expect(400) + + expect(res.text).toEqual('Invalid file extension') + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if filePath is missing', async () => { + const res = await request(app) + .post('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .expect(400) + + expect(res.text).toEqual(`"_filePath" is required`) + expect(res.body).toEqual({}) + }) + }) }) }) diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts index 80b3580..59ae5e6 100644 --- a/api/src/utils/index.ts +++ b/api/src/utils/index.ts @@ -13,6 +13,7 @@ export * from './parseLogToArray' export * from './removeTokensInDB' export * from './saveTokensInDB' export * from './setProcessVariables' +export * from './setupFolders' export * from './sleep' export * from './upload' export * from './validation' diff --git a/api/src/utils/setupFolders.ts b/api/src/utils/setupFolders.ts new file mode 100644 index 0000000..cfe0872 --- /dev/null +++ b/api/src/utils/setupFolders.ts @@ -0,0 +1,7 @@ +import { createFolder } from '@sasjs/utils' +import { getTmpFilesFolderPath } from './file' + +export const setupFolders = async () => { + const drivePath = getTmpFilesFolderPath() + await createFolder(drivePath) +} diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index c33fcbe..5f4c23c 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -98,6 +98,11 @@ export const fileParamValidation = (data: any): Joi.ValidationResult => _filePath: filePathSchema }).validate(data) +export const folderParamValidation = (data: any): Joi.ValidationResult => + Joi.object({ + _folderPath: Joi.string() + }).validate(data) + export const runSASValidation = (data: any): Joi.ValidationResult => Joi.object({ code: Joi.string().required() diff --git a/restClient/drive.rest b/restClient/drive.rest index 75315f1..e351fed 100644 --- a/restClient/drive.rest +++ b/restClient/drive.rest @@ -1,3 +1,6 @@ +### Get contents of folder +GET http://localhost:5000/SASjsApi/drive/folder?_path=/Public/app/react-seed-app/services/web + ### POST http://localhost:5000/SASjsApi/drive/deploy Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDc2LCJleHAiOjE2MzU4OTA0NzZ9.Cx1F54ILgAUtnkit0Wg1K1YVO2RdNjOnTKdPhUtDm5I