Skip to content

Commit 5012b92

Browse files
authored
Merge pull request #241 from OpenPathfinder/ulises/v1-run-workflow
2 parents e6a8bd7 + e8a63be commit 5012b92

File tree

5 files changed

+297
-26
lines changed

5 files changed

+297
-26
lines changed

__tests__/httpServer/apiV1.test.js

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
// Mocks
2+
jest.mock('../../src/reports', () => ({
3+
generateStaticReports: jest.fn()
4+
}))
5+
6+
const mockWorkflowFn = jest.fn()
7+
jest.mock('../../src/cli/workflows', () => ({
8+
getWorkflowsDetails: jest.fn(() => ({
9+
workflows: {
10+
'test-workflow': {
11+
name: 'test-workflow',
12+
description: 'Test workflow',
13+
workflow: mockWorkflowFn
14+
}
15+
},
16+
workflowsList: [
17+
{ id: 'test-workflow', description: 'Test workflow' }
18+
]
19+
}))
20+
}))
21+
122
const request = require('supertest')
223
const { generateStaticReports } = require('../../src/reports')
324
const knexInit = require('knex')
@@ -6,18 +27,15 @@ const { resetDatabase, initializeStore } = require('../../__utils__')
627
const pkg = require('../../package.json')
728
const serverModule = require('../../src/httpServer')
829
const { dbSettings } = getConfig('test')
9-
const { getAllWorkflows } = require('../../src/cli/workflows')
30+
const { getWorkflowsDetails } = require('../../src/cli/workflows')
31+
const { workflowsList } = getWorkflowsDetails()
32+
1033
let server
1134
let serverStop
1235
let app
1336
let knex
1437
let getAllProjects
1538

16-
// Mocks
17-
jest.mock('../../src/reports', () => ({
18-
generateStaticReports: jest.fn()
19-
}))
20-
2139
beforeAll(async () => {
2240
// Initialize server asynchronously
2341
const serverInstance = serverModule()
@@ -121,12 +139,89 @@ describe('HTTP Server API V1', () => {
121139
const response = await app.get('/api/v1/workflow')
122140

123141
expect(response.status).toBe(200)
124-
expect(response.body).toStrictEqual(getAllWorkflows())
142+
expect(response.body).toStrictEqual(workflowsList)
125143
})
126144

127145
test.todo('should return 500 for internal server error')
128146
})
129147

