Files
speckle-server/packages/server/modules/automate/services/functionManagement.ts
T
Iain Sproat a50e053096 chore(server/logging): add operation logging to automate module
- tidy up some passing of loggers to automate
- do not use console.log, instead use @/observability/logging
2025-04-15 11:37:06 +01:00

293 lines
8.9 KiB
TypeScript

import {
CreateFunctionBody,
ExecutionEngineFunctionTemplateId,
createFunction,
getFunctionFactory,
updateFunction as updateExecEngineFunction
} from '@/modules/automate/clients/executionEngine'
import {
AutomateFunctionCreationError,
AutomateFunctionUpdateError
} from '@/modules/automate/errors/management'
import {
BasicGitRepositoryMetadata,
UpdateAutomateFunctionInput,
CreateAutomateFunctionInput,
AutomateFunctionTemplateLanguage
} from '@/modules/core/graph/generated/graphql'
import {
MaybeNullOrUndefined,
Nullable,
Optional,
SourceAppName,
removeNullOrUndefinedKeys
} from '@speckle/shared'
import {
AutomateFunctionGraphQLReturn,
AutomateFunctionReleaseGraphQLReturn
} from '@/modules/automate/helpers/graphTypes'
import {
FunctionReleaseSchemaType,
FunctionSchemaType
} from '@/modules/automate/helpers/executionEngine'
import { Request, Response } from 'express'
import { UnauthorizedError } from '@/modules/shared/errors'
import {
AuthCodePayload,
AuthCodePayloadAction
} from '@/modules/automate/services/authCode'
import {
getServerOrigin,
isDevEnv,
speckleAutomateUrl
} from '@/modules/shared/helpers/envHelper'
import { getFunctionsMarketplaceUrl } from '@/modules/core/helpers/routeHelper'
import type { Logger } from '@/observability/logging'
import { CreateStoredAuthCode } from '@/modules/automate/domain/operations'
import { GetUser } from '@/modules/core/domain/users/operations'
import { noop } from 'lodash'
import { UnknownFunctionTemplateError } from '@/modules/automate/errors/functions'
import { UserInputError } from '@/modules/core/errors/userinput'
const mapGqlTemplateIdToExecEngineTemplateId = (
id: AutomateFunctionTemplateLanguage
): ExecutionEngineFunctionTemplateId => {
switch (id) {
case AutomateFunctionTemplateLanguage.Python:
return ExecutionEngineFunctionTemplateId.Python
case AutomateFunctionTemplateLanguage.DotNet:
return ExecutionEngineFunctionTemplateId.DotNet
case AutomateFunctionTemplateLanguage.Typescript:
return ExecutionEngineFunctionTemplateId.TypeScript
default:
throw new UnknownFunctionTemplateError('Unknown template id')
}
}
const repoUrlToBasicGitRepositoryMetadata = (
url: string
): BasicGitRepositoryMetadata => {
const repoUrl = new URL(url)
const pathParts = repoUrl.pathname.split('/').filter(Boolean)
if (pathParts.length < 2) {
throw new UserInputError('Invalid GitHub repository URL')
}
const [owner, name] = pathParts
return { owner, name, id: repoUrl.toString(), url: repoUrl.toString() }
}
const cleanFunctionLogo = (logo: MaybeNullOrUndefined<string>): Nullable<string> => {
if (!logo?.length) return null
if (logo.startsWith('data:')) return logo
if (logo.startsWith('http:')) return logo
if (logo.startsWith('https:')) return logo
return null
}
export const convertFunctionToGraphQLReturn = (
fn: FunctionSchemaType
): AutomateFunctionGraphQLReturn => {
const functionCreator: FunctionSchemaType['functionCreator'] =
fn.functionCreatorSpeckleUserId && fn.functionCreatorSpeckleServerOrigin
? {
speckleUserId: fn.functionCreatorSpeckleUserId,
speckleServerOrigin: fn.functionCreatorSpeckleServerOrigin
}
: fn.functionCreator
const ret: AutomateFunctionGraphQLReturn = {
id: fn.functionId,
name: fn.functionName,
repo: repoUrlToBasicGitRepositoryMetadata(fn.repoUrl),
isFeatured: fn.isFeatured,
description: fn.description,
logo: cleanFunctionLogo(fn.logo),
tags: fn.tags,
supportedSourceApps: fn.supportedSourceApps,
functionCreator,
workspaceIds: fn.workspaceIds
}
return ret
}
export const convertFunctionReleaseToGraphQLReturn = (
fnRelease: FunctionReleaseSchemaType & { functionId: string }
): AutomateFunctionReleaseGraphQLReturn => {
const ret: AutomateFunctionReleaseGraphQLReturn = {
id: fnRelease.functionVersionId,
versionTag: fnRelease.versionTag,
createdAt: new Date(fnRelease.createdAt),
inputSchema: fnRelease.inputSchema,
commitId: fnRelease.commitId,
functionId: fnRelease.functionId
}
return ret
}
export type CreateFunctionDeps = {
createStoredAuthCode: CreateStoredAuthCode
createExecutionEngineFn: typeof createFunction
getUser: GetUser
logger: Logger
}
export const createFunctionFromTemplateFactory =
(deps: CreateFunctionDeps) =>
async (params: { input: CreateAutomateFunctionInput; userId: string }) => {
const { input, userId } = params
const { createExecutionEngineFn, getUser, createStoredAuthCode, logger } = deps
// Validate user
const user = await getUser(userId)
if (!user) {
throw new AutomateFunctionCreationError('Speckle user not found')
}
const authCode = await createStoredAuthCode({
userId: user.id,
action: AuthCodePayloadAction.CreateFunction
})
const body: CreateFunctionBody<AuthCodePayload> = {
...input,
speckleServerAuthenticationPayload: authCode,
functionName: input.name,
template: mapGqlTemplateIdToExecEngineTemplateId(input.template),
supportedSourceApps: input.supportedSourceApps as SourceAppName[],
logo: cleanFunctionLogo(input.logo),
org: input.org || null
}
const created = await createExecutionEngineFn({ body })
if (isDevEnv() && created) {
logger.info({ created }, `[dev] Created function #${created.functionId}`)
}
// Don't want to pull the function w/ another req, so we'll just return the input
const gqlReturn: AutomateFunctionGraphQLReturn = {
id: created.functionId,
name: body.functionName,
repo: {
id: created.repo.htmlUrl,
url: created.repo.htmlUrl,
name: created.repo.name,
owner: created.repo.owner
},
isFeatured: false,
description: body.description,
logo: body.logo,
tags: body.tags,
supportedSourceApps: body.supportedSourceApps,
functionCreator: {
speckleServerOrigin: getServerOrigin(),
speckleUserId: user.id
}
}
return {
createResponse: created,
graphqlReturn: gqlReturn
}
}
export type UpdateFunctionDeps = {
updateFunction: typeof updateExecEngineFunction
getFunction: ReturnType<typeof getFunctionFactory>
createStoredAuthCode: CreateStoredAuthCode
}
export const updateFunctionFactory =
(deps: UpdateFunctionDeps) =>
async (params: { input: UpdateAutomateFunctionInput; userId: string }) => {
const { getFunction, updateFunction, createStoredAuthCode } = deps
const { input, userId } = params
const existingFn = await getFunction({ functionId: input.id })
if (!existingFn) {
throw new AutomateFunctionUpdateError('Function not found')
}
// Fix up logo, if any
if (input.logo) {
input.logo = cleanFunctionLogo(input.logo)
}
// Filter out empty (null) values from input
const updates = removeNullOrUndefinedKeys(input)
// Skip if there's nothing left
if (Object.keys(updates).length === 0) {
return existingFn
}
const authCode = await createStoredAuthCode({
userId,
action: AuthCodePayloadAction.UpdateFunction
})
const apiResult = await updateFunction({
functionId: updates.id,
body: {
...updates,
functionName: updates.name,
supportedSourceApps: updates.supportedSourceApps as Optional<SourceAppName[]>,
speckleServerAuthenticationPayload: authCode
}
})
return convertFunctionToGraphQLReturn(apiResult)
}
export type StartAutomateFunctionCreatorAuthDeps = {
createStoredAuthCode: CreateStoredAuthCode
}
export const startAutomateFunctionCreatorAuthFactory =
(deps: StartAutomateFunctionCreatorAuthDeps) =>
async (params: { req: Request; res: Response }) => {
const { createStoredAuthCode } = deps
const { req, res } = params
const userId = req.context.userId
if (!userId) {
throw new UnauthorizedError()
}
const authCode = await createStoredAuthCode({
userId,
action: AuthCodePayloadAction.BecomeFunctionAuthor
})
const redirectUrl = new URL(
'/api/v2/functions/auth/githubapp/authorize',
speckleAutomateUrl()
)
redirectUrl.searchParams.set(
'speckleServerAuthenticationPayload',
JSON.stringify({ ...authCode, origin: getServerOrigin() })
)
return res.redirect(redirectUrl.toString())
}
export const handleAutomateFunctionCreatorAuthCallbackFactory =
() => async (params: { req: Request; res: Response }) => {
const { req, res } = params
const {
ghAuth = 'unknown',
ghAuthDesc = 'GitHub Authentication unexpectedly failed'
} = req.query as Record<string, string>
const isSuccess = ghAuth === 'success'
const redirectUrl = getFunctionsMarketplaceUrl(req.session.workspaceSlug)
redirectUrl.searchParams.set('ghAuth', isSuccess ? 'success' : ghAuth)
redirectUrl.searchParams.set('ghAuthDesc', isSuccess ? '' : ghAuthDesc)
req.session?.destroy?.(noop)
return res.redirect(redirectUrl.toString())
}