diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java b/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java index ae56af47d1..bbdc0b926e 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java @@ -1,6 +1,8 @@ package io.temporal.internal.common; -import static io.temporal.internal.common.ProtoEnumNameUtils.*; +import static io.temporal.internal.common.ProtoEnumNameUtils.EVENT_TYPE_PREFIX; +import static io.temporal.internal.common.ProtoEnumNameUtils.simplifiedToUniqueName; +import static io.temporal.internal.common.ProtoEnumNameUtils.uniqueToSimplifiedName; import io.temporal.api.common.v1.Link; import io.temporal.api.enums.v1.EventType; @@ -9,263 +11,94 @@ import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.AbstractMap.SimpleImmutableEntry; -import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Converts between {@link Link} (used on history events and RPCs) and {@link + * io.temporal.api.nexus.v1.Link} (the Nexus wire form: a URL plus a type string). + * + *

Four link types are supported, each with a fixed URL shape: + * + *

+ *   WorkflowEvent    temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}/history
+ *   Workflow         temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}
+ *   NexusOperation   temporal:///namespaces/{ns}/nexus-operations/{operationId}/{runId}/details
+ *   Activity         temporal:///namespaces/{ns}/activities/{activityId}/{runId}/details
+ * 
+ * + *

Whoever decodes one of these URLs — the receiving server, or this class when a link arrives + * here — applies standard URL semantics: path segments are percent-decoded and query values are + * form-decoded. The two rules differ on {@code +}, so the codecs here are deliberately asymmetric — + * see {@link #encodePathSegment} and {@link #decodeQuery}. + * + *

Every method returns {@code null} rather than throwing when a link is malformed. Links are + * decorative metadata attached to a Nexus call, so a bad one must never fail the call carrying it. + */ public class LinkConverter { private static final Logger log = LoggerFactory.getLogger(LinkConverter.class); - private static final String temporalUrlScheme = "temporal"; - private static final String linkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s/history"; - private static final String nexusOperationLinkPathFormat = - "temporal:///namespaces/%s/nexus-operations/%s/%s/details"; - private static final String activityLinkPathFormat = - "temporal:///namespaces/%s/activities/%s/%s/details"; - private static final String workflowLinkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s"; - private static final String linkReferenceTypeKey = "referenceType"; - private static final String linkEventIDKey = "eventID"; - private static final String linkEventTypeKey = "eventType"; - private static final String linkRequestIDKey = "requestID"; - private static final String linkReasonKey = "reason"; - - private static final String eventReferenceType = - Link.WorkflowEvent.EventReference.getDescriptor().getName(); - private static final String requestIDReferenceType = - Link.WorkflowEvent.RequestIdReference.getDescriptor().getName(); - private static final String workflowEventLinkType = - Link.WorkflowEvent.getDescriptor().getFullName(); - private static final String nexusOperationLinkType = - Link.NexusOperation.getDescriptor().getFullName(); - private static final String workflowLinkType = Link.Workflow.getDescriptor().getFullName(); - private static final String activityLinkType = Link.Activity.getDescriptor().getFullName(); - - public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) { - try { + private static final String SCHEME = "temporal"; + private static final String UTF_8 = StandardCharsets.UTF_8.name(); + private static final String NAMESPACES_SEGMENT = "namespaces"; - String url = - String.format( - linkPathFormat, - URLEncoder.encode(we.getNamespace(), StandardCharsets.UTF_8.toString()), - // The 'replace' below handles spaces - the encoder will convert them to a plus, - // which the UI then handles as a plus, thus breaking the link as the - // space is lost. - // It's a known quirk with the URLEncoder as it encodes for forms, not general URIs. - // Only done for the WorkflowId as the other two are values we control, - // and will never have spaces. - URLEncoder.encode(we.getWorkflowId(), StandardCharsets.UTF_8.toString()) - .replace("+", "%20"), - URLEncoder.encode(we.getRunId(), StandardCharsets.UTF_8.toString())); - - List> queryParams = new ArrayList<>(); - if (we.hasEventRef()) { - queryParams.add(new SimpleImmutableEntry<>(linkReferenceTypeKey, eventReferenceType)); - Link.WorkflowEvent.EventReference eventRef = we.getEventRef(); - if (eventRef.getEventId() > 0) { - queryParams.add( - new SimpleImmutableEntry<>(linkEventIDKey, String.valueOf(eventRef.getEventId()))); - } - final String eventType = - URLEncoder.encode( - encodeEventType(eventRef.getEventType()), StandardCharsets.UTF_8.toString()); - queryParams.add(new SimpleImmutableEntry<>(linkEventTypeKey, eventType)); - } else if (we.hasRequestIdRef()) { - queryParams.add(new SimpleImmutableEntry<>(linkReferenceTypeKey, requestIDReferenceType)); - Link.WorkflowEvent.RequestIdReference requestIDRef = we.getRequestIdRef(); - final String requestID = - URLEncoder.encode(requestIDRef.getRequestId(), StandardCharsets.UTF_8.toString()); - queryParams.add(new SimpleImmutableEntry<>(linkRequestIDKey, requestID)); - final String eventType = - URLEncoder.encode( - encodeEventType(requestIDRef.getEventType()), StandardCharsets.UTF_8.toString()); - queryParams.add(new SimpleImmutableEntry<>(linkEventTypeKey, eventType)); - } + private static final String REFERENCE_TYPE_KEY = "referenceType"; + private static final String EVENT_ID_KEY = "eventID"; + private static final String EVENT_TYPE_KEY = "eventType"; + private static final String REQUEST_ID_KEY = "requestID"; + private static final String REASON_KEY = "reason"; - url += - "?" - + queryParams.stream() - .map((item) -> item.getKey() + "=" + item.getValue()) - .collect(Collectors.joining("&")); - - return io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl(url) - .setType(we.getDescriptorForType().getFullName()) - .build(); - } catch (Exception e) { - log.error("Failed to encode Nexus link URL", e); - } - return null; - } + private static final String EVENT_REFERENCE_TYPE = + Link.WorkflowEvent.EventReference.getDescriptor().getName(); + private static final String REQUEST_ID_REFERENCE_TYPE = + Link.WorkflowEvent.RequestIdReference.getDescriptor().getName(); /** - * Converts a {@link Link.Workflow} to a Nexus link. A workflow link addresses a workflow - * execution as a whole rather than one event within it, so the URL uses the workflow path and - * carries no event path suffix and no reference query params. It is used when there is no history - * event to point at, for example a Query or a rejected Update. The optional {@code reason} - * explaining why the link exists is carried as a query param. + * The four link types, as a table of (path keyword, path tail, proto type name). + * + *

The path is always {@code /namespaces/{ns}/{keyword}/{id}/{runId}[/{tail}]}, so a link has 5 + * segments when {@link #tail} is null and 6 otherwise. Matching the count exactly is what keeps a + * Workflow link distinguishable from a WorkflowEvent link, since they share a keyword. */ - public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) { - try { - String url = - String.format( - workflowLinkPathFormat, - encodePathSegment(w.getNamespace()), - encodePathSegment(w.getWorkflowId()), - encodePathSegment(w.getRunId())); - if (!w.getReason().isEmpty()) { - url += - "?" - + linkReasonKey - + "=" - + URLEncoder.encode(w.getReason(), StandardCharsets.UTF_8.toString()); - } - return io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl(url) - .setType(workflowLinkType) - .build(); - } catch (Exception e) { - log.error("Failed to convert WorkflowLink {} to NexusLink", w, e); - return null; - } - } + private enum LinkType { + WORKFLOW_EVENT("workflows", "history", Link.WorkflowEvent.getDescriptor().getFullName()), - public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusLink) { - Link.Builder link = Link.newBuilder(); - try { - URI uri = new URI(nexusLink.getUrl()); + /** A workflow execution as a whole, for when there is no history event to point at. */ + WORKFLOW("workflows", null, Link.Workflow.getDescriptor().getFullName()), - if (!uri.getScheme().equals("temporal")) { - log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); - return null; - } + NEXUS_OPERATION( + "nexus-operations", "details", Link.NexusOperation.getDescriptor().getFullName()), - StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - if (!st.nextToken().equals("namespaces")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String namespace = decodePathSegment(st.nextToken()); - if (!st.nextToken().equals("workflows")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String workflowID = decodePathSegment(st.nextToken()); - String runID = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("history")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } + ACTIVITY("activities", "details", Link.Activity.getDescriptor().getFullName()); - Link.WorkflowEvent.Builder we = - Link.WorkflowEvent.newBuilder() - .setNamespace(namespace) - .setWorkflowId(workflowID) - .setRunId(runID); - - Map queryParams = parseQueryParams(uri); - String referenceType = queryParams.get(linkReferenceTypeKey); - if (referenceType.equals(eventReferenceType)) { - Link.WorkflowEvent.EventReference.Builder eventRef = - Link.WorkflowEvent.EventReference.newBuilder(); - String eventID = queryParams.get(linkEventIDKey); - if (eventID != null && !eventID.isEmpty()) { - eventRef.setEventId(Long.parseLong(eventID)); - } - String eventType = queryParams.get(linkEventTypeKey); - if (eventType != null && !eventType.isEmpty()) { - eventRef.setEventType(decodeEventType(eventType)); - } - we.setEventRef(eventRef); - } else if (referenceType.equals(requestIDReferenceType)) { - Link.WorkflowEvent.RequestIdReference.Builder requestIDRef = - Link.WorkflowEvent.RequestIdReference.newBuilder(); - String requestID = queryParams.get(linkRequestIDKey); - if (requestID != null && !requestID.isEmpty()) { - requestIDRef.setRequestId(requestID); - } - String eventType = queryParams.get(linkEventTypeKey); - if (eventType != null && !eventType.isEmpty()) { - requestIDRef.setEventType(decodeEventType(eventType)); - } - we.setRequestIdRef(requestIDRef); - } else { - log.error("Failed to parse Nexus link URL: invalid reference type: {}", referenceType); - return null; - } + private final String keyword; + @Nullable private final String tail; + private final String type; - link.setWorkflowEvent(we); - } catch (Exception e) { - // Swallow un-parsable links since they are not critical to processing - log.error("Failed to parse Nexus link URL", e); - return null; + LinkType(String keyword, @Nullable String tail, String type) { + this.keyword = keyword; + this.tail = tail; + this.type = type; } - return link.build(); - } - public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) { - if (!workflowLinkType.equals(nexusLink.getType())) { - log.error( - "Failed to parse Nexus link URL: cannot parse link type {} to {}", - nexusLink.getType(), - workflowLinkType); - return null; - } - Link.Builder link = Link.newBuilder(); - try { - URI uri = new URI(nexusLink.getUrl()); - - // Compared in this order so a URL with no scheme at all reports the invalid scheme rather - // than throwing. - if (!temporalUrlScheme.equals(uri.getScheme())) { - log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); - return null; - } - - StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - if (!st.nextToken().equals("namespaces")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String namespace = decodePathSegment(st.nextToken()); - if (!st.nextToken().equals("workflows")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String workflowID = decodePathSegment(st.nextToken()); - String runID = decodePathSegment(st.nextToken()); - // The run ID ends a workflow link, so anything trailing means this is a different link - // shape. In particular this rejects the workflow-event form, which ends in "/history". - if (st.hasMoreTokens()) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - - Link.Workflow.Builder w = - Link.Workflow.newBuilder() - .setNamespace(namespace) - .setWorkflowId(workflowID) - .setRunId(runID); - String reason = rawQueryParam(uri, linkReasonKey); - if (reason != null) { - w.setReason(reason); - } - - link.setWorkflow(w); - } catch (Exception e) { - // Swallow un-parsable links since they are not critical to processing. - log.error("Failed to parse Nexus link URL", e); - return null; + int segmentCount() { + return tail == null ? 5 : 6; } - return link.build(); } - /** - * Dispatches on the oneof variant of {@code commonLink} and converts to the matching {@link - * io.temporal.api.nexus.v1.Link}. Returns {@code null} if no variant is set or encoding fails. - */ + // =============================================================================================== + // Encode: Link -> nexus.v1.Link. + // =============================================================================================== + + /** Dispatches on the oneof variant of {@code commonLink}. Returns null if no variant is set. */ + @Nullable public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) { if (commonLink.hasWorkflowEvent()) { return workflowEventToNexusLink(commonLink.getWorkflowEvent()); @@ -282,229 +115,440 @@ public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) { return null; } - /** - * Dispatches on {@link io.temporal.api.nexus.v1.Link#getType()} and converts to the matching - * {@link Link} variant. Returns {@code null} for unknown or unparseable types. - */ + public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) { + List> reference; + try { + reference = encodeReference(we); + } catch (Exception e) { + // Guarded separately because this runs before encode() is entered, so encode()'s own catch + // does not cover it. encodeEventType rejects an event type these protos do not know + // (EventType.UNRECOGNIZED), which a server newer than this SDK can send. + log.error("Failed to encode Nexus link reference", e); + return null; + } + return encode( + LinkType.WORKFLOW_EVENT, we.getNamespace(), we.getWorkflowId(), we.getRunId(), reference); + } + + public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) { + List> query = new ArrayList<>(); + if (!w.getReason().isEmpty()) { + query.add(param(REASON_KEY, w.getReason())); + } + return encode(LinkType.WORKFLOW, w.getNamespace(), w.getWorkflowId(), w.getRunId(), query); + } + + public static io.temporal.api.nexus.v1.Link nexusOperationToNexusLink(Link.NexusOperation no) { + return encode( + LinkType.NEXUS_OPERATION, + no.getNamespace(), + no.getOperationId(), + no.getRunId(), + Collections.emptyList()); + } + + public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity activity) { + return encode( + LinkType.ACTIVITY, + activity.getNamespace(), + activity.getActivityId(), + activity.getRunId(), + Collections.emptyList()); + } + + // =============================================================================================== + // Decode: nexus.v1.Link -> Link. + // =============================================================================================== + + /** Dispatches on {@link io.temporal.api.nexus.v1.Link#getType()}. */ + @Nullable public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) { String type = nexusLink.getType(); - if (workflowEventLinkType.equals(type)) { + if (LinkType.WORKFLOW_EVENT.type.equals(type)) { return nexusLinkToWorkflowEvent(nexusLink); } - if (nexusOperationLinkType.equals(type)) { + if (LinkType.NEXUS_OPERATION.type.equals(type)) { return nexusLinkToNexusOperation(nexusLink); } - if (workflowLinkType.equals(type)) { + if (LinkType.WORKFLOW.type.equals(type)) { return nexusLinkToWorkflowLink(nexusLink); } - if (activityLinkType.equals(type)) { + if (LinkType.ACTIVITY.type.equals(type)) { return nexusLinkToActivity(nexusLink); } log.warn("ignoring unsupported nexus link type: {}", type); return null; } - public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity activity) { - try { - String url = - String.format( - activityLinkPathFormat, - URLEncoder.encode(activity.getNamespace(), StandardCharsets.UTF_8.toString()), - URLEncoder.encode(activity.getActivityId(), StandardCharsets.UTF_8.toString()) - .replace("+", "%20"), - URLEncoder.encode(activity.getRunId(), StandardCharsets.UTF_8.toString())); - return io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl(url) - .setType(activityLinkType) - .build(); - } catch (Exception e) { - log.error("Failed to encode activity Nexus link URL", e); + @Nullable + public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.WORKFLOW_EVENT, nexusLink); + if (decoded == null) { + return null; } - return null; + Link.WorkflowEvent.Builder we = + Link.WorkflowEvent.newBuilder() + .setNamespace(decoded.namespace) + .setWorkflowId(decoded.id) + .setRunId(decoded.runId); + if (!decodeReference(we, decoded.query)) { + return null; + } + return Link.newBuilder().setWorkflowEvent(we).build(); } - public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) { - if (!activityLinkType.equals(nexusLink.getType())) { - log.error( - "Failed to parse Nexus link URL: cannot parse link type {} to {}", - nexusLink.getType(), - activityLinkType); + @Nullable + public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.WORKFLOW, nexusLink); + if (decoded == null) { return null; } - Link.Builder link = Link.newBuilder(); - try { - URI uri = new URI(nexusLink.getUrl()); - if (!"temporal".equals(uri.getScheme())) { - log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); - return null; - } - StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String namespace = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("activities")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String activityId = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens()) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String runId = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("details")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - link.setActivity( - Link.Activity.newBuilder() - .setNamespace(namespace) - .setActivityId(activityId) - .setRunId(runId)); - } catch (Exception e) { - log.error("Failed to parse activity Nexus link URL", e); + Link.Workflow.Builder w = + Link.Workflow.newBuilder() + .setNamespace(decoded.namespace) + .setWorkflowId(decoded.id) + .setRunId(decoded.runId); + String reason = decoded.query.get(REASON_KEY); + if (reason != null) { + w.setReason(reason); + } + return Link.newBuilder().setWorkflow(w).build(); + } + + @Nullable + public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.NEXUS_OPERATION, nexusLink); + if (decoded == null) { return null; } - return link.build(); + return Link.newBuilder() + .setNexusOperation( + Link.NexusOperation.newBuilder() + .setNamespace(decoded.namespace) + .setOperationId(decoded.id) + .setRunId(decoded.runId)) + .build(); } - public static io.temporal.api.nexus.v1.Link nexusOperationToNexusLink(Link.NexusOperation no) { + @Nullable + public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.ACTIVITY, nexusLink); + if (decoded == null) { + return null; + } + return Link.newBuilder() + .setActivity( + Link.Activity.newBuilder() + .setNamespace(decoded.namespace) + .setActivityId(decoded.id) + .setRunId(decoded.runId)) + .build(); + } + + // =============================================================================================== + // Shared encode/decode. + // =============================================================================================== + + /** + * Builds a Nexus link URL for {@code linkType}. + * + *

Concatenated rather than built with {@link URI}, because the segments are already + * percent-encoded and {@link URI} would escape the escapes. + */ + @Nullable + private static io.temporal.api.nexus.v1.Link encode( + LinkType linkType, + String namespace, + String id, + String runId, + List> queryParams) { try { - String url = - String.format( - nexusOperationLinkPathFormat, - URLEncoder.encode(no.getNamespace(), StandardCharsets.UTF_8.toString()), - // See the WorkflowId comment in workflowEventToNexusLink for why '+' is rewritten to - // '%20'. OperationId is user-supplied and can legally contain spaces. - URLEncoder.encode(no.getOperationId(), StandardCharsets.UTF_8.toString()) - .replace("+", "%20"), - URLEncoder.encode(no.getRunId(), StandardCharsets.UTF_8.toString())); + StringBuilder url = + new StringBuilder(SCHEME) + .append(":///") + .append(NAMESPACES_SEGMENT) + .append('/') + .append(encodePathSegment(namespace)) + .append('/') + .append(linkType.keyword) + .append('/') + .append(encodePathSegment(id)) + .append('/') + .append(encodePathSegment(runId)); + if (linkType.tail != null) { + url.append('/').append(linkType.tail); + } + if (!queryParams.isEmpty()) { + url.append('?').append(encodeQuery(queryParams)); + } return io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl(url) - .setType(nexusOperationLinkType) + .setUrl(url.toString()) + .setType(linkType.type) .build(); } catch (Exception e) { - log.error("Failed to encode Nexus operation link URL", e); + log.error("Failed to encode {} Nexus link URL", linkType, e); + return null; } - return null; } - public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexusLink) { - if (!nexusOperationLinkType.equals(nexusLink.getType())) { - log.error( - "Failed to parse Nexus link URL: cannot parse link type {} to {}", - nexusLink.getType(), - nexusOperationLinkType); - return null; + /** The three path IDs plus the decoded query. */ + private static final class Decoded { + final String namespace; + final String id; + final String runId; + final Map query; + + Decoded(String namespace, String id, String runId, Map query) { + this.namespace = namespace; + this.id = id; + this.runId = runId; + this.query = query; } - Link.Builder link = Link.newBuilder(); - try { - URI uri = new URI(nexusLink.getUrl()); + } - if (!"temporal".equals(uri.getScheme())) { - log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); + /** + * Validates a Nexus link against {@code linkType} and splits out its path IDs and query. + * + *

The declared type must match, the path must have exactly the expected segments, and no + * segment may be empty. + */ + @Nullable + private static Decoded decode(LinkType linkType, io.temporal.api.nexus.v1.Link nexusLink) { + try { + if (!linkType.type.equals(nexusLink.getType())) { + log.error( + "Failed to parse Nexus link URL: cannot parse link type {} to {}", + nexusLink.getType(), + linkType.type); return null; } - StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + URI uri = new URI(nexusLink.getUrl()); + if (!SCHEME.equals(uri.getScheme())) { + log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); return null; } - String namespace = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("nexus-operations")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + String rawPath = uri.getRawPath(); + if (rawPath == null) { + log.error("Failed to parse Nexus link URL: no path: {}", nexusLink.getUrl()); return null; } - String operationId = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens()) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + // Split the raw path: a segment may legally contain an encoded slash. + String[] segments = + rawPath.startsWith("/") ? rawPath.substring(1).split("/", -1) : rawPath.split("/", -1); + + if (segments.length != linkType.segmentCount() + || !NAMESPACES_SEGMENT.equals(segments[0]) + || !linkType.keyword.equals(segments[2]) + || (linkType.tail != null && !linkType.tail.equals(segments[5]))) { + log.error("Failed to parse Nexus link URL: invalid path: {}", rawPath); return null; } - String runId = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("details")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + if (segments[1].isEmpty() || segments[3].isEmpty() || segments[4].isEmpty()) { + log.error("Failed to parse Nexus link URL: empty path segment: {}", rawPath); return null; } - link.setNexusOperation( - Link.NexusOperation.newBuilder() - .setNamespace(namespace) - .setOperationId(operationId) - .setRunId(runId)); + return new Decoded( + decodePathSegment(segments[1]), + decodePathSegment(segments[3]), + decodePathSegment(segments[4]), + decodeQuery(uri.getRawQuery())); } catch (Exception e) { + // Swallow un-parsable links since they are not critical to processing. log.error("Failed to parse Nexus link URL", e); return null; } - return link.build(); } /** - * Percent-encodes a single URL path segment. {@link URLEncoder} targets form encoding, where a - * space becomes '+', so rewrite it to "%20" as required for a path. + * Percent-encodes one path segment, encoding a space as {@code %20}. + * + *

{@code java.net}'s URL codecs target HTML form data rather than general URIs, so {@link + * URLEncoder} emits {@code +} for a space. In a path a {@code +} is a literal plus to the + * decoding server, so in that edge case the path is incorrect and ends up with a + instead of a + * space. Since the encoder writes a {@code +} as {@code %2B}, we can adjust for this by replacing + * any {@code +} signs we find with {@code %2B} as we know they are encoded spaces. */ - private static String encodePathSegment(String value) throws UnsupportedEncodingException { - return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replace("+", "%20"); + private static String encodePathSegment(String segment) throws UnsupportedEncodingException { + return URLEncoder.encode(segment, UTF_8).replace("+", "%20"); } /** - * Percent-decodes a single URL path segment. {@link URLDecoder} targets form decoding, where '+' - * means a space, but in a path a '+' is a literal character. Pre-escaping '+' as "%2B" keeps it - * literal while leaving genuine percent escapes such as "%20" for the decoder to handle. + * Percent-decodes one path segment, leaving {@code +} alone. + * + *

The same form-data quirk in reverse: {@link URLDecoder} reads {@code +} as a space, which + * would corrupt an identifier containing a literal plus. Java 8 has no percent-only decoder, so + * pre-escape plus signs to {@code %2B} and let the form decoder hand them back unchanged. */ - private static String decodePathSegment(String value) throws UnsupportedEncodingException { - return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8.toString()); + private static String decodePathSegment(String segment) throws UnsupportedEncodingException { + return URLDecoder.decode(segment.replace("+", "%2B"), UTF_8); + } + + /** Form-encodes query parameters in the order given. */ + private static String encodeQuery(List> params) + throws UnsupportedEncodingException { + StringBuilder sb = new StringBuilder(); + for (Map.Entry p : params) { + if (sb.length() > 0) { + sb.append('&'); + } + sb.append(URLEncoder.encode(p.getKey(), UTF_8)) + .append('=') + .append(URLEncoder.encode(p.getValue(), UTF_8)); + } + return sb.toString(); } /** - * Reads a single param out of the raw, still-encoded query string, or returns null when the param - * is absent. Unlike {@link #parseQueryParams} the value is decoded exactly once, so values that - * themselves contain '=' or '&' survive the round trip. + * Form-decodes a raw query string. + * + *

Takes {@link URI#getRawQuery()} rather than {@link URI#getQuery()}: the latter is already + * percent-decoded, so decoding it again throws on any value containing a bare {@code %} and + * mis-splits values containing {@code &} or {@code =}. + * + *

Unlike a path segment, a query value is form-decoded, so {@code +} means a space. {@link + * URLDecoder} does that before percent-decoding, which is the required order — form encoding + * writes a literal {@code +} as {@code %2B}. */ - private static String rawQueryParam(URI uri, String key) throws UnsupportedEncodingException { - final String rawQuery = uri.getRawQuery(); + private static Map decodeQuery(@Nullable String rawQuery) + throws UnsupportedEncodingException { if (rawQuery == null || rawQuery.isEmpty()) { - return null; + return Collections.emptyMap(); } + Map params = new LinkedHashMap<>(); for (String pair : rawQuery.split("&")) { - final String[] kv = pair.split("=", 2); - if (kv[0].equals(key)) { - return kv.length == 2 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8.toString()) : ""; + if (pair.isEmpty()) { + continue; } + String[] kv = pair.split("=", 2); + String key = URLDecoder.decode(kv[0], UTF_8); + // First occurrence wins on a repeated key, matching api-go's Query().Get. No encoder emits + // one, but silently preferring the last would make Java the odd SDK out. + if (params.containsKey(key)) { + continue; + } + // A key with no usable value maps to null, whether written "?k" or "?k="; callers null-check. + String value = kv.length == 2 && !kv[1].isEmpty() ? URLDecoder.decode(kv[1], UTF_8) : null; + params.put(key, value); } - return null; + return params; } - private static Map parseQueryParams(URI uri) throws UnsupportedEncodingException { - final String query = uri.getQuery(); - if (query == null || query.isEmpty()) { - return Collections.emptyMap(); + // =============================================================================================== + // The WorkflowEvent "reference": WHICH event in the workflow's history the link points at. + // + // A Link.WorkflowEvent points at one specific history event. The path names the workflow + // (namespace / workflowId / runId); the reference names the event inside it. It travels in the + // query string rather than the path because it is optional and comes in two shapes. + // + // An event can be named two ways, which is why the proto models this as a oneof: + // + // EventReference by event ID, the event's position in history. Only usable once the + // event exists and the caller knows its ID. + // RequestIdReference by the request ID of the RPC that produced the event. Used when the + // caller holds a request ID but no event ID -- a link built at the moment + // a workflow is started or an update is accepted, where the event either + // does not exist yet or its ID was never returned. The server resolves + // the request ID to the event later. + // + // Both arms also carry the event type, which can be enough on its own: a link to + // WorkflowExecutionStarted needs no event ID because that is always event 1, and the UI + // resolves it that way (temporalio/ui, src/lib/utilities/event-link.ts). That is why eventID is + // omitted rather than sent as 0. + // + // The whole link has to cross the wire as one URL string, so encodeReference flattens the oneof + // into referenceType + eventID/requestID + eventType, and decodeReference reads those params + // back and rebuilds it. + // =============================================================================================== + + /** An unset oneof yields no params, so a workflow-event link can legally have an empty query. */ + private static List> encodeReference(Link.WorkflowEvent we) { + List> query = new ArrayList<>(); + if (we.hasEventRef()) { + Link.WorkflowEvent.EventReference ref = we.getEventRef(); + query.add(param(REFERENCE_TYPE_KEY, EVENT_REFERENCE_TYPE)); + // An unset event ID is 0, which is not a valid event ID, so omit it rather than send a zero. + if (ref.getEventId() > 0) { + query.add(param(EVENT_ID_KEY, String.valueOf(ref.getEventId()))); + } + query.add(param(EVENT_TYPE_KEY, encodeEventType(ref.getEventType()))); + } else if (we.hasRequestIdRef()) { + Link.WorkflowEvent.RequestIdReference ref = we.getRequestIdRef(); + query.add(param(REFERENCE_TYPE_KEY, REQUEST_ID_REFERENCE_TYPE)); + query.add(param(REQUEST_ID_KEY, ref.getRequestId())); + query.add(param(EVENT_TYPE_KEY, encodeEventType(ref.getEventType()))); } - Map queryParams = new HashMap<>(); - for (String pair : query.split("&")) { - final String[] kv = pair.split("=", 2); - final String key = URLDecoder.decode(kv[0], StandardCharsets.UTF_8.toString()); - final String value = - kv.length == 2 && !kv[1].isEmpty() - ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8.toString()) - : null; - queryParams.put(key, value); + return query; + } + + /** + * Selects the arm by {@code referenceType}. Returns false if the query names no recognized + * reference, which makes the link unusable — a workflow event link must say which event it means. + * + *

The catch is load-bearing: this runs outside {@link #decode}'s try block, and both {@link + * Long#parseLong} and {@link EventType#valueOf} throw on malformed input. + */ + private static boolean decodeReference(Link.WorkflowEvent.Builder we, Map query) { + try { + return decodeReferenceOrThrow(we, query); + } catch (Exception e) { + log.error("Failed to parse Nexus link URL reference", e); + return false; } - return queryParams; } + private static boolean decodeReferenceOrThrow( + Link.WorkflowEvent.Builder we, Map query) { + String referenceType = query.get(REFERENCE_TYPE_KEY); + if (EVENT_REFERENCE_TYPE.equals(referenceType)) { + Link.WorkflowEvent.EventReference.Builder ref = + Link.WorkflowEvent.EventReference.newBuilder(); + String eventId = query.get(EVENT_ID_KEY); + if (eventId != null && !eventId.isEmpty()) { + ref.setEventId(Long.parseLong(eventId)); + } + String eventType = query.get(EVENT_TYPE_KEY); + if (eventType != null && !eventType.isEmpty()) { + ref.setEventType(decodeEventType(eventType)); + } + we.setEventRef(ref); + return true; + } + if (REQUEST_ID_REFERENCE_TYPE.equals(referenceType)) { + Link.WorkflowEvent.RequestIdReference.Builder ref = + Link.WorkflowEvent.RequestIdReference.newBuilder(); + String requestId = query.get(REQUEST_ID_KEY); + if (requestId != null && !requestId.isEmpty()) { + ref.setRequestId(requestId); + } + String eventType = query.get(EVENT_TYPE_KEY); + if (eventType != null && !eventType.isEmpty()) { + ref.setEventType(decodeEventType(eventType)); + } + we.setRequestIdRef(ref); + return true; + } + log.error("Failed to parse Nexus link URL: invalid reference type: {}", referenceType); + return false; + } + + /** Emits the short PascalCase event type name, e.g. {@code WorkflowExecutionStarted}. */ private static String encodeEventType(EventType eventType) { return uniqueToSimplifiedName(eventType.name(), EVENT_TYPE_PREFIX); } + /** Accepts either the {@code EVENT_TYPE_}-prefixed proto name or the short PascalCase form. */ private static EventType decodeEventType(String eventType) { - // Have to handle the SCREAMING_CASE enum or the traditional temporal PascalCase enum to - // EventType if (eventType.startsWith(EVENT_TYPE_PREFIX)) { return EventType.valueOf(eventType); } return EventType.valueOf(simplifiedToUniqueName(eventType, EVENT_TYPE_PREFIX)); } + + private static Map.Entry param(String key, String value) { + return new java.util.AbstractMap.SimpleImmutableEntry<>(key, value); + } + + private LinkConverter() {} } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java b/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java index 2434f12db2..8086b5c9a5 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java @@ -1,948 +1,556 @@ package io.temporal.internal.common; -import static io.temporal.internal.common.LinkConverter.activityToNexusLink; import static io.temporal.internal.common.LinkConverter.linkToNexusLink; import static io.temporal.internal.common.LinkConverter.nexusLinkToActivity; import static io.temporal.internal.common.LinkConverter.nexusLinkToLink; import static io.temporal.internal.common.LinkConverter.nexusLinkToNexusOperation; import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowEvent; import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowLink; -import static io.temporal.internal.common.LinkConverter.nexusOperationToNexusLink; -import static io.temporal.internal.common.LinkConverter.workflowEventToNexusLink; -import static io.temporal.internal.common.LinkConverter.workflowLinkToNexusLink; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; import io.temporal.api.common.v1.Link; import io.temporal.api.enums.v1.EventType; -import java.io.UnsupportedEncodingException; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import org.junit.Test; +/** + * Tests for {@link LinkConverter}. + * + *

The URL shapes and the encoding rules asserted here are a wire contract shared with the Go, + * Python, TypeScript and .NET SDKs, so changing an expected URL means changing it everywhere. + * + *

Three things are deliberately not pinned as contract, because no decoder can observe them: + * query parameter order, whether a space in a query value is {@code +} or {@code %20}, and whether + * a literal {@code +} in a path is bare or {@code %2B}. Tests that assert Java's concrete choice + * for those say so. + */ public class LinkConverterTest { - @Test - public void testConvertWorkflowEventToNexus_Valid() { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - io.temporal.api.nexus.v1.Link actual = workflowEventToNexusLink(input); - assertEquals(expected, actual); + private static final String WORKFLOW_EVENT = Link.WorkflowEvent.getDescriptor().getFullName(); + private static final String WORKFLOW = Link.Workflow.getDescriptor().getFullName(); + private static final String NEXUS_OPERATION = Link.NexusOperation.getDescriptor().getFullName(); + private static final String ACTIVITY = Link.Activity.getDescriptor().getFullName(); - input = - input.toBuilder() - .setRequestIdRef( - Link.WorkflowEvent.RequestIdReference.newBuilder() - .setRequestId("random-request-id") - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED)) - .build(); - expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?referenceType=RequestIdReference&requestID=random-request-id&eventType=WorkflowExecutionOptionsUpdated") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - actual = workflowEventToNexusLink(input); - assertEquals(expected, actual); - } + // =============================================================================================== + // Encode. + // =============================================================================================== @Test - public void testConvertWorkflowEventToNexus_ValidAngle() { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id>") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id%3E/run-id/history?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - io.temporal.api.nexus.v1.Link actual = workflowEventToNexusLink(input); - assertEquals(expected, actual); + public void encodesWorkflowEventWithEventReference() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf-id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); } + /** An unset event ID is 0, which is not a valid event ID, so the param is omitted. */ @Test - public void testConvertWorkflowEventToNexus_ValidSlash() { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id/") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id%2F/run-id/history?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - io.temporal.api.nexus.v1.Link actual = workflowEventToNexusLink(input); - assertEquals(expected, actual); + public void omitsEventIdWhenUnset() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf-id", "run-id", 0, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); } @Test - public void testConvertWorkflowEventToNexus_ValidSpace() throws UnsupportedEncodingException { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf space+plus") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf%20space%2Bplus/run-id/history?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - io.temporal.api.nexus.v1.Link actual = workflowEventToNexusLink(input); - assertEquals(expected, actual); - - String decoded = URLDecoder.decode(actual.getUrl(), StandardCharsets.UTF_8.toString()); - assertEquals( - "temporal:///namespaces/ns/workflows/wf space+plus/run-id/history?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", - decoded); + public void encodesWorkflowEventWithRequestIdReference() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=RequestIdReference&requestID=req-id" + + "&eventType=WorkflowExecutionOptionsUpdated", + WORKFLOW_EVENT, + requestIdRef( + "ns", + "wf-id", + "run-id", + "req-id", + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED)); } + /** + * A space in a path segment must be {@code %20}, never {@code +}: the decoding server treats a + * {@code +} in a path as a literal plus, so the space would be lost and the link would point at a + * workflow that does not exist. Regression guard for #2874. + */ @Test - public void testConvertWorkflowEventToNexus_ValidEventIDMissing() { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?referenceType=EventReference&eventType=WorkflowExecutionStarted") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - io.temporal.api.nexus.v1.Link actual = workflowEventToNexusLink(input); - assertEquals(expected, actual); + public void encodesSpaceInPathAsPercent20() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf%20id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); } + /** An encoded slash must stay encoded, or the path gains a segment and no longer parses. */ @Test - public void testConvertNexusToWorkflowEvent_Valid() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventID=1&eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); - - input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?referenceType=RequestIdReference&requestID=random-request-id&eventType=WorkflowExecutionOptionsUpdated") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setRequestIdRef( - Link.WorkflowEvent.RequestIdReference.newBuilder() - .setRequestId("random-request-id") - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED))) - .build(); - - actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesSlashAndAngleInPathSegment() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id%2F/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf-id/", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id%3E/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf-id>", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); } @Test - public void testConvertNexusToWorkflowEvent_ValidLongEventType() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesNonAsciiPathSegment() { + assertEncodes( + "temporal:///namespaces/ns%C3%A4/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=7&eventType=NexusOperationScheduled", + WORKFLOW_EVENT, + eventRef("nsä", "wf-id", "run-id", 7, EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED)); } + /** A workflow link addresses the execution as a whole, so it has no {@code /history} tail. */ @Test - public void testConvertNexusToWorkflowEvent_ValidAngle() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id%3E/run-id/history?eventID=1&eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id>") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesWorkflowLink() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id", + WORKFLOW, + workflow("ns", "wf-id", "run-id", "")); } + /** The space encoding in the query value ({@code +}) is Java's choice, not contract. */ @Test - public void testConvertNexusToWorkflowEvent_ValidSlash() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id%2F/run-id/history?eventID=1&eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id/") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesWorkflowLinkReason() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update", + WORKFLOW, + workflow("ns", "wf-id", "run-id", "rejected update")); } @Test - public void testConvertNexusToWorkflowEvent_ValidEventIDMissing() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesWorkflowLinkPathSegments() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf%20id/run-id", + WORKFLOW, workflow("ns", "wf id", "run-id", "")); + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id%2F/run-id", + WORKFLOW, workflow("ns", "wf-id/", "run-id", "")); } + /** A literal plus must survive; a bare {@code +} would form-decode back to a space. */ @Test - public void testConvertNexusToWorkflowEvent_InvalidScheme() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "test:///namespaces/ns/workflows/wf-id/run-id/history?eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowEvent(input)); + public void encodesLiteralPlusInReason() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id?reason=a%2Bb", + WORKFLOW, workflow("ns", "wf-id", "run-id", "a+b")); } + /** A reason may contain the query delimiters themselves; they must not split the parameter. */ @Test - public void testConvertNexusToWorkflowEvent_InvalidPathMissingHistory() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/?eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowEvent(input)); + public void encodesReasonContainingDelimiters() { + Link in = workflow("ns", "wf-id", "run-id", "a&b=c"); + assertEquals(in, nexusLinkToLink(linkToNexusLink(in))); } @Test - public void testConvertNexusToWorkflowEvent_InvalidPathMissingNamespace() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces//workflows/wf-id/run-id/history?eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowEvent(input)); + public void encodesNexusOperationLink() { + assertEncodes( + "temporal:///namespaces/ns/nexus-operations/op-id/run-id/details", + NEXUS_OPERATION, + nexusOperation("ns", "op-id", "run-id")); + assertEncodes( + "temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details", + NEXUS_OPERATION, nexusOperation("ns", "op/id", "run-id")); } @Test - public void testConvertNexusToWorkflowEvent_InvalidEventType() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventType=WorkflowExecution&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowEvent(input)); + public void encodesActivityLink() { + assertEncodes( + "temporal:///namespaces/ns/activities/act-id/run-id/details", + ACTIVITY, + activity("ns", "act-id", "run-id")); + assertEncodes( + "temporal:///namespaces/ns/activities/act%2Fid/run-id/details", + ACTIVITY, activity("ns", "act/id", "run-id")); } + /** The event type goes on the wire in the short PascalCase form. */ @Test - public void testConvertNexusOperationToNexus_Valid() { - Link.NexusOperation input = - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - assertEquals(expected, nexusOperationToNexusLink(input)); + public void encodesEventTypeInPascalCase() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=2&eventType=NexusOperationCancelRequested", + WORKFLOW_EVENT, + eventRef( + "ns", "wf-id", "run-id", 2, EventType.EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED)); } + /** Query parameter order is not contract, but Java's order is pinned so it stays deliberate. */ @Test - public void testConvertNexusOperationToNexus_ValidSlash() { - Link.NexusOperation input = - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op/id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - assertEquals(expected, nexusOperationToNexusLink(input)); + public void emitsQueryParametersInInsertionOrder() { + assertEquals( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + linkToNexusLink( + eventRef( + "ns", "wf-id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) + .getUrl()); } - @Test - public void testConvertNexusToNexusOperation_Valid() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - Link expected = - Link.newBuilder() - .setNexusOperation( - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op-id") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToNexusOperation(input)); - } + // =============================================================================================== + // Decode. + // =============================================================================================== + /** Both event type spellings must decode; other SDKs emit the prefixed form. */ @Test - public void testConvertNexusToNexusOperation_ValidSlash() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - + public void decodesEitherEventTypeSpelling() { Link expected = - Link.newBuilder() - .setNexusOperation( - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op/id") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToNexusOperation(input)); + eventRef("ns", "wf-id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED); + assertDecodes( + expected, + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted"); + assertDecodes( + expected, + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=1" + + "&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED"); + } + + /** Parameters are read by key, so any order decodes. */ + @Test + public void decodesQueryParametersInAnyOrder() { + assertDecodes( + eventRef("ns", "wf-id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?eventType=WorkflowExecutionStarted&referenceType=EventReference&eventID=1"); + assertDecodes( + workflow("ns", "wf-id", "run-id", "why"), + WORKFLOW, + "temporal:///namespaces/ns/workflows/wf-id/run-id?other=x&reason=why"); } + /** + * Path segments are percent-decoded, not form-decoded, in both legal spellings of a plus. Other + * SDKs emit the bare form; neither may ever decode to a space. + */ @Test - public void testConvertNexusToNexusOperation_WrongType() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToNexusOperation(input)); + public void decodesBothSpellingsOfPlusInPathAsPlus() { + Link expected = + eventRef("ns", "a+b", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED); + for (String segment : new String[] {"a+b", "a%2Bb"}) { + assertDecodes( + expected, + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/" + + segment + + "/run-id/history?referenceType=EventReference&eventID=1" + + "&eventType=WorkflowExecutionStarted"); + } + } + + @Test + public void decodesPercentEncodedPathSegments() { + assertDecodes( + eventRef("ns", "wf id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf%20id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted"); + assertDecodes( + activity("ns", "act id", "run-id"), + ACTIVITY, + "temporal:///namespaces/ns/activities/act%20id/run-id/details"); + assertDecodes( + nexusOperation("ns", "op/id", "run-id"), + NEXUS_OPERATION, + "temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details"); } + /** + * Query values are form-decoded, the opposite of path segments, so both spellings of a space must + * decode to a space. .NET emits the percent form. + */ @Test - public void testConvertNexusToNexusOperation_InvalidScheme() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("random:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - assertNull(nexusLinkToNexusOperation(input)); + public void decodesBothSpellingsOfSpaceInQueryValue() { + Link expected = workflow("ns", "wf-id", "run-id", "rejected update"); + for (String value : new String[] {"rejected+update", "rejected%20update"}) { + assertDecodes( + expected, WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id?reason=" + value); + } } + /** Values are read from the raw query, so a percent sign does not discard the link. */ @Test - public void testConvertNexusToNexusOperation_InvalidPathMissingDetails() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - assertNull(nexusLinkToNexusOperation(input)); + public void decodesPercentSignInQueryValue() { + assertDecodes( + requestIdRef( + "ns", "wf-id", "run-id", "100%", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=RequestIdReference&requestID=100%25" + + "&eventType=WorkflowExecutionStarted"); } + /** An absent, empty, bare or similarly-named reason parameter all leave the proto default. */ @Test - public void testConvertActivityToNexus_Valid() { - Link.Activity input = - Link.Activity.newBuilder() - .setNamespace("ns") - .setActivityId("act id/with+characters") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/activities/act%20id%2Fwith%2Bcharacters/run-id/details") - .setType("temporal.api.common.v1.Link.Activity") - .build(); - - assertEquals(expected, activityToNexusLink(input)); + public void decodesWorkflowLinkWithoutUsableReason() { + Link expected = workflow("ns", "wf-id", "run-id", ""); + String base = "temporal:///namespaces/ns/workflows/wf-id/run-id"; + assertDecodes(expected, WORKFLOW, base); + assertDecodes(expected, WORKFLOW, base + "?reason="); + assertDecodes(expected, WORKFLOW, base + "?reason"); + assertDecodes(expected, WORKFLOW, base + "?reasonable=yes"); } - @Test - public void testConvertNexusToActivity_Valid() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/activities/act%20id%2Fwith%2Bcharacters/run-id/details") - .setType("temporal.api.common.v1.Link.Activity") - .build(); - - Link expected = - Link.newBuilder() - .setActivity( - Link.Activity.newBuilder() - .setNamespace("ns") - .setActivityId("act id/with+characters") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToActivity(input)); - } + // =============================================================================================== + // Rejection. + // =============================================================================================== @Test - public void testConvertNexusToActivity_InvalidPath() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/activities/act-id/run-id") - .setType("temporal.api.common.v1.Link.Activity") - .build(); - - assertNull(nexusLinkToActivity(input)); + public void rejectsWrongScheme() { + assertRejected(WORKFLOW_EVENT, "https:///namespaces/ns/workflows/wf-id/run-id/history"); + assertRejected(WORKFLOW, "https:///namespaces/ns/workflows/wf-id/run-id"); + assertRejected(NEXUS_OPERATION, "https:///namespaces/ns/nexus-operations/op/run-id/details"); } + /** A workflow link ends at the run ID; a workflow-event link ends at {@code /history}. */ @Test - public void testNexusLinkToLink_WorkflowEventRoundTrip() { - Link.WorkflowEvent we = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link nexusLink = workflowEventToNexusLink(we); - assertEquals("temporal.api.common.v1.Link.WorkflowEvent", nexusLink.getType()); - - Link converted = nexusLinkToLink(nexusLink); - assertNotNull(converted); - assertEquals(Link.newBuilder().setWorkflowEvent(we).build(), converted); + public void rejectsMismatchedWorkflowPathShapes() { + assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id/history"); + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows/wf-id/run-id"); } @Test - public void testNexusLinkToLink_NexusOperation() { - io.temporal.api.nexus.v1.Link nexusLink = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - Link expected = - Link.newBuilder() - .setNexusOperation( - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op-id") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToLink(nexusLink)); + public void rejectsTrailingPathSegment() { + assertRejected( + WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows/wf-id/run-id/history/extra"); + assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id/extra"); + // A trailing slash is an empty extra segment, not a no-op. + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows/wf-id/run-id/history/"); + assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id/"); } + /** A repeated key takes its first occurrence, as api-go does. No encoder emits one. */ @Test - public void testNexusLinkToLink_ActivityRoundTrip() { - Link.Activity activity = - Link.Activity.newBuilder() - .setNamespace("ns") - .setActivityId("act-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link nexusLink = activityToNexusLink(activity); - assertEquals(Link.newBuilder().setActivity(activity).build(), nexusLinkToLink(nexusLink)); + public void decodesFirstOccurrenceOfARepeatedQueryKey() { + assertDecodes( + workflow("ns", "wf-id", "run-id", "first"), + WORKFLOW, + "temporal:///namespaces/ns/workflows/wf-id/run-id?reason=first&reason=second"); } @Test - public void testNexusLinkToLink_UnknownType() { - io.temporal.api.nexus.v1.Link nexusLink = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id/history") - .setType("unknown.type") - .build(); - - assertNull(nexusLinkToLink(nexusLink)); + public void rejectsMissingPathSegment() { + assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id"); + assertRejected(NEXUS_OPERATION, "temporal:///namespaces/ns/nexus-operations/op-id/run-id"); + assertRejected(ACTIVITY, "temporal:///namespaces/ns/activities/act-id/run-id"); } @Test - public void testLinkToNexusLink_WorkflowEvent() { - Link.WorkflowEvent we = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link actual = - linkToNexusLink(Link.newBuilder().setWorkflowEvent(we).build()); - assertEquals(workflowEventToNexusLink(we), actual); + public void rejectsEmptyPathSegment() { + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces//workflows/wf-id/run-id/history"); + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows//run-id/history"); } @Test - public void testLinkToNexusLink_NexusOperation() { - Link.NexusOperation no = - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link actual = - linkToNexusLink(Link.newBuilder().setNexusOperation(no).build()); - assertEquals(nexusOperationToNexusLink(no), actual); + public void rejectsWrongKindSegment() { + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/activities/wf-id/run-id/history"); } + /** The declared type is authoritative on every decoder, not just the dispatcher. */ @Test - public void testLinkToNexusLink_Activity() { - Link.Activity activity = - Link.Activity.newBuilder() - .setNamespace("ns") - .setActivityId("act-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link actual = - linkToNexusLink(Link.newBuilder().setActivity(activity).build()); - assertEquals(activityToNexusLink(activity), actual); + public void rejectsTypeThatDoesNotMatchThePath() { + String workflowEventUrl = "temporal:///namespaces/ns/workflows/wf-id/run-id/history"; + assertRejected(ACTIVITY, workflowEventUrl); + assertNull(nexusLinkToWorkflowEvent(nexusLink(WORKFLOW, workflowEventUrl))); + assertNull(nexusLinkToWorkflowLink(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); + assertNull(nexusLinkToActivity(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); + assertNull(nexusLinkToNexusOperation(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); } @Test - public void testLinkToNexusLink_Empty() { - assertNull(linkToNexusLink(Link.newBuilder().build())); + public void rejectsUnknownLinkType() { + assertRejected( + "temporal.api.common.v1.Link.NotAVariant", + "temporal:///namespaces/ns/workflows/wf-id/run-id/history"); } @Test - public void testConvertWorkflowToNexus_Valid() { - Link.Workflow input = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals(expected, workflowLinkToNexusLink(input)); + public void rejectsMissingOrUnknownReferenceType() { + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?eventID=1&eventType=WorkflowExecutionStarted"); + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=NotAReference&eventType=WorkflowExecutionStarted"); } @Test - public void testConvertWorkflowToNexus_ValidReason() { - Link.Workflow input = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setReason("rejected update") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals(expected, workflowLinkToNexusLink(input)); + public void rejectsUnparseableEventTypeOrEventId() { + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventType=NotAnEventType"); + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=nope&eventType=WorkflowExecutionStarted"); + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=99999999999999999999" + + "&eventType=WorkflowExecutionStarted"); } + /** A malformed link is dropped, never thrown, so it cannot fail the call carrying it. */ @Test - public void testConvertWorkflowToNexus_ValidSlash() { - Link.Workflow input = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf/id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf%2Fid/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals(expected, workflowLinkToNexusLink(input)); + public void rejectsMalformedUrlsWithoutThrowing() { + for (String url : + new String[] {"", "not a uri at all", "%%%", "temporal:///", "temporal:///namespaces"}) { + assertRejected(WORKFLOW_EVENT, url); + } } - @Test - public void testConvertWorkflowToNexus_ValidSpace() throws UnsupportedEncodingException { - Link.Workflow input = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf%20id/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - io.temporal.api.nexus.v1.Link actual = workflowLinkToNexusLink(input); - assertEquals(expected, actual); - // A space in the path has to survive as %20 rather than the '+' that form encoding would - // produce, otherwise the link resolves to a different workflow ID. - assertEquals( - "temporal:///namespaces/ns/workflows/wf id/run-id", - URLDecoder.decode(actual.getUrl(), StandardCharsets.UTF_8.toString())); - } + // =============================================================================================== + // Dispatch. + // =============================================================================================== @Test - public void testConvertNexusToWorkflow_Valid() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflow( - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToWorkflowLink(input)); + public void dispatchCoversAllFourLinkTypes() { + assertNotNull(linkToNexusLink(eventRef("ns", "w", "r", 1, EventType.EVENT_TYPE_TIMER_STARTED))); + assertNotNull(linkToNexusLink(workflow("ns", "w", "r", ""))); + assertNotNull(linkToNexusLink(nexusOperation("ns", "o", "r"))); + assertNotNull(linkToNexusLink(activity("ns", "a", "r"))); } + /** + * An event type this SDK's protos do not know (a newer server sending a higher enum number) + * arrives as {@code UNRECOGNIZED}, which has no {@code EVENT_TYPE_} prefix to strip. Encoding it + * must drop the link rather than throw out of the Nexus call carrying it. + */ @Test - public void testConvertNexusToWorkflow_ValidReason() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - Link expected = + public void unknownEventTypeEncodesToNullRatherThanThrowing() { + Link link = Link.newBuilder() - .setWorkflow( - Link.Workflow.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() .setNamespace("ns") .setWorkflowId("wf-id") .setRunId("run-id") - .setReason("rejected update")) - .build(); - - assertEquals(expected, nexusLinkToWorkflowLink(input)); - } - - @Test - public void testConvertNexusToWorkflow_WrongType() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - @Test - public void testConvertNexusToWorkflow_InvalidScheme() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("random:///namespaces/ns/workflows/wf-id/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - @Test - public void testConvertNexusToWorkflow_InvalidPathTrailingSegment() { - // The workflow-event form addresses an event inside the workflow, so it must not be accepted - // as a workflow link even when the type says otherwise. - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id/history") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - @Test - public void testConvertNexusToWorkflow_ReasonNotFirstQueryParam() { - // The reason is located by key, not by position, so unrelated params ahead of it are skipped. - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id?foo=bar&reason=Query+processed") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("Query processed", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); - } - - @Test - public void testConvertNexusToWorkflow_EmptyReasonValue() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); - } - - @Test - public void testConvertNexusToWorkflow_BareReasonKey() { - // A key with no '=' must not blow up on the missing value. - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); - } - - @Test - public void testConvertNexusToWorkflow_ReasonPrefixKeyIgnored() { - // "reasonx" must not be treated as "reason". - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reasonx=nope") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); - } - - @Test - public void testConvertNexusToWorkflow_EmptyUrl() { - // A URL with no scheme must be reported as an invalid scheme rather than throwing. - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - /** - * A '+' in a path segment is a literal '+', not a space. Form decoding would turn it into a space - * and point at a different execution. - */ - @Test - public void testConvertNexusToWorkflow_LiteralPlusInPathIsPreserved() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/a+b/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("a+b", nexusLinkToWorkflowLink(input).getWorkflow().getWorkflowId()); - - // A percent-escaped space still decodes to a space. - io.temporal.api.nexus.v1.Link spaceInput = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/a%20b/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("a b", nexusLinkToWorkflowLink(spaceInput).getWorkflow().getWorkflowId()); - - // A '+' this SDK encoded itself does survive, because URLEncoder emits %2B. - Link.Workflow w = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("a+b") - .setRunId("run-id") - .build(); - assertEquals( - Link.newBuilder().setWorkflow(w).build(), - nexusLinkToWorkflowLink(workflowLinkToNexusLink(w))); - } - - @Test - public void testConvertNexusToWorkflow_InvalidPathMissingRunID() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - @Test - public void testWorkflowLinkRoundTrip() { - // Reserved characters in every field at once: the path segments are percent-escaped and the - // reason is form-encoded, so a reason containing '=' and '&' must not be split as query syntax. - Link.Workflow w = - Link.Workflow.newBuilder() - .setNamespace("ns/with/slash") - .setWorkflowId("wf id with space") - .setRunId("run-id") - .setReason("reason with = and &") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventId(1) + .setEventTypeValue(99999))) .build(); - - io.temporal.api.nexus.v1.Link nexusLink = workflowLinkToNexusLink(w); - assertEquals("temporal.api.common.v1.Link.Workflow", nexusLink.getType()); - assertEquals(Link.newBuilder().setWorkflow(w).build(), nexusLinkToWorkflowLink(nexusLink)); + try { + assertNull(linkToNexusLink(link)); + } catch (RuntimeException e) { + fail("must not throw: " + e); + } } @Test - public void testLinkToNexusLink_Workflow() { - Link.Workflow w = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setReason("Query processed") - .build(); - - io.temporal.api.nexus.v1.Link actual = - linkToNexusLink(Link.newBuilder().setWorkflow(w).build()); - assertEquals(workflowLinkToNexusLink(w), actual); + public void unsetVariantEncodesToNull() { + assertNull(linkToNexusLink(Link.newBuilder().build())); } - @Test - public void testNexusLinkToLink_WorkflowRoundTrip() { - Link.Workflow w = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setReason("Query processed") - .build(); - - io.temporal.api.nexus.v1.Link nexusLink = workflowLinkToNexusLink(w); - Link converted = nexusLinkToLink(nexusLink); - assertNotNull(converted); - assertEquals(Link.newBuilder().setWorkflow(w).build(), converted); + /** A batch-job link is a real proto variant that no SDK converts. */ + @Test + public void batchJobVariantEncodesToNull() { + assertNull( + linkToNexusLink( + Link.newBuilder().setBatchJob(Link.BatchJob.newBuilder().setJobId("job")).build())); + } + + // =============================================================================================== + // Helpers. + // =============================================================================================== + + /** Asserts the encoded URL and type, then that the link decodes back to exactly the input. */ + private static void assertEncodes(String expectedUrl, String expectedType, Link input) { + io.temporal.api.nexus.v1.Link actual = linkToNexusLink(input); + assertNotNull("encoding returned null", actual); + assertEquals("type", expectedType, actual.getType()); + assertEquals("url", expectedUrl, actual.getUrl()); + assertEquals("round trip", input, nexusLinkToLink(actual)); + } + + private static void assertDecodes(Link expected, String type, String url) { + Link actual = nexusLinkToLink(nexusLink(type, url)); + assertNotNull("decoding returned null for " + url, actual); + assertEquals(url, expected, actual); + } + + private static void assertRejected(String type, String url) { + try { + assertNull("expected rejection of " + url, nexusLinkToLink(nexusLink(type, url))); + } catch (RuntimeException e) { + fail("expected rejection, but threw, for " + url + ": " + e); + } + } + + private static io.temporal.api.nexus.v1.Link nexusLink(String type, String url) { + return io.temporal.api.nexus.v1.Link.newBuilder().setUrl(url).setType(type).build(); + } + + private static Link eventRef(String ns, String wfId, String runId, long eventId, EventType t) { + Link.WorkflowEvent.EventReference.Builder ref = + Link.WorkflowEvent.EventReference.newBuilder().setEventType(t); + if (eventId != 0) { + ref.setEventId(eventId); + } + return Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(ns) + .setWorkflowId(wfId) + .setRunId(runId) + .setEventRef(ref)) + .build(); + } + + private static Link requestIdRef( + String ns, String wfId, String runId, String requestId, EventType t) { + return Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(ns) + .setWorkflowId(wfId) + .setRunId(runId) + .setRequestIdRef( + Link.WorkflowEvent.RequestIdReference.newBuilder() + .setRequestId(requestId) + .setEventType(t))) + .build(); + } + + private static Link workflow(String ns, String wfId, String runId, String reason) { + return Link.newBuilder() + .setWorkflow( + Link.Workflow.newBuilder() + .setNamespace(ns) + .setWorkflowId(wfId) + .setRunId(runId) + .setReason(reason)) + .build(); + } + + private static Link nexusOperation(String ns, String opId, String runId) { + return Link.newBuilder() + .setNexusOperation( + Link.NexusOperation.newBuilder().setNamespace(ns).setOperationId(opId).setRunId(runId)) + .build(); + } + + private static Link activity(String ns, String actId, String runId) { + return Link.newBuilder() + .setActivity( + Link.Activity.newBuilder().setNamespace(ns).setActivityId(actId).setRunId(runId)) + .build(); } }