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 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 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 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 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 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 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();
}
}