Skip to content

Commit 2150a48

Browse files
committed
Link converter
1 parent be83b0d commit 2150a48

File tree

3 files changed

+315
-0
lines changed

3 files changed

+315
-0
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import test from 'ava';
2+
import Long from 'long';
3+
import { temporal } from '@temporalio/proto';
4+
import {
5+
convertWorkflowEventLinkToNexusLink,
6+
convertNexusLinkToWorkflowEventLink,
7+
} from '@temporalio/worker/lib/nexus-link-converter';
8+
9+
const { EventType } = temporal.api.enums.v1;
10+
const WORKFLOW_EVENT_TYPE = (temporal.api.common.v1.Link.WorkflowEvent as any).fullName.slice(1);
11+
12+
function makeEventRef(eventId: number, eventType: keyof typeof EventType) {
13+
return {
14+
eventId: Long.fromNumber(eventId),
15+
eventType: EventType[eventType],
16+
};
17+
}
18+
19+
function makeRequestIdRef(requestId: string, eventType: keyof typeof EventType) {
20+
return {
21+
requestId,
22+
eventType: EventType[eventType],
23+
};
24+
}
25+
26+
test('convertWorkflowEventLinkToNexusLink and back with eventRef', (t) => {
27+
const we = {
28+
namespace: 'ns',
29+
workflowId: 'wid',
30+
runId: 'rid',
31+
eventRef: makeEventRef(42, 'EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED'),
32+
};
33+
const nexusLink = convertWorkflowEventLinkToNexusLink(we);
34+
t.is(nexusLink.type, WORKFLOW_EVENT_TYPE);
35+
t.regex(nexusLink.url.toString(), /^temporal:\/\/\/namespaces\/ns\/workflows\/wid\/rid\/history\?/);
36+
37+
const roundTrip = convertNexusLinkToWorkflowEventLink(nexusLink);
38+
t.deepEqual(roundTrip, we);
39+
});
40+
41+
test('convertWorkflowEventLinkToNexusLink and back with requestIdRef', (t) => {
42+
const we = {
43+
namespace: 'ns2',
44+
workflowId: 'wid2',
45+
runId: 'rid2',
46+
requestIdRef: makeRequestIdRef('req-123', 'EVENT_TYPE_WORKFLOW_TASK_COMPLETED'),
47+
};
48+
const nexusLink = convertWorkflowEventLinkToNexusLink(we);
49+
t.is(nexusLink.type, WORKFLOW_EVENT_TYPE);
50+
t.regex(nexusLink.url.toString(), /^temporal:\/\/\/namespaces\/ns2\/workflows\/wid2\/rid2\/history\?/);
51+
52+
const roundTrip = convertNexusLinkToWorkflowEventLink(nexusLink);
53+
t.deepEqual(roundTrip, we);
54+
});
55+
56+
test('convertNexusLinkToLinkWorkflowEvent with an event type in PascalCase', (t) => {
57+
const nexusLink = {
58+
url: new URL(
59+
'temporal:///namespaces/ns2/workflows/wid2/rid2/history?referenceType=RequestIdReference&requestID=req-123&eventType=WorkflowTaskCompleted'
60+
),
61+
type: WORKFLOW_EVENT_TYPE,
62+
}
63+
64+
const workflowEventLink = convertNexusLinkToWorkflowEventLink(nexusLink);
65+
t.is(workflowEventLink.requestIdRef?.eventType, EventType.EVENT_TYPE_WORKFLOW_TASK_COMPLETED);
66+
});
67+
68+
test('throws on missing required fields', (t) => {
69+
t.throws(
70+
() =>
71+
convertWorkflowEventLinkToNexusLink({
72+
namespace: '',
73+
workflowId: 'wid',
74+
runId: 'rid',
75+
}),
76+
{ instanceOf: TypeError }
77+
);
78+
t.throws(
79+
() =>
80+
convertWorkflowEventLinkToNexusLink({
81+
namespace: 'ns',
82+
workflowId: '',
83+
runId: 'rid',
84+
}),
85+
{ instanceOf: TypeError }
86+
);
87+
t.throws(
88+
() =>
89+
convertWorkflowEventLinkToNexusLink({
90+
namespace: 'ns',
91+
workflowId: 'wid',
92+
runId: '',
93+
}),
94+
{ instanceOf: TypeError }
95+
);
96+
});
97+
98+
test('throws on invalid URL scheme', (t) => {
99+
const fakeLink = {
100+
url: new URL('http://example.com'),
101+
type: WORKFLOW_EVENT_TYPE,
102+
};
103+
t.throws(() => convertNexusLinkToWorkflowEventLink(fakeLink as any), { instanceOf: TypeError });
104+
});
105+
106+
test('throws on invalid URL path', (t) => {
107+
const fakeLink = {
108+
url: new URL('temporal:///badpath'),
109+
type: WORKFLOW_EVENT_TYPE,
110+
};
111+
t.throws(() => convertNexusLinkToWorkflowEventLink(fakeLink as any), { instanceOf: TypeError });
112+
});
113+
114+
test('throws on unknown reference type', (t) => {
115+
const url = new URL('temporal:///namespaces/ns/workflows/wid/rid/history?referenceType=UnknownType');
116+
const fakeLink = {
117+
url,
118+
type: WORKFLOW_EVENT_TYPE,
119+
};
120+
t.throws(() => convertNexusLinkToWorkflowEventLink(fakeLink as any), { instanceOf: TypeError });
121+
});
122+
123+
test('throws on missing eventType in eventRef', (t) => {
124+
const url = new URL('temporal:///namespaces/ns/workflows/wid/rid/history?referenceType=EventReference&eventID=1');
125+
const fakeLink = {
126+
url,
127+
type: WORKFLOW_EVENT_TYPE,
128+
};
129+
t.throws(() => convertNexusLinkToWorkflowEventLink(fakeLink as any), { message: /Missing eventType parameter/ });
130+
});
131+
132+
test('throws on unknown eventType in eventRef', (t) => {
133+
const url = new URL(
134+
'temporal:///namespaces/ns/workflows/wid/rid/history?referenceType=EventReference&eventID=1&eventType=NotAType'
135+
);
136+
const fakeLink = {
137+
url,
138+
type: WORKFLOW_EVENT_TYPE,
139+
};
140+
t.throws(() => convertNexusLinkToWorkflowEventLink(fakeLink as any), { message: /Unknown eventType parameter/ });
141+
});
142+
143+
test('throws on missing eventType in requestIdRef', (t) => {
144+
const url = new URL(
145+
'temporal:///namespaces/ns/workflows/wid/rid/history?referenceType=RequestIdReference&requestID=req'
146+
);
147+
const fakeLink = {
148+
url,
149+
type: WORKFLOW_EVENT_TYPE,
150+
};
151+
t.throws(() => convertNexusLinkToWorkflowEventLink(fakeLink as any), { message: /Missing eventType parameter/ });
152+
});
153+
154+
test('throws on unknown eventType in requestIdRef', (t) => {
155+
const url = new URL(
156+
'temporal:///namespaces/ns/workflows/wid/rid/history?referenceType=RequestIdReference&requestID=req&eventType=NotAType'
157+
);
158+
const fakeLink = {
159+
url,
160+
type: WORKFLOW_EVENT_TYPE,
161+
};
162+
t.throws(() => convertNexusLinkToWorkflowEventLink(fakeLink as any), { message: /Unknown eventType parameter/ });
163+
});

