44import static io .temporal .internal .common .ProtoEnumNameUtils .simplifiedToUniqueName ;
55import static io .temporal .internal .common .ProtoEnumNameUtils .uniqueToSimplifiedName ;
66
7+ import com .google .common .collect .ImmutableBiMap ;
8+ import io .temporal .api .common .v1 .Execution ;
79import io .temporal .api .common .v1 .Link ;
810import io .temporal .api .enums .v1 .EventType ;
11+ import io .temporal .api .enums .v1 .ExecutionType ;
912import java .io .UnsupportedEncodingException ;
1013import java .net .URI ;
1114import java .net .URLDecoder ;
2427 * Converts between {@link Link} (used on history events and RPCs) and {@link
2528 * io.temporal.api.nexus.v1.Link} (the Nexus wire form: a URL plus a type string).
2629 *
27- * <p>Four link types are supported, each with a fixed URL shape:
30+ * <p>The following link types are supported, each with a fixed URL shape:
2831 *
2932 * <pre>
3033 * WorkflowEvent temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}/history
3134 * Workflow temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}
3235 * NexusOperation temporal:///namespaces/{ns}/nexus-operations/{operationId}/{runId}/details
3336 * Activity temporal:///namespaces/{ns}/activities/{activityId}/{runId}/details
37+ * Callback temporal:///namespaces/{ns}/{executionType}/{businessId}/{runId}/callbacks/{requestId}
3438 * </pre>
3539 *
3640 * <p>Whoever decodes one of these URLs — the receiving server, or this class when a link arrives
@@ -54,42 +58,63 @@ public class LinkConverter {
5458 private static final String EVENT_TYPE_KEY = "eventType" ;
5559 private static final String REQUEST_ID_KEY = "requestID" ;
5660 private static final String REASON_KEY = "reason" ;
61+ private static final String COMPONENT_PATH_KEY = "componentPath" ;
62+
63+ /**
64+ * The path keyword for each execution type a callback can be attached to, in both directions:
65+ * encoding looks up the keyword, decoding validates it and maps it back. Matches the keywords of
66+ * the other link types, so every link addresses an execution the same way.
67+ */
68+ private static final ImmutableBiMap <ExecutionType , String > EXECUTION_TYPE_SEGMENTS =
69+ ImmutableBiMap .of (
70+ ExecutionType .EXECUTION_TYPE_WORKFLOW , "workflows" ,
71+ ExecutionType .EXECUTION_TYPE_ACTIVITY , "activities" ,
72+ ExecutionType .EXECUTION_TYPE_NEXUS_OPERATION , "nexus-operations" );
5773
5874 private static final String EVENT_REFERENCE_TYPE =
5975 Link .WorkflowEvent .EventReference .getDescriptor ().getName ();
6076 private static final String REQUEST_ID_REFERENCE_TYPE =
6177 Link .WorkflowEvent .RequestIdReference .getDescriptor ().getName ();
6278
6379 /**
64- * The four link types, as a table of (path keyword, path tail, proto type name).
80+ * The link types, as a table of (path keyword, path tail, tail ID , proto type name).
6581 *
66- * <p>The path is always {@code /namespaces/{ns}/{keyword}/{id}/{runId}[/{tail}]}, so a link has 5
67- * segments when {@link #tail} is null and 6 otherwise. Matching the count exactly is what keeps a
68- * Workflow link distinguishable from a WorkflowEvent link, since they share a keyword.
82+ * <p>The path is always {@code /namespaces/{ns}/{keyword}/{id}/{runId}[/{tail}[/{tailId}]]}, so a
83+ * link has 5 segments with no tail, 6 with a tail, and 7 with a tail ID. Matching the count
84+ * exactly is what keeps a Workflow link distinguishable from a WorkflowEvent link, since they
85+ * share a keyword.
6986 */
7087 private enum LinkType {
71- WORKFLOW_EVENT ("workflows" , "history" , Link .WorkflowEvent .getDescriptor ().getFullName ()),
88+ WORKFLOW_EVENT ("workflows" , "history" , false , Link .WorkflowEvent .getDescriptor ().getFullName ()),
7289
7390 /** A workflow execution as a whole, for when there is no history event to point at. */
74- WORKFLOW ("workflows" , null , Link .Workflow .getDescriptor ().getFullName ()),
91+ WORKFLOW ("workflows" , null , false , Link .Workflow .getDescriptor ().getFullName ()),
7592
7693 NEXUS_OPERATION (
77- "nexus-operations" , "details" , Link .NexusOperation .getDescriptor ().getFullName ()),
94+ "nexus-operations" , "details" , false , Link .NexusOperation .getDescriptor ().getFullName ()),
7895
79- ACTIVITY ("activities" , "details" , Link .Activity .getDescriptor ().getFullName ());
96+ ACTIVITY ("activities" , "details" , false , Link .Activity .getDescriptor ().getFullName ()),
8097
81- private final String keyword ;
98+ /**
99+ * A callback attached to an execution. The keyword names the execution's type, so it varies and
100+ * is any value of {@link #EXECUTION_TYPE_SEGMENTS}. The tail ID is the callback's request ID.
101+ */
102+ CALLBACK (null , "callbacks" , true , Link .Callback .getDescriptor ().getFullName ());
103+
104+ @ Nullable private final String keyword ;
82105 @ Nullable private final String tail ;
106+ private final boolean hasTailId ;
83107 private final String type ;
84108
85- LinkType (String keyword , @ Nullable String tail , String type ) {
109+ LinkType (@ Nullable String keyword , @ Nullable String tail , boolean hasTailId , String type ) {
86110 this .keyword = keyword ;
87111 this .tail = tail ;
112+ this .hasTailId = hasTailId ;
88113 this .type = type ;
89114 }
90115
91116 int segmentCount () {
92- return tail == null ? 5 : 6 ;
117+ return 5 + ( tail == null ? 0 : 1 ) + ( hasTailId ? 1 : 0 ) ;
93118 }
94119 }
95120
@@ -112,6 +137,9 @@ public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) {
112137 if (commonLink .hasActivity ()) {
113138 return activityToNexusLink (commonLink .getActivity ());
114139 }
140+ if (commonLink .hasCallback ()) {
141+ return callbackToNexusLink (commonLink .getCallback ());
142+ }
115143 return null ;
116144 }
117145
@@ -156,6 +184,30 @@ public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity ac
156184 Collections .emptyList ());
157185 }
158186
187+ @ Nullable
188+ public static io .temporal .api .nexus .v1 .Link callbackToNexusLink (Link .Callback cb ) {
189+ Execution execution = cb .getExecution ();
190+ String executionType = EXECUTION_TYPE_SEGMENTS .get (execution .getType ());
191+ if (executionType == null ) {
192+ log .error (
193+ "Failed to convert Callback link to Nexus link: unsupported execution type: {}" ,
194+ execution .getType ());
195+ return null ;
196+ }
197+ List <Map .Entry <String , String >> query = new ArrayList <>();
198+ for (String segment : cb .getComponentPathList ()) {
199+ query .add (param (COMPONENT_PATH_KEY , segment ));
200+ }
201+ return encode (
202+ LinkType .CALLBACK ,
203+ executionType ,
204+ cb .getNamespace (),
205+ execution .getBusinessId (),
206+ execution .getRunId (),
207+ cb .getRequestId (),
208+ query );
209+ }
210+
159211 // ===============================================================================================
160212 // Decode: nexus.v1.Link -> Link.
161213 // ===============================================================================================
@@ -176,6 +228,9 @@ public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) {
176228 if (LinkType .ACTIVITY .type .equals (type )) {
177229 return nexusLinkToActivity (nexusLink );
178230 }
231+ if (LinkType .CALLBACK .type .equals (type )) {
232+ return nexusLinkToCallback (nexusLink );
233+ }
179234 log .warn ("ignoring unsupported nexus link type: {}" , type );
180235 return null ;
181236 }
@@ -245,22 +300,58 @@ public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink)
245300 .build ();
246301 }
247302
303+ /** Inverse of {@link #callbackToNexusLink}. A missing component path decodes to an empty one. */
304+ @ Nullable
305+ public static Link nexusLinkToCallback (io .temporal .api .nexus .v1 .Link nexusLink ) {
306+ Decoded decoded = decode (LinkType .CALLBACK , nexusLink );
307+ if (decoded == null ) {
308+ return null ;
309+ }
310+ ExecutionType executionType = EXECUTION_TYPE_SEGMENTS .inverse ().get (decoded .keyword );
311+ return Link .newBuilder ()
312+ .setCallback (
313+ Link .Callback .newBuilder ()
314+ .setNamespace (decoded .namespace )
315+ .setExecution (
316+ Execution .newBuilder ()
317+ .setType (executionType )
318+ .setBusinessId (decoded .id )
319+ .setRunId (decoded .runId ))
320+ .setRequestId (decoded .tailId )
321+ .addAllComponentPath (decoded .componentPath ))
322+ .build ();
323+ }
324+
248325 // ===============================================================================================
249326 // Shared encode/decode.
250327 // ===============================================================================================
251328
329+ /** Builds a Nexus link URL for {@code linkType}, which must have a fixed keyword. */
330+ @ Nullable
331+ private static io .temporal .api .nexus .v1 .Link encode (
332+ LinkType linkType ,
333+ String namespace ,
334+ String id ,
335+ String runId ,
336+ List <Map .Entry <String , String >> queryParams ) {
337+ return encode (linkType , linkType .keyword , namespace , id , runId , null , queryParams );
338+ }
339+
252340 /**
253- * Builds a Nexus link URL for {@code linkType}.
341+ * Builds a Nexus link URL for {@code linkType}, with {@code keyword} in place of the type's own
342+ * for a type whose keyword varies, and {@code tailId} after the tail for a type that has one.
254343 *
255344 * <p>Concatenated rather than built with {@link URI}, because the segments are already
256345 * percent-encoded and {@link URI} would escape the escapes.
257346 */
258347 @ Nullable
259348 private static io .temporal .api .nexus .v1 .Link encode (
260349 LinkType linkType ,
350+ String keyword ,
261351 String namespace ,
262352 String id ,
263353 String runId ,
354+ @ Nullable String tailId ,
264355 List <Map .Entry <String , String >> queryParams ) {
265356 try {
266357 StringBuilder url =
@@ -270,14 +361,17 @@ private static io.temporal.api.nexus.v1.Link encode(
270361 .append ('/' )
271362 .append (encodePathSegment (namespace ))
272363 .append ('/' )
273- .append (linkType . keyword )
364+ .append (keyword )
274365 .append ('/' )
275366 .append (encodePathSegment (id ))
276367 .append ('/' )
277368 .append (encodePathSegment (runId ));
278369 if (linkType .tail != null ) {
279370 url .append ('/' ).append (linkType .tail );
280371 }
372+ if (tailId != null ) {
373+ url .append ('/' ).append (encodePathSegment (tailId ));
374+ }
281375 if (!queryParams .isEmpty ()) {
282376 url .append ('?' ).append (encodeQuery (queryParams ));
283377 }
@@ -291,18 +385,31 @@ private static io.temporal.api.nexus.v1.Link encode(
291385 }
292386 }
293387
294- /** The three path IDs plus the decoded query. */
388+ /** The path IDs, the execution keyword and the tail ID if any, plus the query. */
295389 private static final class Decoded {
296390 final String namespace ;
391+ final String keyword ;
297392 final String id ;
298393 final String runId ;
394+ @ Nullable final String tailId ;
299395 final Map <String , String > query ;
300-
301- Decoded (String namespace , String id , String runId , Map <String , String > query ) {
396+ final List <String > componentPath ;
397+
398+ Decoded (
399+ String namespace ,
400+ String keyword ,
401+ String id ,
402+ String runId ,
403+ @ Nullable String tailId ,
404+ @ Nullable String rawQuery )
405+ throws UnsupportedEncodingException {
302406 this .namespace = namespace ;
407+ this .keyword = keyword ;
303408 this .id = id ;
304409 this .runId = runId ;
305- this .query = query ;
410+ this .tailId = tailId ;
411+ this .query = decodeQuery (rawQuery );
412+ this .componentPath = decodeQueryValues (rawQuery );
306413 }
307414 }
308415
@@ -339,21 +446,29 @@ private static Decoded decode(LinkType linkType, io.temporal.api.nexus.v1.Link n
339446
340447 if (segments .length != linkType .segmentCount ()
341448 || !NAMESPACES_SEGMENT .equals (segments [0 ])
342- || ! linkType .keyword .equals (segments [2 ])
449+ || ( linkType . keyword != null && ! linkType .keyword .equals (segments [2 ]) )
343450 || (linkType .tail != null && !linkType .tail .equals (segments [5 ]))) {
344451 log .error ("Failed to parse Nexus link URL: invalid path: {}" , rawPath );
345452 return null ;
346453 }
347- if (segments [1 ].isEmpty () || segments [3 ].isEmpty () || segments [4 ].isEmpty ()) {
348- log .error ("Failed to parse Nexus link URL: empty path segment: {}" , rawPath );
454+ for (String segment : segments ) {
455+ if (segment .isEmpty ()) {
456+ log .error ("Failed to parse Nexus link URL: empty path segment: {}" , rawPath );
457+ return null ;
458+ }
459+ }
460+ if (linkType .keyword == null && !EXECUTION_TYPE_SEGMENTS .containsValue (segments [2 ])) {
461+ log .error ("Failed to parse Nexus link URL: unsupported execution type: {}" , segments [2 ]);
349462 return null ;
350463 }
351464
352465 return new Decoded (
353466 decodePathSegment (segments [1 ]),
467+ segments [2 ],
354468 decodePathSegment (segments [3 ]),
355469 decodePathSegment (segments [4 ]),
356- decodeQuery (uri .getRawQuery ()));
470+ linkType .hasTailId ? decodePathSegment (segments [6 ]) : null ,
471+ uri .getRawQuery ());
357472 } catch (Exception e ) {
358473 // Swallow un-parsable links since they are not critical to processing.
359474 log .error ("Failed to parse Nexus link URL" , e );
@@ -435,6 +550,26 @@ private static Map<String, String> decodeQuery(@Nullable String rawQuery)
435550 return params ;
436551 }
437552
553+ /**
554+ * Form-decodes every {@code componentPath} value in a raw query, in order. {@link #decodeQuery}
555+ * keeps only the first value of a repeated key, so this repeated param needs its own pass. A key
556+ * with no value yields an empty string, matching api-go's {@code Query()}.
557+ */
558+ private static List <String > decodeQueryValues (@ Nullable String rawQuery )
559+ throws UnsupportedEncodingException {
560+ List <String > values = new ArrayList <>();
561+ if (rawQuery == null || rawQuery .isEmpty ()) {
562+ return values ;
563+ }
564+ for (String pair : rawQuery .split ("&" )) {
565+ String [] kv = pair .split ("=" , 2 );
566+ if (!pair .isEmpty () && COMPONENT_PATH_KEY .equals (URLDecoder .decode (kv [0 ], UTF_8 ))) {
567+ values .add (kv .length == 2 ? URLDecoder .decode (kv [1 ], UTF_8 ) : "" );
568+ }
569+ }
570+ return values ;
571+ }
572+
438573 // ===============================================================================================
439574 // The WorkflowEvent "reference": WHICH event in the workflow's history the link points at.
440575 //
0 commit comments