From ffcf193b87d811b166d79af74013776a253b50b0 Mon Sep 17 00:00:00 2001 From: Yury Date: Tue, 29 Oct 2024 11:18:04 +0300 Subject: [PATCH] feat(code): added code/trigger API endpoint --- api/.env.example | 2 +- api/public/swagger.yaml | 60 ++++++++++++++++- api/src/controllers/code.ts | 89 ++++++++++++++++++++++++- api/src/controllers/internal/Session.ts | 32 ++++++--- api/src/routes/api/code.ts | 20 +++++- api/src/types/Session.ts | 1 + api/src/utils/validation.ts | 7 ++ 7 files changed, 194 insertions(+), 17 deletions(-) diff --git a/api/.env.example b/api/.env.example index d59f43e4..1804918f 100644 --- a/api/.env.example +++ b/api/.env.example @@ -25,7 +25,7 @@ LDAP_USERS_BASE_DN = LDAP_GROUPS_BASE_DN = #default value is 100 -MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100 +MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100 #default value is 10 MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10 diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index dc73202a..04dacd39 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -109,6 +109,36 @@ components: - runTime type: object additionalProperties: false + TriggerCodeResponse: + properties: + sessionId: + type: string + description: "Session ID (SAS WORK folder) used to execute code.\nThis session ID should be used to poll job status." + example: '{ sessionId: ''20241028074744-54132-1730101664824'' }' + required: + - sessionId + type: object + additionalProperties: false + TriggerCodePayload: + properties: + code: + type: string + description: 'Code of program' + example: '* Code HERE;' + runTime: + $ref: '#/components/schemas/RunTimeType' + description: 'runtime for program' + example: sas + expiresAfterMins: + type: number + format: double + description: "Amount of minutes after the completion of the job when the session must be\ndestroyed." + example: 15 + required: + - code + - runTime + type: object + additionalProperties: false MemberType.folder: enum: - folder @@ -805,6 +835,30 @@ paths: application/json: schema: $ref: '#/components/schemas/ExecuteCodePayload' + /SASjsApi/code/trigger: + post: + operationId: TriggerCode + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerCodeResponse' + description: 'Trigger Code on the Specified Runtime' + summary: 'Trigger Code and Return Session Id not awaiting for the job completion' + tags: + - Code + security: + - + bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerCodePayload' /SASjsApi/drive/deploy: post: operationId: Deploy @@ -1789,7 +1843,7 @@ paths: anyOf: - {type: string} - {type: string, format: byte} - description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms" + description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts additional URL parameters (converted to session variables)\nand file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms" summary: 'Execute a Stored Program, returns _webout and (optionally) log.' tags: - STP @@ -1798,7 +1852,7 @@ paths: bearerAuth: [] parameters: - - description: 'Location of the Stored Program in SASjs Drive' + description: 'Location of code in SASjs Drive' in: query name: _program required: true @@ -1806,7 +1860,7 @@ paths: type: string example: /Projects/myApp/some/program - - description: 'Optional query param for setting debug mode (returns the session log in the response body)' + description: 'Optional query param for setting debug mode, which will return the session log.' in: query name: _debug required: false diff --git a/api/src/controllers/code.ts b/api/src/controllers/code.ts index 72fa376d..11912d9e 100644 --- a/api/src/controllers/code.ts +++ b/api/src/controllers/code.ts @@ -1,11 +1,10 @@ import express from 'express' import { Request, Security, Route, Tags, Post, Body } from 'tsoa' -import { ExecutionController } from './internal' +import { ExecutionController, getSessionController } from './internal' import { getPreProgramVariables, getUserAutoExec, ModeType, - parseLogToArray, RunTimeType } from '../utils' @@ -22,6 +21,34 @@ interface ExecuteCodePayload { runTime: RunTimeType } +interface TriggerCodePayload { + /** + * Code of program + * @example "* Code HERE;" + */ + code: string + /** + * runtime for program + * @example "sas" + */ + runTime: RunTimeType + /** + * Amount of minutes after the completion of the job when the session must be + * destroyed. + * @example 15 + */ + expiresAfterMins?: number +} + +interface TriggerCodeResponse { + /** + * Session ID (SAS WORK folder) used to execute code. + * This session ID should be used to poll job status. + * @example "{ sessionId: '20241028074744-54132-1730101664824' }" + */ + sessionId: string +} + @Security('bearerAuth') @Route('SASjsApi/code') @Tags('Code') @@ -44,6 +71,18 @@ export class CodeController { ): Promise { return executeCode(request, body) } + + /** + * Trigger Code on the Specified Runtime + * @summary Trigger Code and Return Session Id not awaiting for the job completion + */ + @Post('/trigger') + public async triggerCode( + @Request() request: express.Request, + @Body() body: TriggerCodePayload + ): Promise { + return triggerCode(request, body) + } } const executeCode = async ( @@ -76,3 +115,49 @@ const executeCode = async ( } } } + +const triggerCode = async ( + req: express.Request, + { code, runTime, expiresAfterMins }: TriggerCodePayload +): Promise<{ sessionId: string }> => { + const { user } = req + const userAutoExec = + process.env.MODE === ModeType.Server + ? user?.autoExec + : await getUserAutoExec() + + // get session controller based on runTime + const sessionController = getSessionController(runTime) + + // get session + const session = await sessionController.getSession() + + // add expiresAfterMins to session if provided + if (expiresAfterMins) { + // expiresAfterMins.used is set initially to false + session.expiresAfterMins = { mins: expiresAfterMins, used: false } + } + + try { + // call executeProgram method of ExecutionController without awaiting + new ExecutionController().executeProgram({ + program: code, + preProgramVariables: getPreProgramVariables(req), + vars: { ...req.query, _debug: 131 }, + otherArgs: { userAutoExec }, + runTime: runTime, + includePrintOutput: true, + session // session is provided + }) + + // return session id + return { sessionId: session.id } + } catch (err: any) { + throw { + code: 400, + status: 'failure', + message: 'Job execution failed.', + error: typeof err === 'object' ? err.toString() : err + } + } +} diff --git a/api/src/controllers/internal/Session.ts b/api/src/controllers/internal/Session.ts index 31ee7f3f..35736585 100644 --- a/api/src/controllers/internal/Session.ts +++ b/api/src/controllers/internal/Session.ts @@ -14,8 +14,7 @@ import { createFile, fileExists, generateTimestamp, - readFile, - isWindows + readFile } from '@sasjs/utils' const execFilePromise = promisify(execFile) @@ -190,20 +189,33 @@ ${autoExecContent}` } private scheduleSessionDestroy(session: Session) { - setTimeout( - async () => { - if (session.inUse) { - // adding 10 more minutes - const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000 + setTimeout(async () => { + if (session.inUse) { + // adding 10 more minutes + const newDeathTimeStamp = + parseInt(session.deathTimeStamp) + 10 * 60 * 1000 + session.deathTimeStamp = newDeathTimeStamp.toString() + + this.scheduleSessionDestroy(session) + } else { + const { expiresAfterMins } = session + + // delay session destroy if expiresAfterMins present + if (expiresAfterMins && !expiresAfterMins.used) { + // calculate session death time using expiresAfterMins + const newDeathTimeStamp = + parseInt(session.deathTimeStamp) + expiresAfterMins.mins * 60 * 1000 session.deathTimeStamp = newDeathTimeStamp.toString() + // set expiresAfterMins to true to avoid using it again + session.expiresAfterMins!.used = true + this.scheduleSessionDestroy(session) } else { await this.deleteSession(session) } - }, - parseInt(session.deathTimeStamp) - new Date().getTime() - 100 - ) + } + }, parseInt(session.deathTimeStamp) - new Date().getTime() - 100) } } diff --git a/api/src/routes/api/code.ts b/api/src/routes/api/code.ts index 09171c06..c8239500 100644 --- a/api/src/routes/api/code.ts +++ b/api/src/routes/api/code.ts @@ -1,5 +1,5 @@ import express from 'express' -import { runCodeValidation } from '../../utils' +import { runCodeValidation, triggerCodeValidation } from '../../utils' import { CodeController } from '../../controllers/' const runRouter = express.Router() @@ -28,4 +28,22 @@ runRouter.post('/execute', async (req, res) => { } }) +runRouter.post('/trigger', async (req, res) => { + const { error, value: body } = triggerCodeValidation(req.body) + if (error) return res.status(400).send(error.details[0].message) + + try { + const response = await controller.triggerCode(req, body) + + res.status(200) + res.send(response) + } catch (err: any) { + const statusCode = err.code + + delete err.code + + res.status(statusCode).send(err) + } +}) + export default runRouter diff --git a/api/src/types/Session.ts b/api/src/types/Session.ts index 0507a6d9..4fa1cfba 100644 --- a/api/src/types/Session.ts +++ b/api/src/types/Session.ts @@ -8,4 +8,5 @@ export interface Session { consumed: boolean completed: boolean crashed?: string + expiresAfterMins?: { mins: number; used: boolean } } diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index 66070fbf..a1e33107 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -178,6 +178,13 @@ export const runCodeValidation = (data: any): Joi.ValidationResult => runTime: Joi.string().valid(...process.runTimes) }).validate(data) +export const triggerCodeValidation = (data: any): Joi.ValidationResult => + Joi.object({ + code: Joi.string().required(), + runTime: Joi.string().valid(...process.runTimes), + expiresAfterMins: Joi.number().greater(0) + }).validate(data) + export const executeProgramRawValidation = (data: any): Joi.ValidationResult => Joi.object({ _program: Joi.string().required(),