From ffdce3b1a21ab3bf077e9b48c4edd0273d907158 Mon Sep 17 00:00:00 2001 From: ricardo Date: Thu, 28 Apr 2022 20:44:24 -0400 Subject: [PATCH] :zap: Add execution resource --- package-lock.json | 2 +- .../cli/src/PublicApi/Services/execution.ts | 66 +++++ .../cli/src/PublicApi/Services/workflow.ts | 24 ++ packages/cli/src/PublicApi/helpers.ts | 66 +++-- packages/cli/src/PublicApi/middlewares.ts | 24 +- .../cli/src/PublicApi/publicApiRequest.ts | 54 +++++ .../src/PublicApi/v1/handlers/Executions.ts | 129 ++++++++++ .../cli/src/PublicApi/v1/handlers/Users.ts | 6 +- packages/cli/src/PublicApi/v1/openapi.yml | 227 +++++++++++++++--- 9 files changed, 530 insertions(+), 68 deletions(-) create mode 100644 packages/cli/src/PublicApi/Services/execution.ts create mode 100644 packages/cli/src/PublicApi/Services/workflow.ts create mode 100644 packages/cli/src/PublicApi/publicApiRequest.ts create mode 100644 packages/cli/src/PublicApi/v1/handlers/Executions.ts diff --git a/package-lock.json b/package-lock.json index 97e3f03d89..e3607f463c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -134,7 +134,7 @@ "eslint-plugin-vue": "^7.16.0", "eventsource": "^1.0.7", "express": "^4.16.4", - "express-openapi-validator": "^4.13.7", + "express-openapi-validator": "^4.13.6", "fast-glob": "^3.2.5", "fflate": "^0.7.0", "file-saver": "^2.0.2", diff --git a/packages/cli/src/PublicApi/Services/execution.ts b/packages/cli/src/PublicApi/Services/execution.ts new file mode 100644 index 0000000000..9b73255c79 --- /dev/null +++ b/packages/cli/src/PublicApi/Services/execution.ts @@ -0,0 +1,66 @@ +import { In, Equal, Not, ObjectLiteral, MoreThan, LessThan } from 'typeorm'; +import { Db, IExecutionFlattedDb } from '../..'; +import { ExecutionStatus } from '../publicApiRequest'; + +function getStatusCondition(status: ExecutionStatus): ObjectLiteral { + const condition: ObjectLiteral = {}; + + if (status === 'success') { + condition.finished = true; + } else if (status === 'running') { + condition.stoppedAt = Equal(null); + } else if (status === 'waiting') { + condition.waitTill = Not(null); + } else if (status === 'error') { + condition.stoppedAt = Not(null); + } + return condition; +} + +export async function getExecutions(data: { + limit: number; + lastId?: number; + workflowIds?: number[]; + status?: ExecutionStatus; +}): Promise { + const executions = await Db.collections.Execution.find({ + where: { + ...(data.lastId && { id: LessThan(data.lastId) }), + ...(data.status && { ...getStatusCondition(data.status) }), + ...(data.workflowIds && { workflowId: In(data.workflowIds) }), + }, + order: { id: 'DESC' }, + take: data.limit, + }); + return executions; +} + +export async function getExecutionsCount(data: { + limit: number; + lastId?: number; + workflowIds?: number[]; + status?: ExecutionStatus; +}): Promise { + const executions = await Db.collections.Execution.count({ + where: { + id: LessThan(data.lastId), + ...(data.status && { ...getStatusCondition(data.status) }), + ...(data.workflowIds && { workflowId: In(data.workflowIds) }), + }, + take: data.limit, + }); + return executions; +} + +export async function getExecution(id: number): Promise { + const execution = await Db.collections.Execution.findOne({ + where: { + id, + }, + }); + return execution; +} + +export async function deleteExecution(execution: IExecutionFlattedDb): Promise { + await Db.collections.Execution.remove(execution); +} diff --git a/packages/cli/src/PublicApi/Services/workflow.ts b/packages/cli/src/PublicApi/Services/workflow.ts new file mode 100644 index 0000000000..f34a0bd057 --- /dev/null +++ b/packages/cli/src/PublicApi/Services/workflow.ts @@ -0,0 +1,24 @@ +import { User } from '../../databases/entities/User'; +import { Db } from '../..'; + +export async function getSharedWorkflowIds(user: User): Promise { + const sharedWorkflows = await Db.collections.SharedWorkflow.find({ + where: { + user, + }, + }); + return sharedWorkflows.map((workflow) => workflow.workflowId); +} + +export async function getWorkflowAccess( + user: User, + workflowId: string | undefined, +): Promise { + const sharedWorkflows = await Db.collections.SharedWorkflow.find({ + where: { + user, + workflow: { id: workflowId }, + }, + }); + return !!sharedWorkflows.length; +} diff --git a/packages/cli/src/PublicApi/helpers.ts b/packages/cli/src/PublicApi/helpers.ts index 690c17c5e5..a84b471d73 100644 --- a/packages/cli/src/PublicApi/helpers.ts +++ b/packages/cli/src/PublicApi/helpers.ts @@ -4,11 +4,11 @@ /* eslint-disable import/no-cycle */ // eslint-disable-next-line import/no-extraneous-dependencies import { pick } from 'lodash'; -import { In } from 'typeorm'; import { validate as uuidValidate } from 'uuid'; import { OpenAPIV3, Format } from 'express-openapi-validator/dist/framework/types'; import express = require('express'); import validator from 'validator'; +import { In } from 'typeorm'; import { User } from '../databases/entities/User'; import type { Role } from '../databases/entities/Role'; import { ActiveWorkflowRunner, Db, InternalHooksManager, ITelemetryUserDeletionData } from '..'; @@ -18,39 +18,57 @@ import { SharedWorkflow } from '../databases/entities/SharedWorkflow'; import { SharedCredentials } from '../databases/entities/SharedCredentials'; import { WorkflowEntity } from '../databases/entities/WorkflowEntity'; -interface IPaginationOffsetDecoded { - offset: number; - limit: number; -} export type OperationID = 'getUsers' | 'getUser'; -export const decodeCursor = (cursor: string): IPaginationOffsetDecoded => { - const { offset, limit } = JSON.parse(Buffer.from(cursor, 'base64').toString()); - return { - offset, - limit, - }; +type PaginationBase = { limit: number }; + +type PaginationOffsetDecoded = PaginationBase & { offset: number }; + +type PaginationCursorDecoded = PaginationBase & { lastId: number }; + +type OffsetPagination = PaginationBase & { offset: number; numberOfTotalRecords: number }; + +type CursorPagination = PaginationBase & { lastId: number; numberOfNextRecords: number }; + +export const decodeCursor = (cursor: string): PaginationOffsetDecoded | PaginationCursorDecoded => { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) as + | PaginationCursorDecoded + | PaginationOffsetDecoded; }; -export const encodeNextCursor = ( - offset: number, - limit: number, - numberOfRecords: number, -): string | null => { - const retrieveRecordsLength = offset + limit; - - if (retrieveRecordsLength < numberOfRecords) { +const encodeOffSetPagination = (pagination: OffsetPagination): string | null => { + if (pagination.numberOfTotalRecords > pagination.offset + pagination.limit) { return Buffer.from( JSON.stringify({ - limit, - offset: offset + limit, + limit: pagination.limit, + offset: pagination.offset + pagination.limit, }), ).toString('base64'); } - return null; }; +const encodeCursoPagination = (pagination: CursorPagination): string | null => { + if (pagination.numberOfNextRecords) { + return Buffer.from( + JSON.stringify({ + lastId: pagination.lastId, + limit: pagination.limit, + }), + ).toString('base64'); + } + return null; +}; + +export const encodeNextCursor = ( + pagination: OffsetPagination | CursorPagination, +): string | null => { + if ('offset' in pagination) { + return encodeOffSetPagination(pagination); + } + return encodeCursoPagination(pagination); +}; + export const getSelectableProperties = (table: 'user' | 'role'): string[] => { return { user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt', 'isPending'], @@ -193,13 +211,13 @@ export async function getAllUsersAndCount(data: { limit?: number; offset?: number; }): Promise<[User[], number]> { - const users = await Db.collections.User!.find({ + const users = await Db.collections.User.find({ where: {}, relations: data?.includeRole ? ['globalRole'] : undefined, skip: data.offset, take: data.limit, }); - const count = await Db.collections.User!.count(); + const count = await Db.collections.User.count(); return [users, count]; } diff --git a/packages/cli/src/PublicApi/middlewares.ts b/packages/cli/src/PublicApi/middlewares.ts index 2ede9abb23..25365ded6e 100644 --- a/packages/cli/src/PublicApi/middlewares.ts +++ b/packages/cli/src/PublicApi/middlewares.ts @@ -11,10 +11,9 @@ import type { UserRequest } from '../requests'; import * as UserManagementMailer from '../UserManagement/email/UserManagementMailer'; import { decodeCursor, getGlobalMemberRole } from './helpers'; +import { Role, PaginatatedRequest } from './publicApiRequest'; -type Role = 'owner' | 'member'; - -const instanceOwnerSetup = ( +export const instanceOwnerSetup = ( req: express.Request, res: express.Response, next: express.NextFunction, @@ -36,8 +35,8 @@ const emailSetup = ( next(); }; -const authorize = - (role: [Role]) => +export const authorize = + (role: Role[]) => (req: express.Request, res: express.Response, next: express.NextFunction): any => { const { globalRole: { name: userRole }, @@ -76,17 +75,22 @@ const transferingToDeletedUser = ( next(); }; -const validCursor = ( - req: UserRequest.Get, +export const validCursor = ( + req: PaginatatedRequest, res: express.Response, next: express.NextFunction, ): any => { if (req.query.cursor) { const { cursor } = req.query; try { - const { offset, limit } = decodeCursor(cursor); - req.query.offset = offset; - req.query.limit = limit; + const paginationData = decodeCursor(cursor); + if ('offset' in paginationData) { + req.query.offset = paginationData.offset; + req.query.limit = paginationData.limit; + } else { + req.query.lastId = paginationData.lastId; + req.query.limit = paginationData.limit; + } } catch (error) { return res.status(400).json({ message: 'An invalid cursor was used', diff --git a/packages/cli/src/PublicApi/publicApiRequest.ts b/packages/cli/src/PublicApi/publicApiRequest.ts new file mode 100644 index 0000000000..2e07ced69d --- /dev/null +++ b/packages/cli/src/PublicApi/publicApiRequest.ts @@ -0,0 +1,54 @@ +/* eslint-disable import/no-cycle */ +import express from 'express'; + +import type { User } from '../databases/entities/User'; + +export type ExecutionStatus = 'error' | 'running' | 'success' | 'waiting' | null; + +export type Role = 'owner' | 'member'; + +export type AuthlessRequest< + RouteParams = {}, + ResponseBody = {}, + RequestBody = {}, + RequestQuery = {}, +> = express.Request; + +export type AuthenticatedRequest< + RouteParams = {}, + ResponseBody = {}, + RequestBody = {}, + RequestQuery = {}, +> = express.Request & { + user: User; +}; + +export type PaginatatedRequest = AuthenticatedRequest< + {}, + {}, + {}, + { + limit?: number; + cursor?: string; + offset?: number; + lastId?: number; + } +>; +export declare namespace ExecutionRequest { + type GetAll = AuthenticatedRequest< + {}, + {}, + {}, + { + status?: ExecutionStatus; + limit?: number; + cursor?: string; + offset?: number; + workflowId?: number; + lastId?: number; + } + >; + + type Get = AuthenticatedRequest<{ executionId: number }, {}, {}, {}>; + type Delete = Get; +} diff --git a/packages/cli/src/PublicApi/v1/handlers/Executions.ts b/packages/cli/src/PublicApi/v1/handlers/Executions.ts new file mode 100644 index 0000000000..fb7138df89 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/Executions.ts @@ -0,0 +1,129 @@ +import express = require('express'); + +import { ExecutionRequest } from '../../publicApiRequest'; +import { encodeNextCursor } from '../../helpers'; +import { authorize, instanceOwnerSetup, validCursor } from '../../middlewares'; +import { + getExecutions, + getExecution, + deleteExecution, + getExecutionsCount, +} from '../../Services/execution'; +import { getSharedWorkflowIds, getWorkflowAccess } from '../../Services/workflow'; + +export = { + deleteExecution: [ + instanceOwnerSetup, + authorize(['owner', 'member']), + async (req: ExecutionRequest.Delete, res: express.Response): Promise => { + const { executionId } = req.params; + + const execution = await getExecution(executionId); + if (execution === undefined) { + return res.status(404).json({ + message: 'Execution not found.', + }); + } + + if (req.user.globalRole.name === 'owner') { + await deleteExecution(execution); + return res.json(execution); + } + + const userHasAccessToWorkflow = await getWorkflowAccess(req.user, execution.workflowId); + + if (userHasAccessToWorkflow) { + await deleteExecution(execution); + return res.json(execution); + } + + return res.status(404).json({ + message: 'Execution not found.', + }); + }, + ], + getExecution: [ + instanceOwnerSetup, + authorize(['owner', 'member']), + async (req: ExecutionRequest.Get, res: express.Response): Promise => { + const { executionId } = req.params; + + const execution = await getExecution(executionId); + if (execution === undefined) { + return res.status(404).json({ + message: 'Execution not found.', + }); + } + + if (req.user.globalRole.name === 'owner') { + return res.json(execution); + } + + const userHasAccessToWorkflow = await getWorkflowAccess(req.user, execution.workflowId); + + if (userHasAccessToWorkflow) { + return res.json(execution); + } + return res.status(404).json({ + message: 'Execution not found.', + }); + }, + ], + getExecutions: [ + instanceOwnerSetup, + authorize(['owner', 'member']), + validCursor, + async (req: ExecutionRequest.GetAll, res: express.Response): Promise => { + const { + lastId = undefined, + limit = 100, + status = undefined, + workflowId = undefined, + } = req.query; + + const filters = { + status, + limit, + lastId, + ...(workflowId && { workflowIds: [workflowId] }), + }; + + if (req.user.globalRole.name === 'owner') { + const executions = await getExecutions(filters); + + filters.lastId = executions.slice(-1)[0].id as number; + + const count = await getExecutionsCount(filters); + + return res.json({ + data: executions, + nextCursor: encodeNextCursor({ + lastId: filters.lastId, + limit, + numberOfNextRecords: count, + }), + }); + } + + const sharedWorkflowsIds = []; + + if (!workflowId) { + const sharedWorkflows = await getSharedWorkflowIds(req.user); + sharedWorkflowsIds.push(...sharedWorkflows); + } + + const executions = await getExecutions(filters); + + const count = await getExecutionsCount(filters); + + return res.json({ + data: executions, + nextCursor: encodeNextCursor({ + lastId: executions.slice(-1)[0].id as number, + limit, + numberOfNextRecords: count, + }), + }); + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/Users.ts b/packages/cli/src/PublicApi/v1/handlers/Users.ts index 16143761ab..4cce5da2eb 100644 --- a/packages/cli/src/PublicApi/v1/handlers/Users.ts +++ b/packages/cli/src/PublicApi/v1/handlers/Users.ts @@ -135,7 +135,11 @@ export = { return res.json({ data: clean(users, { includeRole }), - nextCursor: encodeNextCursor(offset, limit, count), + nextCursor: encodeNextCursor({ + offset, + limit, + numberOfTotalRecords: count, + }), }); }, ], diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index d5053600ae..365dedff62 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -18,8 +18,43 @@ servers: tags: - name: User description: Operations about user +- name: Execution + description: Operations about execution paths: /users: + post: + x-eov-operation-id: createUsers + x-eov-operation-handler: v1/handlers/Users + tags: + - User + summary: Invite a user + description: Invites a user to your instance. Only available for the instance owner. + operationId: createUser + requestBody: + description: Created user object. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserInformation' + required: true + responses: + "200": + description: A User object + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserInformation' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + get: x-eov-operation-id: getUsers x-eov-operation-handler: v1/handlers/Users @@ -66,38 +101,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - post: - x-eov-operation-id: createUsers - x-eov-operation-handler: v1/handlers/Users - tags: - - User - summary: Invite a user - description: Invites a user to your instance. Only available for the instance owner. - operationId: createUser - requestBody: - description: Created user object. - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/UserInformation' - required: true - responses: - "200": - description: A User object - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/UserInformation' - "401": - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/Error' /users/{identifier}: get: x-eov-operation-id: getUser @@ -177,6 +180,121 @@ paths: $ref: '#/components/schemas/Error' "404": description: User not found + /executions: + get: + x-eov-operation-id: getExecutions + x-eov-operation-handler: v1/handlers/Executions + tags: + - Execution + summary: Retrieve all executions + description: Retrieve all executions from your instance. + parameters: + - name: status + in: query + description: Status to filter the executions by. + required: false + schema: + type: string + enum: ['error', 'running', 'success', 'waiting'] + default: 'any' + - name: workflowId + in: query + description: Workflow to filter the executions by. + required: false + schema: + type: number + example: 1000 + - name: limit + in: query + description: The maximum number of items to return. + required: false + schema: + type: number + example: 100 + default: 100 + - name: cursor + in: query + description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request's response. Default value fetches the first "page" of the collection. See pagination for more detail. + required: false + style: form + schema: + type: string + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA + responses: + "200": + description: Operation successful. + content: + application/json: + schema: + $ref: '#/components/schemas/ExecutionDetailsResponse' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "404": + description: User not found + /executions/{executionId}: + get: + x-eov-operation-id: getExecution + x-eov-operation-handler: v1/handlers/Executions + tags: + - Execution + summary: Retrieve an execution + description: Retrieve an execution from you instance. + parameters: + - name: executionId + in: path + description: The ID of the execution. + required: true + schema: + type: number + responses: + "200": + description: Operation successful. + content: + application/json: + schema: + $ref: '#/components/schemas/ExecutionInformation' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "404": + description: User not found + delete: + x-eov-operation-id: deleteExecution + x-eov-operation-handler: v1/handlers/Executions + tags: + - Execution + summary: Delete an execution + description: Deletes an execution from your instance. + parameters: + - name: executionId + in: path + description: The ID of the execution to be deleted. + required: true + schema: + type: number + responses: + "200": + description: Operation successful. + content: + application/json: + schema: + $ref: '#/components/schemas/ExecutionInformation' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "404": + description: User not found + components: schemas: Error: @@ -233,6 +351,18 @@ components: readOnly: true globalRole: $ref: '#/components/schemas/RoleInformation' + ExecutionDetailsResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ExecutionInformation' + nextCursor: + type: string + description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA UserDetailsResponse: type: object properties: @@ -245,6 +375,39 @@ components: description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. nullable: true example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA + ExecutionInformation: + type: object + properties: + id: + type: number + example: 1000 + data: + type: string + finished: + type: boolean + example: true + mode: + type: string + enum: ['cli', 'error', 'integrated', 'internal', 'manual', 'retry', 'trigger', 'webhook'] + retryOf: + type: string + nullable: true + retrySuccessId: + type: string + nullable: true + startedAt: + type: string + format: date-time + stoppedAt: + type: string + format: date-time + workflowId: + type: string + example: 1000 + waitTill: + type: string + nullable: true + format: date-time RoleInformation: type: object properties: