• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

temporalio / sdk-java / #347

31 Jul 2026 06:59PM UTC coverage: 68.148% (-0.03%) from 68.181%
#347

push

github

web-flow
NEXUS-485: Support Workflow Update as a Nexus Operation (#2945)

* NEXUS-485: Support Workflow Update as a Nexus Operation

* address comments, change signatures to newer

* address comments 2: add all workflow exec overloads

* address comments: log failed

7166 of 12554 branches covered (57.08%)

Branch coverage included in aggregate %.

118 of 296 new or added lines in 11 files covered. (39.86%)

28 existing lines in 7 files now uncovered.

29722 of 41575 relevant lines covered (71.49%)

0.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.42
/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java
1
package io.temporal.internal.common;
2

3
import static io.temporal.internal.common.ProtoEnumNameUtils.*;
4

5
import io.temporal.api.common.v1.Link;
6
import io.temporal.api.enums.v1.EventType;
7
import java.io.UnsupportedEncodingException;
8
import java.net.URI;
9
import java.net.URLDecoder;
10
import java.net.URLEncoder;
11
import java.nio.charset.StandardCharsets;
12
import java.util.*;
13
import java.util.AbstractMap.SimpleImmutableEntry;
14
import java.util.stream.Collectors;
15
import org.slf4j.Logger;
16
import org.slf4j.LoggerFactory;
17

18
public class LinkConverter {
×
19

20
  private static final Logger log = LoggerFactory.getLogger(LinkConverter.class);
1✔
21

22
  private static final String temporalUrlScheme = "temporal";
23
  private static final String linkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s/history";
24
  private static final String nexusOperationLinkPathFormat =
25
      "temporal:///namespaces/%s/nexus-operations/%s/%s/details";
26
  private static final String linkReferenceTypeKey = "referenceType";
27
  private static final String linkEventIDKey = "eventID";
28
  private static final String linkEventTypeKey = "eventType";
29
  private static final String linkRequestIDKey = "requestID";
30

31
  private static final String eventReferenceType =
32
      Link.WorkflowEvent.EventReference.getDescriptor().getName();
1✔
33
  private static final String requestIDReferenceType =
34
      Link.WorkflowEvent.RequestIdReference.getDescriptor().getName();
1✔
35
  private static final String workflowEventLinkType =
36
      Link.WorkflowEvent.getDescriptor().getFullName();
1✔
37
  private static final String nexusOperationLinkType =
38
      Link.NexusOperation.getDescriptor().getFullName();
1✔
39
  private static final String workflowLinkType = Link.Workflow.getDescriptor().getFullName();
1✔
40

41
  public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) {
42
    try {
43

44
      String url =
1✔
45
          String.format(
1✔
46
              linkPathFormat,
47
              URLEncoder.encode(we.getNamespace(), StandardCharsets.UTF_8.toString()),
1✔
48
              // The 'replace' below handles spaces - the encoder will convert them to a plus,
49
              // which the UI then handles as a plus, thus breaking the link as the
50
              // space is lost.
51
              // It's a known quirk with the URLEncoder as it encodes for forms, not general URIs.
52
              // Only done for the WorkflowId as the other two are values we control,
53
              // and will never have spaces.
54
              URLEncoder.encode(we.getWorkflowId(), StandardCharsets.UTF_8.toString())
1✔
55
                  .replace("+", "%20"),
1✔
56
              URLEncoder.encode(we.getRunId(), StandardCharsets.UTF_8.toString()));
1✔
57

58
      List<Map.Entry<String, String>> queryParams = new ArrayList<>();
1✔
59
      if (we.hasEventRef()) {
1✔
60
        queryParams.add(new SimpleImmutableEntry<>(linkReferenceTypeKey, eventReferenceType));
1✔
61
        Link.WorkflowEvent.EventReference eventRef = we.getEventRef();
1✔
62
        if (eventRef.getEventId() > 0) {
1✔
63
          queryParams.add(
1✔
64
              new SimpleImmutableEntry<>(linkEventIDKey, String.valueOf(eventRef.getEventId())));
1✔
65
        }
66
        final String eventType =
1✔
67
            URLEncoder.encode(
1✔
68
                encodeEventType(eventRef.getEventType()), StandardCharsets.UTF_8.toString());
1✔
69
        queryParams.add(new SimpleImmutableEntry<>(linkEventTypeKey, eventType));
1✔
70
      } else if (we.hasRequestIdRef()) {
1!
71
        queryParams.add(new SimpleImmutableEntry<>(linkReferenceTypeKey, requestIDReferenceType));
1✔
72
        Link.WorkflowEvent.RequestIdReference requestIDRef = we.getRequestIdRef();
1✔
73
        final String requestID =
1✔
74
            URLEncoder.encode(requestIDRef.getRequestId(), StandardCharsets.UTF_8.toString());
1✔
75
        queryParams.add(new SimpleImmutableEntry<>(linkRequestIDKey, requestID));
1✔
76
        final String eventType =
1✔
77
            URLEncoder.encode(
1✔
78
                encodeEventType(requestIDRef.getEventType()), StandardCharsets.UTF_8.toString());
1✔
79
        queryParams.add(new SimpleImmutableEntry<>(linkEventTypeKey, eventType));
1✔
80
      }
81

82
      url +=
1✔
83
          "?"
84
              + queryParams.stream()
1✔
85
                  .map((item) -> item.getKey() + "=" + item.getValue())
1✔
86
                  .collect(Collectors.joining("&"));
1✔
87

88
      return io.temporal.api.nexus.v1.Link.newBuilder()
1✔
89
          .setUrl(url)
1✔
90
          .setType(we.getDescriptorForType().getFullName())
1✔
91
          .build();
1✔
92
    } catch (Exception e) {
×
93
      log.error("Failed to encode Nexus link URL", e);
×
94
    }
95
    return null;
×
96
  }
97

98
  public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) {
99
    try {
NEW
100
      String namespace = URLEncoder.encode(w.getNamespace(), StandardCharsets.UTF_8.toString());
×
NEW
101
      String workflowId =
×
NEW
102
          URLEncoder.encode(w.getWorkflowId(), StandardCharsets.UTF_8.toString())
×
NEW
103
              .replace("+", "%20"); // handle workflowIds supporting spaces
×
NEW
104
      String runId = URLEncoder.encode(w.getRunId(), StandardCharsets.UTF_8.toString());
×
NEW
105
      String url = String.format(linkPathFormat, namespace, workflowId, runId);
×
NEW
106
      return io.temporal.api.nexus.v1.Link.newBuilder()
×
NEW
107
          .setUrl(url)
×
NEW
108
          .setType(workflowLinkType)
×
NEW
109
          .build();
×
NEW
110
    } catch (Exception e) {
×
NEW
111
      log.error("Failed to convert WorkflowLink {} to NexusLink", w, e);
×
NEW
112
      return null;
×
113
    }
114
  }
115

116
  public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusLink) {
117
    Link.Builder link = Link.newBuilder();
1✔
118
    try {
119
      URI uri = new URI(nexusLink.getUrl());
1✔
120

121
      if (!uri.getScheme().equals("temporal")) {
1✔
122
        log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme());
1✔
123
        return null;
1✔
124
      }
125

126
      StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/");
1✔
127
      if (!st.nextToken().equals("namespaces")) {
1!
128
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
129
        return null;
×
130
      }
131
      String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
132
      if (!st.nextToken().equals("workflows")) {
1✔
133
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
1✔
134
        return null;
1✔
135
      }
136
      String workflowID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
137
      String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
138
      if (!st.hasMoreTokens() || !st.nextToken().equals("history")) {
1!
139
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
1✔
140
        return null;
1✔
141
      }
142

143
      Link.WorkflowEvent.Builder we =
144
          Link.WorkflowEvent.newBuilder()
1✔
145
              .setNamespace(namespace)
1✔
146
              .setWorkflowId(workflowID)
1✔
147
              .setRunId(runID);
1✔
148

149
      Map<String, String> queryParams = parseQueryParams(uri);
1✔
150
      String referenceType = queryParams.get(linkReferenceTypeKey);
1✔
151
      if (referenceType.equals(eventReferenceType)) {
1✔
152
        Link.WorkflowEvent.EventReference.Builder eventRef =
153
            Link.WorkflowEvent.EventReference.newBuilder();
1✔
154
        String eventID = queryParams.get(linkEventIDKey);
1✔
155
        if (eventID != null && !eventID.isEmpty()) {
1!
156
          eventRef.setEventId(Long.parseLong(eventID));
1✔
157
        }
158
        String eventType = queryParams.get(linkEventTypeKey);
1✔
159
        if (eventType != null && !eventType.isEmpty()) {
1!
160
          eventRef.setEventType(decodeEventType(eventType));
1✔
161
        }
162
        we.setEventRef(eventRef);
1✔
163
      } else if (referenceType.equals(requestIDReferenceType)) {
1!
164
        Link.WorkflowEvent.RequestIdReference.Builder requestIDRef =
165
            Link.WorkflowEvent.RequestIdReference.newBuilder();
1✔
166
        String requestID = queryParams.get(linkRequestIDKey);
1✔
167
        if (requestID != null && !requestID.isEmpty()) {
1!
168
          requestIDRef.setRequestId(requestID);
1✔
169
        }
170
        String eventType = queryParams.get(linkEventTypeKey);
1✔
171
        if (eventType != null && !eventType.isEmpty()) {
1!
172
          requestIDRef.setEventType(decodeEventType(eventType));
1✔
173
        }
174
        we.setRequestIdRef(requestIDRef);
1✔
175
      } else {
1✔
176
        log.error("Failed to parse Nexus link URL: invalid reference type: {}", referenceType);
×
177
        return null;
×
178
      }
179

180
      link.setWorkflowEvent(we);
1✔
181
    } catch (Exception e) {
1✔
182
      // Swallow un-parsable links since they are not critical to processing
183
      log.error("Failed to parse Nexus link URL", e);
1✔
184
      return null;
1✔
185
    }
1✔
186
    return link.build();
1✔
187
  }
188

189
  public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) {
NEW
190
    Link.Builder link = Link.newBuilder();
×
191
    try {
NEW
192
      URI uri = new URI(nexusLink.getUrl());
×
NEW
193
      log.debug("Parsing nexus link URL: {}", uri.getRawPath());
×
NEW
194
      if (!uri.getScheme().equals(temporalUrlScheme)) {
×
NEW
195
        log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme());
×
NEW
196
        return null;
×
197
      }
NEW
198
      StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/");
×
199
      // maybe add constants for "namespaces", "workflows" too
NEW
200
      if (!st.nextToken().equals("namespaces")) {
×
NEW
201
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
NEW
202
        return null;
×
203
      }
NEW
204
      String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
×
NEW
205
      if (!st.nextToken().equals("workflows")) {
×
NEW
206
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
NEW
207
        return null;
×
208
      }
NEW
209
      String workflowID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
×
NEW
210
      if (!st.hasMoreTokens()) {
×
NEW
211
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
NEW
212
        return null;
×
213
      }
NEW
214
      String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
×
NEW
215
      link.setWorkflow(
×
NEW
216
          Link.Workflow.newBuilder()
×
NEW
217
              .setNamespace(namespace)
×
NEW
218
              .setWorkflowId(workflowID)
×
NEW
219
              .setRunId(runID));
×
NEW
220
    } catch (Exception e) {
×
NEW
221
      log.error("Failed to convert NexusLink {} to WorkflowLink", nexusLink, e);
×
NEW
222
      return null;
×
NEW
223
    }
×
NEW
224
    return link.build();
×
225
  }
226

227
  /**
228
   * Dispatches on the oneof variant of {@code commonLink} and converts to the matching {@link
229
   * io.temporal.api.nexus.v1.Link}. Returns {@code null} if no variant is set or encoding fails.
230
   */
231
  public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) {
232
    if (commonLink.hasWorkflowEvent()) {
1✔
233
      return workflowEventToNexusLink(commonLink.getWorkflowEvent());
1✔
234
    }
235
    if (commonLink.hasNexusOperation()) {
1✔
236
      return nexusOperationToNexusLink(commonLink.getNexusOperation());
1✔
237
    }
238
    if (commonLink.hasWorkflow()) {
1!
NEW
239
      return workflowLinkToNexusLink(commonLink.getWorkflow());
×
240
    }
241
    return null;
1✔
242
  }
243

244
  /**
245
   * Dispatches on {@link io.temporal.api.nexus.v1.Link#getType()} and converts to the matching
246
   * {@link Link} variant. Returns {@code null} for unknown or unparseable types.
247
   */
248
  public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) {
249
    String type = nexusLink.getType();
1✔
250
    if (workflowEventLinkType.equals(type)) {
1✔
251
      return nexusLinkToWorkflowEvent(nexusLink);
1✔
252
    }
253
    if (nexusOperationLinkType.equals(type)) {
1✔
254
      return nexusLinkToNexusOperation(nexusLink);
1✔
255
    }
256
    if (workflowLinkType.equals(type)) {
1!
NEW
257
      return nexusLinkToWorkflowLink(nexusLink);
×
258
    }
259
    log.warn("ignoring unsupported nexus link type: {}", type);
1✔
260
    return null;
1✔
261
  }
