Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,14 @@ public <R> QueryOutput<R> query(QueryInput<R> input) {
QueryWorkflowResponse result;
result = genericClient.query(request);

// A query writes nothing to history, so the server returns a link to the workflow execution
// that processed it rather than to an event. When the query is issued from inside a Nexus
// operation handler, propagate that link so the caller's Nexus operation event points at the
// queried workflow. Older servers leave it unset.
if (CurrentNexusOperationContext.isNexusContext() && result.hasLink()) {
CurrentNexusOperationContext.get().addResponseLink(result.getLink());
}

boolean queryRejected = result.hasQueryRejected();
WorkflowExecutionStatus rejectStatus =
queryRejected ? result.getQueryRejected().getStatus() : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ public class LinkConverter {
"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();
Expand Down Expand Up @@ -98,14 +100,28 @@ public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.Workfl
return null;
}

/**
* 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.
*/
public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) {
try {
String namespace = URLEncoder.encode(w.getNamespace(), StandardCharsets.UTF_8.toString());
String workflowId =
URLEncoder.encode(w.getWorkflowId(), StandardCharsets.UTF_8.toString())
.replace("+", "%20"); // handle workflowIds supporting spaces
String runId = URLEncoder.encode(w.getRunId(), StandardCharsets.UTF_8.toString());
String url = String.format(linkPathFormat, namespace, workflowId, runId);
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)
Expand All @@ -131,13 +147,13 @@ public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusL
log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
return null;
}
String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
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 = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
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;
Expand Down Expand Up @@ -190,38 +206,57 @@ public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusL
}

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());
log.debug("Parsing nexus link URL: {}", uri.getRawPath());
if (!uri.getScheme().equals(temporalUrlScheme)) {

// 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(), "/");
// maybe add constants for "namespaces", "workflows" too
if (!st.nextToken().equals("namespaces")) {
log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
return null;
}
String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
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 = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
if (!st.hasMoreTokens()) {
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;
}
String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
link.setWorkflow(

Link.Workflow.Builder w =
Link.Workflow.newBuilder()
.setNamespace(namespace)
.setWorkflowId(workflowID)
.setRunId(runID));
.setRunId(runID);
String reason = rawQueryParam(uri, linkReasonKey);
if (reason != null) {
w.setReason(reason);
}

link.setWorkflow(w);
} catch (Exception e) {
log.error("Failed to convert NexusLink {} to WorkflowLink", nexusLink, 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();
Expand Down Expand Up @@ -308,17 +343,17 @@ public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink)
log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
return null;
}
String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
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 = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
String activityId = decodePathSegment(st.nextToken());
if (!st.hasMoreTokens()) {
log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
return null;
}
String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
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;
Expand Down Expand Up @@ -378,17 +413,17 @@ public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexus
log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
return null;
}
String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
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());
return null;
}
String operationId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
String operationId = decodePathSegment(st.nextToken());
if (!st.hasMoreTokens()) {
log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
return null;
}
String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
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;
Expand All @@ -406,6 +441,42 @@ public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexus
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.
*/
private static String encodePathSegment(String value) throws UnsupportedEncodingException {
return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).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.
*/
private static String decodePathSegment(String value) throws UnsupportedEncodingException {
return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8.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.
*/
private static String rawQueryParam(URI uri, String key) throws UnsupportedEncodingException {
final String rawQuery = uri.getRawQuery();
if (rawQuery == null || rawQuery.isEmpty()) {
return null;
}
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()) : "";
}
}
return null;
}

private static Map<String, String> parseQueryParams(URI uri) throws UnsupportedEncodingException {
final String query = uri.getQuery();
if (query == null || query.isEmpty()) {
Expand Down
Loading
Loading