packages/worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@temporalio/workflow": "file:../workflow",
2525
"abort-controller": "^3.0.0",
2626
"heap-js": "^2.3.0",
27+
"long": "^5.2.3",
2728
"memfs": "^4.6.0",
2829
"proto3-json-serializer": "^2.0.0",
2930
"protobufjs": "^7.2.5",
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import Long from 'long';
2+
import { Link as NexusLink } from 'nexus-rpc';
3+
import { temporal } from '@temporalio/proto';
4+
5+
const { EventType } = temporal.api.enums.v1;
6+
type WorkflowEventLink = temporal.api.common.v1.Link.IWorkflowEvent;
7+
type EventReference = temporal.api.common.v1.Link.WorkflowEvent.IEventReference;
8+
type RequestIdReference = temporal.api.common.v1.Link.WorkflowEvent.IRequestIdReference;
9+
10+
const LINK_EVENT_ID_PARAM = 'eventID';
11+
const LINK_EVENT_TYPE_PARAM = 'eventType';
12+
const LINK_REQUEST_ID_PARAM = 'requestID';
13+
14+
const EVENT_REFERENCE_TYPE = 'EventReference';
15+
const REQUEST_ID_REFERENCE_TYPE = 'RequestIdReference';
16+
17+
// fullName isn't part of the generated typed unfortunately.
18+
const WORKFLOW_EVENT_TYPE: string = (temporal.api.common.v1.Link.WorkflowEvent as any).fullName.slice(1);
19+
20+
function pascalCaseToConstantCase(s: string) {
21+
return s.replace(/[^\b][A-Z]/g, (m) => `${m[0]}_${m[1]}`).toUpperCase();
22+
}
23+
24+
function constantCaseToPascalCase(s: string) {
25+
return s.replace(/[A-Z]+_?/g, (m) => `${m[0]}${m.slice(1).toLocaleLowerCase()}`.replace(/_/, ''));
26+
}
27+
28+
function normalizeEnumValue(value: string, prefix: string) {
29+
value = pascalCaseToConstantCase(value);
30+
if (!value.startsWith(prefix)) {
31+
value = `${prefix}_${value}`;
32+
}
33+
return value;
34+
}
35+
36+
export function convertWorkflowEventLinkToNexusLink(we: WorkflowEventLink): NexusLink {
37+
if (!we.namespace || !we.workflowId || !we.runId) {
38+
throw new TypeError('Missing required fields: namespace, workflowId, or runId');
39+
}
40+
const url = new URL(
41+
`temporal:///namespaces/${encodeURIComponent(we.namespace)}/workflows/${encodeURIComponent(
42+
we.workflowId
43+
)}/${encodeURIComponent(we.runId)}/history`
44+
);
45+
46+
if (we.eventRef != null) {
47+
url.search = convertLinkWorkflowEventEventReferenceToURLQuery(we.eventRef);
48+
} else if (we.requestIdRef != null) {
49+
url.search = convertLinkWorkflowEventRequestIdReferenceToURLQuery(we.requestIdRef);
50+
}
51+
52+
return {
53+
url,
54+
type: WORKFLOW_EVENT_TYPE,
55+
};
56+
}
57+
58+
export function convertNexusLinkToWorkflowEventLink(link: NexusLink): WorkflowEventLink {
59+
if (link.url.protocol !== 'temporal:') {
60+
throw new TypeError(`Invalid URL scheme: ${link.url}, expected 'temporal:', got '${link.url.protocol}'`);
61+
}
62+
63+
// /namespaces/:namespace/workflows/:workflowId/:runId/history
64+
const parts = link.url.pathname.split('/');
65+
if (parts.length !== 7 || parts[1] !== 'namespaces' || parts[3] !== 'workflows' || parts[6] !== 'history') {
66+
throw new TypeError(`Invalid URL path: ${link.url}`);
67+
}
68+
const namespace = decodeURIComponent(parts[2]);
69+
const workflowId = decodeURIComponent(parts[4]);
70+
const runId = decodeURIComponent(parts[5]);
71+
72+
const query = link.url.searchParams;
73+
const refType = query.get('referenceType');
74+
75+
const workflowEventLink: WorkflowEventLink = {
76+
namespace,
77+
workflowId,
78+
runId,
79+
};
80+
81+
switch (refType) {
82+
case EVENT_REFERENCE_TYPE:
83+
workflowEventLink.eventRef = convertURLQueryToLinkWorkflowEventEventReference(query);
84+
break;
85+
case REQUEST_ID_REFERENCE_TYPE:
86+
workflowEventLink.requestIdRef = convertURLQueryToLinkWorkflowEventRequestIdReference(query);
87+
break;
88+
default:
89+
throw new TypeError(`Unknown reference type: ${refType}`);
90+
}
91+
return workflowEventLink;
92+
}
93+
94+
function convertLinkWorkflowEventEventReferenceToURLQuery(eventRef: EventReference): string {
95+
const params = new URLSearchParams();
96+
params.set('referenceType', EVENT_REFERENCE_TYPE);
97+
if (eventRef.eventId != null) {
98+
const eventId = eventRef.eventId.toNumber();
99+
if (eventId > 0) {
100+
params.set(LINK_EVENT_ID_PARAM, `${eventId}`);
101+
}
102+
}
103+
if (eventRef.eventType != null) {
104+
const eventType = constantCaseToPascalCase(EventType[eventRef.eventType].replace('EVENT_TYPE_', ''));
105+
params.set(LINK_EVENT_TYPE_PARAM, eventType);
106+
}
107+
return params.toString();
108+
}
109+
110+
function convertURLQueryToLinkWorkflowEventEventReference(query: URLSearchParams): EventReference {
111+
let eventId = 0;
112+
const eventIdParam = query.get(LINK_EVENT_ID_PARAM);
113+
if (eventIdParam && /^\d+$/.test(eventIdParam)) {
114+
eventId = parseInt(eventIdParam, 10);
115+
}
116+
const eventTypeParam = query.get(LINK_EVENT_TYPE_PARAM);
117+
if (!eventTypeParam) {
118+
throw new TypeError(`Missing eventType parameter`);
119+
}
120+
const eventType = EventType[normalizeEnumValue(eventTypeParam, 'EVENT_TYPE') as keyof typeof EventType];
121+
if (eventType == null) {
122+
throw new TypeError(`Unknown eventType parameter: ${eventTypeParam}`);
123+
}
124+
return { eventId: Long.fromNumber(eventId), eventType };
125+
}
126+
127+
function convertLinkWorkflowEventRequestIdReferenceToURLQuery(requestIdRef: RequestIdReference): string {
128+
const params = new URLSearchParams();
129+
params.set('referenceType', REQUEST_ID_REFERENCE_TYPE);
130+
if (requestIdRef.requestId != null) {
131+
params.set(LINK_REQUEST_ID_PARAM, requestIdRef.requestId);
132+
}
133+
if (requestIdRef.eventType != null) {
134+
const eventType = constantCaseToPascalCase(EventType[requestIdRef.eventType].replace('EVENT_TYPE_', ''));
135+
params.set(LINK_EVENT_TYPE_PARAM, eventType);
136+
}
137+
return params.toString();
138+
}
139+
140+
function convertURLQueryToLinkWorkflowEventRequestIdReference(query: URLSearchParams): RequestIdReference {
141+
const requestId = query.get(LINK_REQUEST_ID_PARAM);
142+
const eventTypeParam = query.get(LINK_EVENT_TYPE_PARAM);
143+
if (!eventTypeParam) {
144+
throw new TypeError(`Missing eventType parameter`);
145+
}
146+
const eventType = EventType[normalizeEnumValue(eventTypeParam, 'EVENT_TYPE') as keyof typeof EventType];
147+
if (eventType == null) {
148+
throw new TypeError(`Unknown eventType parameter: ${eventTypeParam}`);
149+
}
150+
return { requestId, eventType };
151+
}

0 commit comments

Comments
 (0)