148+
describe('POST /api/v1/workflow/:id/run', () => {
149+
let workflowSpy
150+
let mockWorkflowFn
151+
152+
beforeEach(() => {
153+
mockWorkflowFn = jest.fn()
154+
workflowSpy = jest.spyOn(require('../../src/cli/workflows'), 'getWorkflowsDetails').mockReturnValue({
155+
workflows: {
156+
'test-workflow': {
157+
name: 'test-workflow',
158+
description: 'Test workflow',
159+
workflow: mockWorkflowFn
160+
}
161+
},
162+
workflowsList: [
163+
{ id: 'test-workflow', description: 'Test workflow' }
164+
]
165+
})
166+
})
167+
168+
afterEach(() => {
169+
workflowSpy.mockRestore()
170+
jest.clearAllMocks()
171+
})
172+
173+
test('should return 202 and run the specified workflow', async () => {
174+
mockWorkflowFn.mockResolvedValueOnce()
175+
const response = await app
176+
.post('/api/v1/workflow/test-workflow/run')
177+
.set('Content-Type', 'application/json')
178+
.send({ some: 'data' })
179+
180+
expect(response.status).toBe(202)
181+
expect(response.body).toHaveProperty('status', 'completed')
182+
expect(response.body.workflow).toMatchObject({
183+
id: 'test-workflow',
184+
description: 'Test workflow'
185+
})
186+
expect(mockWorkflowFn).toHaveBeenCalledWith({ some: 'data' })
187+
})
188+
189+
test('should return 404 for invalid workflow ID', async () => {
190+
// Overwrite the spy to return no workflows
191+
workflowSpy.mockReturnValueOnce({
192+
workflows: {},
193+
workflowsList: []
194+
})
195+
const response = await app
196+
.post('/api/v1/workflow/invalid-workflow/run')
197+
.set('Content-Type', 'application/json')
198+
.send({})
199+
200+
expect(response.status).toBe(404)
201+
expect(response.body).toStrictEqual({ errors: [{ message: 'Workflow not found' }] })
202+
})
203+
204+
test('should return 500 for internal server error', async () => {
205+
mockWorkflowFn.mockRejectedValueOnce(new Error('Something went wrong'))
206+
207+
const response = await app
208+
.post('/api/v1/workflow/test-workflow/run')
209+
.set('Content-Type', 'application/json')
210+
.send({ some: 'data' })
211+
212+
expect(response.status).toBe(500)
213+
expect(response.body.status).toBe('failed')
214+
expect(response.body.workflow).toMatchObject({
215+
id: 'test-workflow',
216+
description: 'Test workflow'
217+
})
218+
expect(response.body.errors[0].message).toMatch(/Failed to run workflow: Something went wrong/)
219+
expect(mockWorkflowFn).toHaveBeenCalledWith({ some: 'data' })
220+
})
221+
222+
test.todo('should return 500 when workflow execution times out')
223+
})
224+
130225
describe('POST /api/v1/generate-reports', () => {
131226
test('should return status completed when report generation succeeds', async () => {
132227
generateStaticReports.mockResolvedValueOnce()

src/cli/workflows.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const inquirer = require('inquirer').default
2+
const _ = require('lodash')
23
const debug = require('debug')('cli:workflows')
34
const { updateGithubOrgs, upsertGithubRepositories, runAllTheComplianceChecks, upsertOSSFScorecardAnalysis } = require('../workflows')
45
const { generateStaticReports } = require('../reports')
@@ -31,10 +32,24 @@ const commandList = [{
3132
workflow: bulkImport
3233
}]
3334

34-
const workflows = commandList.map(({ name, description }) => ({ id: name, description }))
35-
3635
const validCommandNames = commandList.map(({ name }) => name)
3736

37+
const getWorkflowsDetails = () => {
38+
const workflows = {}
39+
const workflowsList = []
40+
41+
commandList.forEach((workflow) => {
42+
const workflowName = _.kebabCase(workflow.name)
43+
workflowsList.push({ id: workflowName, description: workflow.description })
44+
workflows[workflowName] = {
45+
description: workflow.description,
46+
workflow: workflow.workflow
47+
}
48+
})
49+
50+
return { workflows, workflowsList }
51+
}
52+
3853
function listWorkflowCommand (options = {}) {
3954
logger.info('Available workflows:')
4055
commandList.forEach(({ name, description }) => logger.info(`- ${name}: ${description}`))
@@ -72,6 +87,6 @@ async function runWorkflowCommand (knex, options = {}) {
7287

7388
module.exports = {
7489
listWorkflowCommand,
75-
getAllWorkflows: () => workflows,
90+
getWorkflowsDetails,
7691
runWorkflowCommand
7792
}

src/httpServer/routers/apiV1.js

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,28 @@ const { logger } = require('../../utils')
44
const { initializeStore } = require('../../store')
55
const _ = require('lodash')
66
const { isSlug } = require('validator')
7-
const { getAllWorkflows } = require('../../cli/workflows')
7+
const { getWorkflowsDetails } = require('../../cli/workflows')
8+
9+
const HTTP_DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
10+
11+
const runWorkflow = (workflowName, data) => new Promise((resolve, reject) => {
12+
const { workflows } = getWorkflowsDetails()
13+
const workflow = workflows[workflowName]
14+
if (!workflow || typeof workflow.workflow !== 'function') {
15+
return reject(new Error('Invalid Workflow'))
16+
}
17+
// @TODO: This is temporary and ideally we should move this to a queue system
18+
// to avoid blocking the HTTP server and allow long-running workflows
19+
const timeout = setTimeout(() => {
20+
reject(new Error('Workflow default timeout reached'))
21+
}, HTTP_DEFAULT_TIMEOUT)
22+
23+
Promise.resolve()
24+
.then(() => workflow.workflow(data))
25+
.then(() => resolve(workflow))
26+
.catch(err => reject(new Error(`Failed to run workflow: ${err.message}`)))
27+
.finally(() => clearTimeout(timeout))
28+
})
829

930
function createApiRouter (knex, express) {
1031
const { addProject, getProjectByName } = initializeStore(knex)
@@ -45,14 +66,45 @@ function createApiRouter (knex, express) {
4566

4667
router.get('/workflow', (req, res) => {
4768
try {
48-
const workflows = getAllWorkflows()
49-
res.json(workflows)
69+
const { workflowsList } = getWorkflowsDetails()
70+
res.json(workflowsList)
5071
} catch (error) {
5172
logger.error(error)
5273
res.status(500).json({ errors: [{ message: 'Failed to retrieve workflows' }] })
5374
}
5475
})
5576

77+
router.post('/workflow/:id/run', async (req, res) => {
78+
const { id } = req.params
79+
const data = req.body
80+
const { workflows } = getWorkflowsDetails()
81+
82+
if (!id || !isSlug(id)) {
83+
return res.status(400).json({ errors: [{ message: 'Invalid workflow ID' }] })
84+
}
85+
const workflow = workflows[id]
86+
87+
if (!workflow) {
88+
return res.status(404).json({ errors: [{ message: 'Workflow not found' }] })
89+
}
90+
try {
91+
// @TODO: We need to delegate the workflow execution to a worker and provide and endpoint to check the status
92+
// This is a temporary solution to run the workflow within the HTTP timeout
93+
// data validation is done in the workflow itself
94+
const wf = await runWorkflow(id, data)
95+
res.status(202).json({ status: 'completed', workflow: { id, description: wf.description } })
96+
} catch (error) {
97+
logger.error(error)
98+
res.status(500).json({
99+
status: 'failed',
100+
workflow: workflow
101+
? { id, description: workflow.description }
102+
: undefined,
103+
errors: [{ message: `Failed to run workflow: ${error.message}` }]
104+
})
105+
}
106+
})
107+
56108
router.post('/generate-reports', async (req, res) => {
57109
const startTs = new Date().toISOString()
58110
try {

src/httpServer/swagger/api-v1.yml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,82 @@ paths:
7070
schema:
7171
$ref: '#/components/schemas/ErrorResponse'
7272

73+
/api/v1/workflow/{workflowId}/run:
74+
post:
75+
summary: Execute a workflow
76+
description: Executes the specified workflow with the provided input data
77+
operationId: executeWorkflow
78+
tags:
79+
- Workflows
80+
parameters:
81+
- name: workflowId
82+
in: path
83+
required: true
84+
description: The ID of the workflow to execute
85+
schema:
86+
type: string
87+
example: example-workflow
88+
requestBody:
89+
required: true
90+
content:
91+
application/json:
92+
schema:
93+
type: object
94+
# @TODO: Improve this schema to be more specific about the expected input
95+
properties:
96+
data:
97+
oneOf:
98+
- type: object
99+
- type: array
100+
items:
101+
oneOf:
102+
- type: object
103+
- type: string
104+
responses:
105+
'202':
106+
description: Workflow executed successfully
107+
content:
108+
application/json:
109+
schema:
110+
type: object
111+
additionalProperties: false
112+
properties:
113+
status:
114+
type: string
115+
example: completed
116+
workflow:
117+
type: object
118+
properties:
119+
id:
120+
type: string
121+
example: example-workflow
122+
description:
123+
type: string
124+
example: This is an example workflow
125+
required:
126+
- id
127+
- description
128+
required:
129+
- status
130+
- workflow
131+
'400':
132+
description: Bad request, invalid input data
133+
content:
134+
application/json:
135+
schema:
136+
$ref: '#/components/schemas/ErrorResponse'
137+
'404':
138+
description: Workflow not found
139+
content:
140+
application/json:
141+
schema:
142+
$ref: '#/components/schemas/ErrorResponse'
143+
'500':
144+
description: Internal server error
145+
content:
146+
application/json:
147+
schema:
148+
$ref: '#/components/schemas/ErrorResponse'
73149
/api/v1/project:
74150
post:
75151
summary: Create a new project

0 commit comments

Comments
 (0)