From 4ec4fa1b6c1554bbac04ba275da3e1bba2dbf012 Mon Sep 17 00:00:00 2001 From: Josh Wulf Date: Thu, 19 Sep 2024 17:00:12 +1200 Subject: [PATCH] feat(camunda8): implement createProcessInstanceWithResult --- .../c8/rest/createProcessRest.spec.ts | 90 +++++++++++++++++++ .../testdata/create-process-rest.bpmn | 32 +++++++ src/c8/lib/C8Dto.ts | 70 ++++++++++++++- src/c8/lib/C8RestClient.ts | 70 +++++++++++++-- .../lib/RestApiProcessInstanceClassFactory.ts | 55 ++++++++++++ 5 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/c8/rest/createProcessRest.spec.ts create mode 100644 src/__tests__/testdata/create-process-rest.bpmn create mode 100644 src/c8/lib/RestApiProcessInstanceClassFactory.ts diff --git a/src/__tests__/c8/rest/createProcessRest.spec.ts b/src/__tests__/c8/rest/createProcessRest.spec.ts new file mode 100644 index 00000000..65625ce9 --- /dev/null +++ b/src/__tests__/c8/rest/createProcessRest.spec.ts @@ -0,0 +1,90 @@ +import path from 'node:path' + +import { C8RestClient } from '../../../c8/lib/C8RestClient' +import { LosslessDto } from '../../../lib' + +let bpmnProcessId: string +let processDefinitionKey: string +const restClient = new C8RestClient() + +beforeAll(async () => { + const res = await restClient.deployResourcesFromFiles([ + path.join('.', 'src', '__tests__', 'testdata', 'create-process-rest.bpmn'), + ]) + ;({ bpmnProcessId, processDefinitionKey } = res.processes[0]) +}) + +class myVariableDto extends LosslessDto { + someNumberField?: number +} + +test('Can create a process from bpmn id', (done) => { + restClient + .createProcessInstance({ + bpmnProcessId, + variables: { + someNumberField: 8, + }, + }) + .then((res) => { + expect(res.processKey).toEqual(processDefinitionKey) + done() + }) +}) + +test('Can create a process from process definition key', (done) => { + restClient + .createProcessInstance({ + processDefinitionKey, + variables: { + someNumberField: 8, + }, + }) + .then((res) => { + expect(res.processKey).toEqual(processDefinitionKey) + done() + }) +}) + +test('Can create a process with a lossless Dto', (done) => { + restClient + .createProcessInstance({ + processDefinitionKey, + variables: new myVariableDto({ someNumberField: 8 }), + }) + .then((res) => { + expect(res.processKey).toEqual(processDefinitionKey) + done() + }) +}) + +test('Can create a process and get the result', (done) => { + const variables = new myVariableDto({ someNumberField: 8 }) + restClient + .createProcessInstanceWithResult({ + processDefinitionKey, + variables, + outputVariablesDto: myVariableDto, + }) + .then((res) => { + console.log(res) + expect(res.processKey).toEqual(processDefinitionKey) + expect(res.variables.someNumberField).toBe(8) + done() + }) +}) + +test('Can create a process and get the result', (done) => { + restClient + .createProcessInstanceWithResult({ + processDefinitionKey, + variables: new myVariableDto({ someNumberField: 9 }), + }) + .then((res) => { + expect(res.processKey).toEqual(processDefinitionKey) + // Without an outputVariablesDto, the response variables will be of type unknown + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((res.variables as any).someNumberField).toBe(9) + done() + }) +}) diff --git a/src/__tests__/testdata/create-process-rest.bpmn b/src/__tests__/testdata/create-process-rest.bpmn new file mode 100644 index 00000000..4f6d520e --- /dev/null +++ b/src/__tests__/testdata/create-process-rest.bpmn @@ -0,0 +1,32 @@ + + + + + Flow_15yxzfg + + + Flow_15yxzfg + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/c8/lib/C8Dto.ts b/src/c8/lib/C8Dto.ts index c97d1f11..af234b35 100644 --- a/src/c8/lib/C8Dto.ts +++ b/src/c8/lib/C8Dto.ts @@ -120,7 +120,7 @@ export class DeployResourceResponse extends DeployResourceResponseDto { forms!: FormDeployment[] } -export class CreateProcessInstanceResponse extends LosslessDto { +export class CreateProcessInstanceResponse> { /** * The unique key identifying the process definition (e.g. returned from a process * in the DeployResourceResponse message) @@ -141,6 +141,10 @@ export class CreateProcessInstanceResponse extends LosslessDto { * the tenant identifier of the created process instance */ readonly tenantId!: string + /** + * If `awaitCompletion` is true, this will be populated with the output variables. Otherwise, it will be an empty object. + */ + readonly variables!: T } export interface MigrationMappingInstruction { @@ -212,3 +216,67 @@ export class PublishMessageResponse extends LosslessDto { /** the tenantId of the message */ tenantId!: string } + +export interface CreateProcessBaseRequest { + /** + * the version of the process; if not specified it will use the latest version + */ + version?: number + /** + * JSON document that will instantiate the variables for the root variable scope of the + * process instance. + */ + variables: V + /** The tenantId for a multi-tenant enabled cluster. */ + tenantId?: string + /** a reference key chosen by the user and will be part of all records resulted from this operation */ + operationReference?: number | LosslessNumber + /** + * List of start instructions. If empty (default) the process instance + * will start at the start event. If non-empty the process instance will apply start + * instructions after it has been created + */ + startInstructions?: ProcessInstanceCreationStartInstruction[] + /** + * Wait for the process instance to complete. If the process instance completion does not occur within the requestTimeout, the request will be closed. Defaults to false. + */ + // This is commented out, because we used specialised methods for the two cases. + // awaitCompletion?: boolean + /** + * Timeout (in ms) the request waits for the process to complete. By default or when set to 0, the generic request timeout configured in the cluster is applied. + */ + requestTimeout?: number +} + +export interface ProcessInstanceCreationStartInstruction { + /** + * future extensions might include + * - different types of start instructions + * - ability to set local variables for different flow scopes + * for now, however, the start instruction is implicitly a + * "startBeforeElement" instruction + */ + elementId: string +} + +export interface CreateProcessInstanceFromBpmnProcessId< + V extends JSONDoc | LosslessDto, +> extends CreateProcessBaseRequest { + /** + * the BPMN process ID of the process definition + */ + bpmnProcessId: string +} + +export interface CreateProcessInstanceFromProcessDefinition< + V extends JSONDoc | LosslessDto, +> extends CreateProcessBaseRequest { + /** + * the key of the process definition + */ + processDefinitionKey: string +} + +export type CreateProcessInstanceReq = + | CreateProcessInstanceFromBpmnProcessId + | CreateProcessInstanceFromProcessDefinition diff --git a/src/c8/lib/C8RestClient.ts b/src/c8/lib/C8RestClient.ts index ad2b9c50..02b21a50 100644 --- a/src/c8/lib/C8RestClient.ts +++ b/src/c8/lib/C8RestClient.ts @@ -26,7 +26,6 @@ import { ActivateJobsRequest, BroadcastSignalReq, CompleteJobRequest, - CreateProcessInstanceReq, ErrorJobWithVariables, FailJobRequest, IProcessVariables, @@ -41,6 +40,7 @@ import { import { BroadcastSignalResponse, CorrelateMessageResponse, + CreateProcessInstanceReq, CreateProcessInstanceResponse, Ctor, DecisionDeployment, @@ -59,11 +59,14 @@ import { import { C8JobWorker, C8JobWorkerConfig } from './C8JobWorker' import { getLogger } from './C8Logger' import { createSpecializedRestApiJobClass } from './RestApiJobClassFactory' +import { createSpecializedCreateProcessInstanceResponseClass } from './RestApiProcessInstanceClassFactory' const trace = debug('camunda:zeebe') const CAMUNDA_REST_API_VERSION = 'v2' +class DefaultLosslessDto extends LosslessDto {} + export class C8RestClient { private userAgentString: string private oAuthProvider: IOAuthProvider @@ -448,25 +451,82 @@ export class C8RestClient { } /** - * Create and start a process instance + * Create and start a process instance. This method does not await the outcome of the process. For that, use `createProcessInstanceWithResult`. */ - public async createProcessInstance( + public async createProcessInstance( request: CreateProcessInstanceReq + ): Promise>> + + async createProcessInstance< + T extends JSONDoc | LosslessDto, + V extends LosslessDto, + >( + request: CreateProcessInstanceReq & { + outputVariablesDto?: Ctor + } ) { const headers = await this.getHeaders() + const outputVariablesDto: Ctor | Ctor = + (request.outputVariablesDto as Ctor) ?? DefaultLosslessDto + + const CreateProcessInstanceResponseWithVariablesDto = + createSpecializedCreateProcessInstanceResponseClass(outputVariablesDto) + return this.rest.then((rest) => rest .post(`process-instances`, { body: losslessStringify(this.addDefaultTenantId(request)), headers, parseJson: (text) => - losslessParse(text, CreateProcessInstanceResponse), + losslessParse(text, CreateProcessInstanceResponseWithVariablesDto), }) - .json() + .json< + InstanceType + >() ) } + /** + * Create and start a process instance. This method awaits the outcome of the process. + */ + public async createProcessInstanceWithResult( + request: CreateProcessInstanceReq + ): Promise> + + public async createProcessInstanceWithResult< + T extends JSONDoc | LosslessDto, + V extends LosslessDto, + >( + request: CreateProcessInstanceReq & { + outputVariablesDto: Ctor + } + ): Promise> + public async createProcessInstanceWithResult< + T extends JSONDoc | LosslessDto, + V, + >( + request: CreateProcessInstanceReq & { + outputVariablesDto?: Ctor + } + ) { + /** + * We override the type system to make `awaitCompletion` hidden from end-users. This has been done because supporting the permutations of + * creating a process with/without awaiting the result and with/without an outputVariableDto in a single method is complex. I could not get all + * the cases to work with intellisense for the end-user using either generics or with signature overloads. + * + * To address this, createProcessInstance has all the functionality, but hides the `awaitCompletion` attribute from the signature. This method + * is a wrapper around createProcessInstance that sets `awaitCompletion` to true, and explicitly informs the type system via signature overloads. + * + * This is not ideal, but it is the best solution I could come up with. + */ + return this.createProcessInstance({ + ...request, + awaitCompletion: true, + outputVariablesDto: request.outputVariablesDto, + } as unknown as CreateProcessInstanceReq) + } + /** * Cancel an active process instance */ diff --git a/src/c8/lib/RestApiProcessInstanceClassFactory.ts b/src/c8/lib/RestApiProcessInstanceClassFactory.ts new file mode 100644 index 00000000..d5f1654d --- /dev/null +++ b/src/c8/lib/RestApiProcessInstanceClassFactory.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { LosslessDto } from '../../lib' + +import { CreateProcessInstanceResponse } from './C8Dto' + +const factory = + createMemoizedSpecializedCreateProcessInstanceResponseClassFactory() + +// Creates a specialized RestApiJob class that is cached based on output variables +export const createSpecializedCreateProcessInstanceResponseClass = < + Variables extends LosslessDto, +>( + outputVariableDto: new (obj: any) => Variables +) => { + return factory(outputVariableDto) +} + +function createMemoizedSpecializedCreateProcessInstanceResponseClassFactory() { + const cache = new Map() + + return function ( + outputVariableDto: new (obj: any) => Variables + ): new (obj: any) => CreateProcessInstanceResponse { + // Create a unique cache key based on the class and inputs + const cacheKey = JSON.stringify({ + outputVariableDto, + }) + + // Check for cached result + if (cache.has(cacheKey)) { + return cache.get(cacheKey) + } + + // Create a new class that extends the original class + class CustomCreateProcessInstanceResponseClass< + Variables extends LosslessDto, + > extends CreateProcessInstanceResponse { + variables!: Variables + } + + // Use Reflect to define the metadata on the new class's prototype + Reflect.defineMetadata( + 'child:class', + outputVariableDto, + CustomCreateProcessInstanceResponseClass.prototype, + 'variables' + ) + + // Store the new class in cache + cache.set(cacheKey, CustomCreateProcessInstanceResponseClass) + + // Return the new class + return CustomCreateProcessInstanceResponseClass + } +}