diff --git a/packages/backend/package.json b/packages/backend/package.json index 2686d597..e16585a7 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -69,6 +69,7 @@ "prettier": "^2.5.1", "raw-body": "^2.5.2", "showdown": "^2.1.0", + "slugify": "^1.6.6", "uuid": "^9.0.1", "winston": "^3.7.1", "xmlrpc": "^1.3.2" diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.js b/packages/backend/src/controllers/api/v1/flows/export-flow.js new file mode 100644 index 00000000..d3446dd9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.js @@ -0,0 +1,9 @@ +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findById(request.params.flowId) + .throwIfNotFound(); + + const { exportedFlowAsString, slug } = await flow.export(); + + response.status(201).attachment(slug).send(exportedFlowAsString); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js new file mode 100644 index 00000000..1f72ade9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import exportFlowMock from '../../../../../test/mocks/rest/api/v1/flows/export-flow.js'; + +describe('POST /api/v1/flows/:flowId/export', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should export the flow data of the current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} deneme`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/flows/${currentUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + // Test headers for file attachment + expect(response.headers['content-disposition']).toContain( + 'attachment; filename="name-your-flow.json"' + ); + expect(response.headers['content-type']).toBe( + 'application/json; charset=utf-8' + ); + + const expectedFileStructure = await exportFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedFileStructure); + }); + + it('should export the flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${anotherUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} deneme`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + expect(response.headers['content-disposition']).toStrictEqual( + 'attachment; filename="name-your-flow.json"' + ); + expect(response.headers['content-type']).toStrictEqual( + 'application/json; charset=utf-8' + ); + + const expectedFileStructure = await exportFlowMock(anotherUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedFileStructure); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/export') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index c9f6329f..13718283 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -113,6 +113,10 @@ const authorizationList = { action: 'create', subject: 'Flow', }, + 'POST /api/v1/flows/:flowId/export': { + action: 'update', + subject: 'Flow', + }, 'POST /api/v1/flows/:flowId/steps': { action: 'update', subject: 'Flow', diff --git a/packages/backend/src/helpers/export-flow.js b/packages/backend/src/helpers/export-flow.js index 9c4f6986..05b238dc 100644 --- a/packages/backend/src/helpers/export-flow.js +++ b/packages/backend/src/helpers/export-flow.js @@ -23,7 +23,6 @@ const exportFlow = async (flow) => { })), }; - console.log(JSON.stringify(exportedFlow, null, 2)); return exportedFlow; }; diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 56744396..e9d32811 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -1,4 +1,5 @@ import { ValidationError } from 'objection'; +import slugify from 'slugify'; import Base from './base.js'; import Step from './step.js'; import User from './user.js'; @@ -7,6 +8,7 @@ import ExecutionStep from './execution-step.js'; import globalVariable from '../helpers/global-variable.js'; import logger from '../helpers/logger.js'; import Telemetry from '../helpers/telemetry/index.js'; +import exportFlow from '../helpers/export-flow.js'; import flowQueue from '../queues/flow.js'; import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, @@ -426,6 +428,24 @@ class Flow extends Base { } } + slugifyNameAsFilename() { + const slug = slugify(this.name, { + lower: true, + strict: true, + replacement: '-', + }); + + return `${slug}.json`; + } + + async export() { + const exportedFlow = await exportFlow(this); + const exportedFlowAsString = JSON.stringify(exportedFlow, null, 2); + const slug = this.slugifyNameAsFilename(); + + return { exportedFlowAsString, slug }; + } + async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js index 7faefa17..0329d6b6 100644 --- a/packages/backend/src/models/flow.test.js +++ b/packages/backend/src/models/flow.test.js @@ -10,6 +10,7 @@ import { createFlow } from '../../test/factories/flow.js'; import { createStep } from '../../test/factories/step.js'; import { createExecution } from '../../test/factories/execution.js'; import { createExecutionStep } from '../../test/factories/execution-step.js'; +import * as exportFlow from '../helpers/export-flow.js'; describe('Flow model', () => { it('tableName should return correct name', () => { @@ -506,6 +507,69 @@ describe('Flow model', () => { }); }); + describe('slugifyNameAsFilename', () => { + it('should generate a slug file name from flow name', async () => { + const flow = await createFlow({ + name: 'My Flow Name', + }); + + const slug = flow.slugifyNameAsFilename(); + expect(slug).toBe('my-flow-name.json'); + }); + }); + + describe('export', () => { + it('should call slugifyNameAsFilename method', async () => { + const flow = await createFlow({ + name: 'My Flow Name', + }); + + const slugifyNameAsFilenameSpy = vi + .spyOn(flow, 'slugifyNameAsFilename') + .mockImplementation(() => 'my-flow-name.json'); + + await flow.export(); + + expect(slugifyNameAsFilenameSpy).toHaveBeenCalledOnce(); + }); + + it('should call exportFlow method', async () => { + const flow = await createFlow(); + + const exportFlowSpy = vi + .spyOn(exportFlow, 'default') + .mockImplementation(() => {}); + + await flow.export(); + + expect(exportFlowSpy).toHaveBeenCalledOnce(); + }); + + it('should return exportedFlowAsString and slug', async () => { + const flow = await createFlow(); + + const exportedFlowAsString = { + name: 'My Flow Name', + }; + + const slug = 'slug'; + + vi.spyOn(exportFlow, 'default').mockReturnValue(exportedFlowAsString); + vi.spyOn(flow, 'slugifyNameAsFilename').mockReturnValue(slug); + + const expectedExportedFlowAsString = JSON.stringify( + exportedFlowAsString, + null, + 2 + ); + + expect(await flow.export()).toStrictEqual({ + exportedFlowAsString: expectedExportedFlowAsString, + slug: 'slug', + }); + }); + }); + describe('throwIfHavingLessThanTwoSteps', () => { it('should throw validation error with less than two steps', async () => { const flow = await createFlow(); diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js index 8b507b82..10b19e74 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -9,6 +9,7 @@ import createFlowAction from '../../../controllers/api/v1/flows/create-flow.js'; import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js'; import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js'; +import exportFlowAction from '../../../controllers/api/v1/flows/export-flow.js'; const router = Router(); @@ -17,6 +18,13 @@ router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction); router.post('/', authenticateUser, authorizeUser, createFlowAction); router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); +router.post( + '/:flowId/export', + authenticateUser, + authorizeUser, + exportFlowAction +); + router.patch( '/:flowId/status', authenticateUser, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js new file mode 100644 index 00000000..e235a6c7 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js @@ -0,0 +1,32 @@ +import { expect } from 'vitest'; + +const duplicateFlowMock = async (flow, steps = []) => { + const data = { + id: expect.any(String), + name: flow.name, + }; + + if (steps.length) { + data.steps = steps.map((step) => { + const computedStep = { + id: expect.any(String), + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: expect.any(Object), + position: step.position, + }; + + if (step.type === 'trigger') { + computedStep.webhookPath = expect.stringContaining('/webhooks/flows/'); + } + + return computedStep; + }); + } + + return data; +}; + +export default duplicateFlowMock; diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock index bf899f12..184e2e40 100644 --- a/packages/backend/yarn.lock +++ b/packages/backend/yarn.lock @@ -4177,6 +4177,11 @@ simple-update-notifier@^1.0.7: dependencies: semver "~7.0.0" +slugify@^1.6.6: + version "1.6.6" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" + integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" @@ -4261,16 +4266,7 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4302,14 +4298,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==