262

263
  public static io.temporal.api.nexus.v1.Link nexusOperationToNexusLink(Link.NexusOperation no) {
264
    try {
265
      String url =
1✔
266
          String.format(
1✔
267
              nexusOperationLinkPathFormat,
268
              URLEncoder.encode(no.getNamespace(), StandardCharsets.UTF_8.toString()),
1✔
269
              // See the WorkflowId comment in workflowEventToNexusLink for why '+' is rewritten to
270
              // '%20'. OperationId is user-supplied and can legally contain spaces.
271
              URLEncoder.encode(no.getOperationId(), StandardCharsets.UTF_8.toString())
1✔
272
                  .replace("+", "%20"),
1✔
273
              URLEncoder.encode(no.getRunId(), StandardCharsets.UTF_8.toString()));
1✔
274
      return io.temporal.api.nexus.v1.Link.newBuilder()
1✔
275
          .setUrl(url)
1✔
276
          .setType(nexusOperationLinkType)
1✔
277
          .build();
1✔
278
    } catch (Exception e) {
×
279
      log.error("Failed to encode Nexus operation link URL", e);
×
280
    }
281
    return null;
×
282
  }
283

284
  public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexusLink) {
285
    if (!nexusOperationLinkType.equals(nexusLink.getType())) {
1✔
286
      log.error(
1✔
287
          "Failed to parse Nexus link URL: cannot parse link type {} to {}",
288
          nexusLink.getType(),
1✔
289
          nexusOperationLinkType);
290
      return null;
1✔
291
    }
292
    Link.Builder link = Link.newBuilder();
1✔
293
    try {
294
      URI uri = new URI(nexusLink.getUrl());
1✔
295

296
      if (!"temporal".equals(uri.getScheme())) {
1✔
297
        log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme());
1✔
298
        return null;
1✔
299
      }
300

301
      StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/");
1✔
302
      if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) {
1!
303
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
304
        return null;
×
305
      }
306
      String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
307
      if (!st.hasMoreTokens() || !st.nextToken().equals("nexus-operations")) {
1!
308
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
309
        return null;
×
310
      }
311
      String operationId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
312
      if (!st.hasMoreTokens()) {
1!
313
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
314
        return null;
×
315
      }
316
      String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
317
      if (!st.hasMoreTokens() || !st.nextToken().equals("details")) {
1!
318
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
1✔
319
        return null;
1✔
320
      }
321

322
      link.setNexusOperation(
1✔
323
          Link.NexusOperation.newBuilder()
1✔
324
              .setNamespace(namespace)
1✔
325
              .setOperationId(operationId)
1✔
326
              .setRunId(runId));
1✔
327
    } catch (Exception e) {
×
328
      log.error("Failed to parse Nexus link URL", e);
×
329
      return null;
×
330
    }
1✔
331
    return link.build();
1✔
332
  }
333

334
  private static Map<String, String> parseQueryParams(URI uri) throws UnsupportedEncodingException {
335
    final String query = uri.getQuery();
1✔
336
    if (query == null || query.isEmpty()) {
1!
337
      return Collections.emptyMap();
×
338
    }
339
    Map<String, String> queryParams = new HashMap<>();
1✔
340
    for (String pair : query.split("&")) {
1✔
341
      final String[] kv = pair.split("=", 2);
1✔
342
      final String key = URLDecoder.decode(kv[0], StandardCharsets.UTF_8.toString());
1✔
343
      final String value =
344
          kv.length == 2 && !kv[1].isEmpty()
1!
345
              ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8.toString())
1✔
346
              : null;
1✔
347
      queryParams.put(key, value);
1✔
348
    }
349
    return queryParams;
1✔
350
  }
351

352
  private static String encodeEventType(EventType eventType) {
353
    return uniqueToSimplifiedName(eventType.name(), EVENT_TYPE_PREFIX);
1✔
354
  }
355

356
  private static EventType decodeEventType(String eventType) {
357
    // Have to handle the SCREAMING_CASE enum or the traditional temporal PascalCase enum to
358
    // EventType
359
    if (eventType.startsWith(EVENT_TYPE_PREFIX)) {
1✔
360
      return EventType.valueOf(eventType);
1✔
361
    }
362
    return EventType.valueOf(simplifiedToUniqueName(eventType, EVENT_TYPE_PREFIX));
1✔
363
  }
364
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc