From cd967cf0537c950b5699fe4c9bcb0de32e1965d5 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 29 Sep 2026 10:40:39 -0700 Subject: [PATCH 1/2] Unified link converters --- temporalnexus/link_converter.go | 606 ++++++++++----------------- temporalnexus/link_converter_test.go | 82 ++++ 2 files changed, 297 insertions(+), 391 deletions(-) diff --git a/temporalnexus/link_converter.go b/temporalnexus/link_converter.go index b5990a5b..23cdda3f 100644 --- a/temporalnexus/link_converter.go +++ b/temporalnexus/link_converter.go @@ -3,7 +3,6 @@ package temporalnexus import ( - "errors" "fmt" "net/url" "regexp" @@ -13,19 +12,11 @@ import ( commonpb "go.temporal.io/api/common/v1" enumspb "go.temporal.io/api/enums/v1" nexuspb "go.temporal.io/api/nexus/v1" + "google.golang.org/protobuf/proto" ) const ( - urlSchemeTemporalKey = "temporal" - urlPathNamespaceKey = "namespace" - urlPathWorkflowIDKey = "workflowID" - urlPathOperationIDKey = "operationID" - urlPathActivityIDKey = "activityID" - urlPathRunIDKey = "runID" - urlPathWorkflowEventTemplate = "/namespaces/%s/workflows/%s/%s/history" - urlPathNexusOperationTemplate = "/namespaces/%s/nexus-operations/%s/%s/details" - urlPathActivityTemplate = "/namespaces/%s/activities/%s/%s/details" - urlPathWorkflowTemplate = "/namespaces/%s/workflows/%s/%s" + urlSchemeTemporalKey = "temporal" linkWorkflowEventReferenceTypeKey = "referenceType" linkEventIDKey = "eventID" @@ -37,374 +28,253 @@ const ( linkReasonKey = "reason" ) +// linkType describes the Nexus URL form of one common.v1.Link variant. Every variant's path is +// /namespaces/{namespace}/{collection}/{id}/{runID}, optionally followed by a fixed tail segment, +// so encoding and decoding differ between variants only by this table. +type linkType struct { + // name is the proto full name, carried as nexus.Link.Type. + name string + // label names the variant in error messages, e.g. "Link_Activity". + label string + // template is the path with a %s for each of namespace, id and runID. + template string + // pathRE matches an escaped path against template, capturing namespace, id and runID. + pathRE *regexp.Regexp +} + +func newLinkType(m proto.Message, template string) linkType { + const segment = `([^/]+)` + desc := m.ProtoReflect().Descriptor() + return linkType{ + name: string(desc.FullName()), + label: "Link_" + string(desc.Name()), + template: template, + pathRE: regexp.MustCompile("^" + fmt.Sprintf(template, segment, segment, segment) + "$"), + } +} + var ( - rePatternNamespace = fmt.Sprintf(`(?P<%s>[^/]+)`, urlPathNamespaceKey) - rePatternWorkflowID = fmt.Sprintf(`(?P<%s>[^/]+)`, urlPathWorkflowIDKey) - rePatternOperationID = fmt.Sprintf(`(?P<%s>[^/]+)`, urlPathOperationIDKey) - rePatternActivityID = fmt.Sprintf(`(?P<%s>[^/]+)`, urlPathActivityIDKey) - rePatternRunID = fmt.Sprintf(`(?P<%s>[^/]+)`, urlPathRunIDKey) - urlPathRE = regexp.MustCompile(fmt.Sprintf( - `^/namespaces/%s/workflows/%s/%s/history$`, - rePatternNamespace, - rePatternWorkflowID, - rePatternRunID, - )) - urlPathNexusOperationRE = regexp.MustCompile(fmt.Sprintf( - `^/namespaces/%s/nexus-operations/%s/%s/details$`, - rePatternNamespace, - rePatternOperationID, - rePatternRunID, - )) - urlPathActivityRE = regexp.MustCompile(fmt.Sprintf( - `^/namespaces/%s/activities/%s/%s/details$`, - rePatternNamespace, - rePatternActivityID, - rePatternRunID, - )) - urlPathWorkflowRE = regexp.MustCompile(fmt.Sprintf( - `^/namespaces/%s/workflows/%s/%s$`, - rePatternNamespace, - rePatternWorkflowID, - rePatternRunID, - )) + workflowEventLinkType = newLinkType(&commonpb.Link_WorkflowEvent{}, "/namespaces/%s/workflows/%s/%s/history") + workflowLinkType = newLinkType(&commonpb.Link_Workflow{}, "/namespaces/%s/workflows/%s/%s") + nexusOperationLinkType = newLinkType(&commonpb.Link_NexusOperation{}, "/namespaces/%s/nexus-operations/%s/%s/details") + activityLinkType = newLinkType(&commonpb.Link_Activity{}, "/namespaces/%s/activities/%s/%s/details") + eventReferenceType = string((&commonpb.Link_WorkflowEvent_EventReference{}).ProtoReflect().Descriptor().Name()) requestIDReferenceType = string((&commonpb.Link_WorkflowEvent_RequestIdReference{}).ProtoReflect().Descriptor().Name()) - - workflowEventLinkType = string((&commonpb.Link_WorkflowEvent{}).ProtoReflect().Descriptor().FullName()) - nexusOperationLinkType = string((&commonpb.Link_NexusOperation{}).ProtoReflect().Descriptor().FullName()) - activityLinkType = string((&commonpb.Link_Activity{}).ProtoReflect().Descriptor().FullName()) - workflowLinkType = string((&commonpb.Link_Workflow{}).ProtoReflect().Descriptor().FullName()) ) -// ConvertLinkWorkflowEventToNexusLink converts a Link_WorkflowEvent type to Nexus Link. -// -// NOTE: Experimental -func ConvertLinkWorkflowEventToNexusLink(we *commonpb.Link_WorkflowEvent) nexus.Link { - u := &url.URL{ - Scheme: urlSchemeTemporalKey, - Path: fmt.Sprintf(urlPathWorkflowEventTemplate, we.GetNamespace(), we.GetWorkflowId(), we.GetRunId()), - RawPath: fmt.Sprintf( - urlPathWorkflowEventTemplate, - url.PathEscape(we.GetNamespace()), - url.PathEscape(we.GetWorkflowId()), - url.PathEscape(we.GetRunId()), - ), - } - - switch ref := we.GetReference().(type) { - case *commonpb.Link_WorkflowEvent_EventRef: - u.RawQuery = convertLinkWorkflowEventEventReferenceToURLQuery(ref.EventRef) - case *commonpb.Link_WorkflowEvent_RequestIdRef: - u.RawQuery = convertLinkWorkflowEventRequestIdReferenceToURLQuery(ref.RequestIdRef) - } - return nexus.Link{ - URL: u, - Type: string(we.ProtoReflect().Descriptor().FullName()), - } -} - -// ConvertWorkflowLinkToNexusLink converts a Link_Workflow into a Nexus Link. -// -// NOTE: Experimental -func ConvertWorkflowLinkToNexusLink(wl *commonpb.Link_Workflow) nexus.Link { +// encode builds the Nexus link for this variant. Path holds the raw IDs and RawPath their escaped +// form, so an ID containing a slash stays a single segment. +func (lt linkType) encode(namespace, id, runID, rawQuery string) nexus.Link { return nexus.Link{ URL: &url.URL{ - Scheme: urlSchemeTemporalKey, - Path: fmt.Sprintf(urlPathWorkflowEventTemplate, wl.GetNamespace(), wl.GetWorkflowId(), wl.GetRunId()), - RawPath: fmt.Sprintf( - urlPathWorkflowEventTemplate, - url.PathEscape(wl.GetNamespace()), - url.PathEscape(wl.GetWorkflowId()), - url.PathEscape(wl.GetRunId()), - ), + Scheme: urlSchemeTemporalKey, + Path: fmt.Sprintf(lt.template, namespace, id, runID), + RawPath: fmt.Sprintf(lt.template, url.PathEscape(namespace), url.PathEscape(id), url.PathEscape(runID)), + RawQuery: rawQuery, }, - Type: string(wl.ProtoReflect().Descriptor().FullName()), + Type: lt.name, } } -// ConvertNexusLinkToLinkWorkflowEvent converts a Nexus Link to Link_WorkflowEvent. -// -// NOTE: Experimental -func ConvertNexusLinkToLinkWorkflowEvent(link nexus.Link) (*commonpb.Link_WorkflowEvent, error) { - we := &commonpb.Link_WorkflowEvent{} - if link.Type != workflowEventLinkType { - return nil, fmt.Errorf( - "cannot parse link type %q to %q", - link.Type, - workflowEventLinkType, - ) - } +// decodedLink holds the path IDs and query of a Nexus link that matched its linkType. +type decodedLink struct { + namespace, id, runID string + query url.Values +} +// decode validates a Nexus link against this variant: the declared type, the scheme, and a path +// matching the template exactly. +func (lt linkType) decode(link nexus.Link) (decodedLink, error) { + if link.Type != lt.name { + return decodedLink{}, fmt.Errorf("cannot parse link type %q to %q", link.Type, lt.name) + } if link.URL == nil { - return nil, fmt.Errorf("failed to parse link to Link_WorkflowEvent: empty URL") + return decodedLink{}, lt.errorf("empty URL") } - if link.URL.Scheme != urlSchemeTemporalKey { - return nil, fmt.Errorf( - "failed to parse link to Link_WorkflowEvent: invalid scheme: %s", - link.URL.Scheme, - ) + return decodedLink{}, lt.errorf("invalid scheme: %s", link.URL.Scheme) } - - matches := urlPathRE.FindStringSubmatch(link.URL.EscapedPath()) + matches := lt.pathRE.FindStringSubmatch(link.URL.EscapedPath()) if len(matches) != 4 { - return nil, fmt.Errorf("failed to parse link to Link_WorkflowEvent: malformed URL path") - } - - var err error - we.Namespace, err = url.PathUnescape(matches[urlPathRE.SubexpIndex(urlPathNamespaceKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_WorkflowEvent: %w", err) + return decodedLink{}, lt.errorf("malformed URL path") } - - we.WorkflowId, err = url.PathUnescape(matches[urlPathRE.SubexpIndex(urlPathWorkflowIDKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_WorkflowEvent: %w", err) - } - - we.RunId, err = url.PathUnescape(matches[urlPathRE.SubexpIndex(urlPathRunIDKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_WorkflowEvent: %w", err) - } - - switch refType := link.URL.Query().Get(linkWorkflowEventReferenceTypeKey); refType { - case eventReferenceType: - eventRef, err := convertURLQueryToLinkWorkflowEventEventReference(link.URL.Query()) + var ids [3]string + for i, match := range matches[1:] { + id, err := url.PathUnescape(match) if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_WorkflowEvent: %w", err) - } - we.Reference = &commonpb.Link_WorkflowEvent_EventRef{ - EventRef: eventRef, + return decodedLink{}, lt.errorf("%w", err) } - case requestIDReferenceType: - requestIDRef, err := convertURLQueryToLinkWorkflowEventRequestIdReference(link.URL.Query()) - if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_WorkflowEvent: %w", err) - } - we.Reference = &commonpb.Link_WorkflowEvent_RequestIdRef{ - RequestIdRef: requestIDRef, - } - default: - return nil, fmt.Errorf( - "failed to parse link to Link_WorkflowEvent: unknown reference type: %q", - refType, - ) + ids[i] = id } + return decodedLink{namespace: ids[0], id: ids[1], runID: ids[2], query: link.URL.Query()}, nil +} - return we, nil +func (lt linkType) errorf(format string, args ...any) error { + return fmt.Errorf("failed to parse link to "+lt.label+": "+format, args...) } -// ConvertLinkNexusOperationToNexusLink converts a Link_NexusOperation type to Nexus Link. +// Outbound: common.v1.Link -> Nexus link. + +// ConvertLinkWorkflowEventToNexusLink converts a Link_WorkflowEvent type to Nexus Link. // // NOTE: Experimental -func ConvertLinkNexusOperationToNexusLink(no *commonpb.Link_NexusOperation) nexus.Link { - u := &url.URL{ - Scheme: urlSchemeTemporalKey, - Path: fmt.Sprintf(urlPathNexusOperationTemplate, no.GetNamespace(), no.GetOperationId(), no.GetRunId()), - RawPath: fmt.Sprintf( - urlPathNexusOperationTemplate, - url.PathEscape(no.GetNamespace()), - url.PathEscape(no.GetOperationId()), - url.PathEscape(no.GetRunId()), - ), - } - return nexus.Link{ - URL: u, - Type: string(no.ProtoReflect().Descriptor().FullName()), +func ConvertLinkWorkflowEventToNexusLink(we *commonpb.Link_WorkflowEvent) nexus.Link { + var rawQuery string + switch ref := we.GetReference().(type) { + case *commonpb.Link_WorkflowEvent_EventRef: + rawQuery = convertLinkWorkflowEventEventReferenceToURLQuery(ref.EventRef) + case *commonpb.Link_WorkflowEvent_RequestIdRef: + rawQuery = convertLinkWorkflowEventRequestIdReferenceToURLQuery(ref.RequestIdRef) } + return workflowEventLinkType.encode(we.GetNamespace(), we.GetWorkflowId(), we.GetRunId(), rawQuery) } -// ConvertNexusLinkToLinkNexusOperation converts a Nexus Link to Link_NexusOperation. +// ConvertLinkWorkflowToNexusLink converts a Link_Workflow type to Nexus Link. // // NOTE: Experimental -func ConvertNexusLinkToLinkNexusOperation(link nexus.Link) (*commonpb.Link_NexusOperation, error) { - no := &commonpb.Link_NexusOperation{} - if link.Type != nexusOperationLinkType { - return nil, fmt.Errorf( - "cannot parse link type %q to %q", - link.Type, - nexusOperationLinkType, - ) - } - - if link.URL == nil { - return nil, fmt.Errorf("failed to parse link to Link_NexusOperation: empty URL") - } - - if link.URL.Scheme != urlSchemeTemporalKey { - return nil, fmt.Errorf( - "failed to parse link to Link_NexusOperation: invalid scheme: %s", - link.URL.Scheme, - ) - } - - matches := urlPathNexusOperationRE.FindStringSubmatch(link.URL.EscapedPath()) - if len(matches) != 4 { - return nil, errors.New("failed to parse link to Link_NexusOperation: malformed URL path") - } - - var err error - no.Namespace, err = url.PathUnescape(matches[urlPathNexusOperationRE.SubexpIndex(urlPathNamespaceKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse namespace in Link_NexusOperation: %w", err) - } - - no.OperationId, err = url.PathUnescape(matches[urlPathNexusOperationRE.SubexpIndex(urlPathOperationIDKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse operation id in Link_NexusOperation: %w", err) +func ConvertLinkWorkflowToNexusLink(w *commonpb.Link_Workflow) nexus.Link { + var rawQuery string + if w.GetReason() != "" { + rawQuery = url.Values{linkReasonKey: {w.GetReason()}}.Encode() } + return workflowLinkType.encode(w.GetNamespace(), w.GetWorkflowId(), w.GetRunId(), rawQuery) +} - no.RunId, err = url.PathUnescape(matches[urlPathNexusOperationRE.SubexpIndex(urlPathRunIDKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse run id in Link_NexusOperation: %w", err) - } +// ConvertWorkflowLinkToNexusLink converts a Link_Workflow into a Nexus Link. +// +// Deprecated: Use [ConvertLinkWorkflowToNexusLink], which this now calls. This function previously +// produced a workflow-event path, ending in /history, that no decoder accepts as a Link_Workflow. +func ConvertWorkflowLinkToNexusLink(wl *commonpb.Link_Workflow) nexus.Link { + return ConvertLinkWorkflowToNexusLink(wl) +} - return no, nil +// ConvertLinkNexusOperationToNexusLink converts a Link_NexusOperation type to Nexus Link. +// +// NOTE: Experimental +func ConvertLinkNexusOperationToNexusLink(no *commonpb.Link_NexusOperation) nexus.Link { + return nexusOperationLinkType.encode(no.GetNamespace(), no.GetOperationId(), no.GetRunId(), "") } // ConvertLinkActivityToNexusLink converts a Link_Activity type to Nexus Link. // // NOTE: Experimental func ConvertLinkActivityToNexusLink(a *commonpb.Link_Activity) nexus.Link { - u := &url.URL{ - Scheme: urlSchemeTemporalKey, - Path: fmt.Sprintf(urlPathActivityTemplate, a.GetNamespace(), a.GetActivityId(), a.GetRunId()), - RawPath: fmt.Sprintf( - urlPathActivityTemplate, - url.PathEscape(a.GetNamespace()), - url.PathEscape(a.GetActivityId()), - url.PathEscape(a.GetRunId()), - ), - } - - return nexus.Link{ - URL: u, - Type: string(a.ProtoReflect().Descriptor().FullName()), - } + return activityLinkType.encode(a.GetNamespace(), a.GetActivityId(), a.GetRunId(), "") } -// ConvertNexusLinkToLinkActivity converts a Nexus Link to Link_Activity. +// CommonLinkToNexusLink converts a common.v1.Link into a nexus.v1.Link, dispatching on the link's +// variant. Returns (nil, false) for any variant not handled here. // // NOTE: Experimental -func ConvertNexusLinkToLinkActivity(link nexus.Link) (*commonpb.Link_Activity, error) { - a := &commonpb.Link_Activity{} - if link.Type != activityLinkType { - return nil, fmt.Errorf( - "cannot parse link type %q to %q", - link.Type, - activityLinkType, - ) - } - - if link.URL == nil { - return nil, errors.New("failed to parse link to Link_Activity: empty URL") - } - - if link.URL.Scheme != urlSchemeTemporalKey { - return nil, fmt.Errorf( - "failed to parse link to Link_Activity: invalid scheme: %s", - link.URL.Scheme, - ) - } - - matches := urlPathActivityRE.FindStringSubmatch(link.URL.EscapedPath()) - if len(matches) != 4 { - return nil, errors.New("failed to parse link to Link_Activity: malformed URL path") - } - - var err error - a.Namespace, err = url.PathUnescape(matches[urlPathActivityRE.SubexpIndex(urlPathNamespaceKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_Activity: %w", err) - } - - a.ActivityId, err = url.PathUnescape(matches[urlPathActivityRE.SubexpIndex(urlPathActivityIDKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_Activity: %w", err) - } - - a.RunId, err = url.PathUnescape(matches[urlPathActivityRE.SubexpIndex(urlPathRunIDKey)]) - if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_Activity: %w", err) +func CommonLinkToNexusLink(link *commonpb.Link) (*nexuspb.Link, bool) { + var nexusLink nexus.Link + switch v := link.GetVariant().(type) { + case *commonpb.Link_WorkflowEvent_: + if v.WorkflowEvent == nil { + return nil, false + } + nexusLink = ConvertLinkWorkflowEventToNexusLink(v.WorkflowEvent) + case *commonpb.Link_NexusOperation_: + if v.NexusOperation == nil { + return nil, false + } + nexusLink = ConvertLinkNexusOperationToNexusLink(v.NexusOperation) + case *commonpb.Link_Activity_: + if v.Activity == nil { + return nil, false + } + nexusLink = ConvertLinkActivityToNexusLink(v.Activity) + case *commonpb.Link_Workflow_: + if v.Workflow == nil { + return nil, false + } + nexusLink = ConvertLinkWorkflowToNexusLink(v.Workflow) + default: + return nil, false } - return a, nil + return &nexuspb.Link{Url: nexusLink.URL.String(), Type: nexusLink.Type}, true } -// ConvertLinkWorkflowToNexusLink converts a Link_Workflow type to Nexus Link. +// Inbound: Nexus link -> common.v1.Link. + +// ConvertNexusLinkToLinkWorkflowEvent converts a Nexus Link to Link_WorkflowEvent. // // NOTE: Experimental -func ConvertLinkWorkflowToNexusLink(w *commonpb.Link_Workflow) nexus.Link { - u := &url.URL{ - Scheme: urlSchemeTemporalKey, - Path: fmt.Sprintf(urlPathWorkflowTemplate, w.GetNamespace(), w.GetWorkflowId(), w.GetRunId()), - RawPath: fmt.Sprintf( - urlPathWorkflowTemplate, - url.PathEscape(w.GetNamespace()), - url.PathEscape(w.GetWorkflowId()), - url.PathEscape(w.GetRunId()), - ), +func ConvertNexusLinkToLinkWorkflowEvent(link nexus.Link) (*commonpb.Link_WorkflowEvent, error) { + d, err := workflowEventLinkType.decode(link) + if err != nil { + return nil, err } - - if w.GetReason() != "" { - values := url.Values{} - values.Set(linkReasonKey, w.GetReason()) - u.RawQuery = values.Encode() + we := &commonpb.Link_WorkflowEvent{ + Namespace: d.namespace, + WorkflowId: d.id, + RunId: d.runID, } - return nexus.Link{ - URL: u, - Type: string(w.ProtoReflect().Descriptor().FullName()), + switch refType := d.query.Get(linkWorkflowEventReferenceTypeKey); refType { + case eventReferenceType: + eventRef, err := convertURLQueryToLinkWorkflowEventEventReference(d.query) + if err != nil { + return nil, workflowEventLinkType.errorf("%w", err) + } + we.Reference = &commonpb.Link_WorkflowEvent_EventRef{EventRef: eventRef} + case requestIDReferenceType: + requestIDRef, err := convertURLQueryToLinkWorkflowEventRequestIdReference(d.query) + if err != nil { + return nil, workflowEventLinkType.errorf("%w", err) + } + we.Reference = &commonpb.Link_WorkflowEvent_RequestIdRef{RequestIdRef: requestIDRef} + default: + return nil, workflowEventLinkType.errorf("unknown reference type: %q", refType) } + return we, nil } // ConvertNexusLinkToLinkWorkflow converts a Nexus Link to Link_Workflow. // // NOTE: Experimental func ConvertNexusLinkToLinkWorkflow(link nexus.Link) (*commonpb.Link_Workflow, error) { - w := &commonpb.Link_Workflow{} - if link.Type != workflowLinkType { - return nil, fmt.Errorf( - "cannot parse link type %q to %q", - link.Type, - workflowLinkType, - ) - } - - if link.URL == nil { - return nil, errors.New("failed to parse link to Link_Workflow: empty URL") - } - - if link.URL.Scheme != urlSchemeTemporalKey { - return nil, fmt.Errorf( - "failed to parse link to Link_Workflow: invalid scheme: %s", - link.URL.Scheme, - ) - } - - matches := urlPathWorkflowRE.FindStringSubmatch(link.URL.EscapedPath()) - if len(matches) != 4 { - return nil, errors.New("failed to parse link to Link_Workflow: malformed URL path") - } - - var err error - w.Namespace, err = url.PathUnescape(matches[urlPathWorkflowRE.SubexpIndex(urlPathNamespaceKey)]) + d, err := workflowLinkType.decode(link) if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_Workflow: %w", err) + return nil, err } + return &commonpb.Link_Workflow{ + Namespace: d.namespace, + WorkflowId: d.id, + RunId: d.runID, + Reason: d.query.Get(linkReasonKey), + }, nil +} - w.WorkflowId, err = url.PathUnescape(matches[urlPathWorkflowRE.SubexpIndex(urlPathWorkflowIDKey)]) +// ConvertNexusLinkToLinkNexusOperation converts a Nexus Link to Link_NexusOperation. +// +// NOTE: Experimental +func ConvertNexusLinkToLinkNexusOperation(link nexus.Link) (*commonpb.Link_NexusOperation, error) { + d, err := nexusOperationLinkType.decode(link) if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_Workflow: %w", err) + return nil, err } + return &commonpb.Link_NexusOperation{ + Namespace: d.namespace, + OperationId: d.id, + RunId: d.runID, + }, nil +} - w.RunId, err = url.PathUnescape(matches[urlPathWorkflowRE.SubexpIndex(urlPathRunIDKey)]) +// ConvertNexusLinkToLinkActivity converts a Nexus Link to Link_Activity. +// +// NOTE: Experimental +func ConvertNexusLinkToLinkActivity(link nexus.Link) (*commonpb.Link_Activity, error) { + d, err := activityLinkType.decode(link) if err != nil { - return nil, fmt.Errorf("failed to parse link to Link_Workflow: %w", err) + return nil, err } - - w.Reason = link.URL.Query().Get(linkReasonKey) - - return w, nil + return &commonpb.Link_Activity{ + Namespace: d.namespace, + ActivityId: d.id, + RunId: d.runID, + }, nil } // NexusLinkToCommonLink converts a nexus.v1.Link into a common.v1.Link, dispatching on link.Type. @@ -420,87 +290,43 @@ func NexusLinkToCommonLink(link *nexuspb.Link) (*commonpb.Link, bool) { } nexusLink.URL = u } + + var commonLink *commonpb.Link + var err error switch nexusLink.Type { - case workflowEventLinkType: - we, err := ConvertNexusLinkToLinkWorkflowEvent(nexusLink) - if err != nil { - return nil, false - } - return &commonpb.Link{ - Variant: &commonpb.Link_WorkflowEvent_{WorkflowEvent: we}, - }, true - case nexusOperationLinkType: - no, err := ConvertNexusLinkToLinkNexusOperation(nexusLink) - if err != nil { - return nil, false - } - return &commonpb.Link{ - Variant: &commonpb.Link_NexusOperation_{NexusOperation: no}, - }, true - case activityLinkType: - a, err := ConvertNexusLinkToLinkActivity(nexusLink) - if err != nil { - return nil, false - } - return &commonpb.Link{ - Variant: &commonpb.Link_Activity_{Activity: a}, - }, true - case workflowLinkType: - w, err := ConvertNexusLinkToLinkWorkflow(nexusLink) - if err != nil { - return nil, false - } - return &commonpb.Link{ - Variant: &commonpb.Link_Workflow_{Workflow: w}, - }, true + case workflowEventLinkType.name: + var we *commonpb.Link_WorkflowEvent + we, err = ConvertNexusLinkToLinkWorkflowEvent(nexusLink) + commonLink = &commonpb.Link{Variant: &commonpb.Link_WorkflowEvent_{WorkflowEvent: we}} + case nexusOperationLinkType.name: + var no *commonpb.Link_NexusOperation + no, err = ConvertNexusLinkToLinkNexusOperation(nexusLink) + commonLink = &commonpb.Link{Variant: &commonpb.Link_NexusOperation_{NexusOperation: no}} + case activityLinkType.name: + var a *commonpb.Link_Activity + a, err = ConvertNexusLinkToLinkActivity(nexusLink) + commonLink = &commonpb.Link{Variant: &commonpb.Link_Activity_{Activity: a}} + case workflowLinkType.name: + var w *commonpb.Link_Workflow + w, err = ConvertNexusLinkToLinkWorkflow(nexusLink) + commonLink = &commonpb.Link{Variant: &commonpb.Link_Workflow_{Workflow: w}} default: return nil, false } -} - -// CommonLinkToNexusLink converts a common.v1.Link into a nexus.v1.Link, dispatching on the link's -// variant. Returns (nil, false) for any variant not handled here. -// -// NOTE: Experimental -func CommonLinkToNexusLink(link *commonpb.Link) (*nexuspb.Link, bool) { - variant := link.GetVariant() - if variant == nil { - return nil, false - } - - switch v := variant.(type) { - case *commonpb.Link_WorkflowEvent_: - if v.WorkflowEvent == nil { - return nil, false - } - nexusLink := ConvertLinkWorkflowEventToNexusLink(v.WorkflowEvent) - return &nexuspb.Link{Url: nexusLink.URL.String(), Type: nexusLink.Type}, true - case *commonpb.Link_NexusOperation_: - if v.NexusOperation == nil { - return nil, false - } - nexusLink := ConvertLinkNexusOperationToNexusLink(v.NexusOperation) - return &nexuspb.Link{Url: nexusLink.URL.String(), Type: nexusLink.Type}, true - case *commonpb.Link_Activity_: - if v.Activity == nil { - return nil, false - } - nexusLink := ConvertLinkActivityToNexusLink(v.Activity) - return &nexuspb.Link{Url: nexusLink.URL.String(), Type: nexusLink.Type}, true - case *commonpb.Link_Workflow_: - if v.Workflow == nil { - return nil, false - } - nexusLink := ConvertLinkWorkflowToNexusLink(v.Workflow) - return &nexuspb.Link{Url: nexusLink.URL.String(), Type: nexusLink.Type}, true - default: + if err != nil { return nil, false } + return commonLink, true } +// The WorkflowEvent reference names which event in the workflow's history the link points at, +// either by event ID or by the request ID that produced the event. It is flattened into the query +// as referenceType plus eventID or requestID, and eventType. + func convertLinkWorkflowEventEventReferenceToURLQuery(eventRef *commonpb.Link_WorkflowEvent_EventReference) string { values := url.Values{} values.Set(linkWorkflowEventReferenceTypeKey, eventReferenceType) + // Zero is not a valid event ID, so an unset ID is omitted rather than sent. if eventRef.GetEventId() > 0 { values.Set(linkEventIDKey, strconv.FormatInt(eventRef.GetEventId(), 10)) } @@ -511,9 +337,8 @@ func convertLinkWorkflowEventEventReferenceToURLQuery(eventRef *commonpb.Link_Wo func convertURLQueryToLinkWorkflowEventEventReference(queryValues url.Values) (*commonpb.Link_WorkflowEvent_EventReference, error) { var err error eventRef := &commonpb.Link_WorkflowEvent_EventReference{} - eventIDValue := queryValues.Get(linkEventIDKey) - if eventIDValue != "" { - eventRef.EventId, err = strconv.ParseInt(queryValues.Get(linkEventIDKey), 10, 64) + if eventIDValue := queryValues.Get(linkEventIDKey); eventIDValue != "" { + eventRef.EventId, err = strconv.ParseInt(eventIDValue, 10, 64) if err != nil { return nil, err } @@ -534,13 +359,12 @@ func convertLinkWorkflowEventRequestIdReferenceToURLQuery(requestIDRef *commonpb } func convertURLQueryToLinkWorkflowEventRequestIdReference(queryValues url.Values) (*commonpb.Link_WorkflowEvent_RequestIdReference, error) { - var err error - requestIDRef := &commonpb.Link_WorkflowEvent_RequestIdReference{ - RequestId: queryValues.Get(linkRequestIDKey), - } - requestIDRef.EventType, err = enumspb.EventTypeFromString(queryValues.Get(linkEventTypeKey)) + eventType, err := enumspb.EventTypeFromString(queryValues.Get(linkEventTypeKey)) if err != nil { return nil, err } - return requestIDRef, nil + return &commonpb.Link_WorkflowEvent_RequestIdReference{ + RequestId: queryValues.Get(linkRequestIDKey), + EventType: eventType, + }, nil } diff --git a/temporalnexus/link_converter_test.go b/temporalnexus/link_converter_test.go index a74c11ee..e588fb7d 100644 --- a/temporalnexus/link_converter_test.go +++ b/temporalnexus/link_converter_test.go @@ -971,3 +971,85 @@ func TestNexusLinkToCommonLinkUnsupported(t *testing.T) { }) require.False(t, ok) } + +// ConvertWorkflowLinkToNexusLink used to emit a workflow-event path ending in /history, which +// ConvertNexusLinkToLinkWorkflow rejects, so its output could never be decoded as a Link_Workflow. +func TestConvertWorkflowLinkToNexusLink(t *testing.T) { + input := &commonpb.Link_Workflow{ + Namespace: "ns", + WorkflowId: "wf-id", + RunId: "run-id", + Reason: "rejected update", + } + + link := temporalnexus.ConvertWorkflowLinkToNexusLink(input) + require.Equal(t, temporalnexus.ConvertLinkWorkflowToNexusLink(input), link) + + output, err := temporalnexus.ConvertNexusLinkToLinkWorkflow(link) + require.NoError(t, err) + if diff := cmp.Diff(input, output, protocmp.Transform()); diff != "" { + assert.Fail(t, "Proto mismatch (-want +got):\n"+diff) + } +} + +// Every link type builds its path through the same encoder, so escaping is checked for all of +// them: a space in a path segment is %20, never the form-encoded +, and a literal + survives. +func TestLinkIDEscapingRoundTrip(t *testing.T) { + const id = "id with space+plus/slash%percent" + cases := []struct { + name string + link *commonpb.Link + }{ + { + name: "workflow event", + link: &commonpb.Link{Variant: &commonpb.Link_WorkflowEvent_{WorkflowEvent: &commonpb.Link_WorkflowEvent{ + Namespace: id, + WorkflowId: id, + RunId: id, + Reference: &commonpb.Link_WorkflowEvent_EventRef{EventRef: &commonpb.Link_WorkflowEvent_EventReference{ + EventType: enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, + }}, + }}}, + }, + { + name: "workflow", + link: &commonpb.Link{Variant: &commonpb.Link_Workflow_{Workflow: &commonpb.Link_Workflow{ + Namespace: id, + WorkflowId: id, + RunId: id, + }}}, + }, + { + name: "nexus operation", + link: &commonpb.Link{Variant: &commonpb.Link_NexusOperation_{NexusOperation: &commonpb.Link_NexusOperation{ + Namespace: id, + OperationId: id, + RunId: id, + }}}, + }, + { + name: "activity", + link: &commonpb.Link{Variant: &commonpb.Link_Activity_{Activity: &commonpb.Link_Activity{ + Namespace: id, + ActivityId: id, + RunId: id, + }}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + nexusLink, ok := temporalnexus.CommonLinkToNexusLink(tc.link) + require.True(t, ok) + u, err := url.Parse(nexusLink.GetUrl()) + require.NoError(t, err) + require.Contains(t, u.EscapedPath(), "/id%20with%20space+plus%2Fslash%25percent/") + + commonLink, ok := temporalnexus.NexusLinkToCommonLink(nexusLink) + require.True(t, ok) + if diff := cmp.Diff(tc.link, commonLink, protocmp.Transform()); diff != "" { + assert.Fail(t, "Proto mismatch (-want +got):\n"+diff) + } + }) + } +} From 058282a741bc75e8c28fba81a6b497267745cff5 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 1 Oct 2026 15:50:46 -0700 Subject: [PATCH 2/2] PR comment updates --- temporalnexus/link_converter.go | 16 ++++------------ temporalnexus/link_converter_test.go | 20 -------------------- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/temporalnexus/link_converter.go b/temporalnexus/link_converter.go index 23cdda3f..16cdd704 100644 --- a/temporalnexus/link_converter.go +++ b/temporalnexus/link_converter.go @@ -141,14 +141,6 @@ func ConvertLinkWorkflowToNexusLink(w *commonpb.Link_Workflow) nexus.Link { return workflowLinkType.encode(w.GetNamespace(), w.GetWorkflowId(), w.GetRunId(), rawQuery) } -// ConvertWorkflowLinkToNexusLink converts a Link_Workflow into a Nexus Link. -// -// Deprecated: Use [ConvertLinkWorkflowToNexusLink], which this now calls. This function previously -// produced a workflow-event path, ending in /history, that no decoder accepts as a Link_Workflow. -func ConvertWorkflowLinkToNexusLink(wl *commonpb.Link_Workflow) nexus.Link { - return ConvertLinkWorkflowToNexusLink(wl) -} - // ConvertLinkNexusOperationToNexusLink converts a Link_NexusOperation type to Nexus Link. // // NOTE: Experimental @@ -319,10 +311,10 @@ func NexusLinkToCommonLink(link *nexuspb.Link) (*commonpb.Link, bool) { return commonLink, true } -// The WorkflowEvent reference names which event in the workflow's history the link points at, -// either by event ID or by the request ID that produced the event. It is flattened into the query -// as referenceType plus eventID or requestID, and eventType. - +// convertLinkWorkflowEventEventReferenceToURLQuery flattens a WorkflowEvent reference into the +// link's query. The reference names which event in the workflow's history the link points at, +// either by event ID or by the request ID that produced the event, and is encoded as referenceType +// plus eventID or requestID, and eventType. func convertLinkWorkflowEventEventReferenceToURLQuery(eventRef *commonpb.Link_WorkflowEvent_EventReference) string { values := url.Values{} values.Set(linkWorkflowEventReferenceTypeKey, eventReferenceType) diff --git a/temporalnexus/link_converter_test.go b/temporalnexus/link_converter_test.go index e588fb7d..3721d2b5 100644 --- a/temporalnexus/link_converter_test.go +++ b/temporalnexus/link_converter_test.go @@ -972,26 +972,6 @@ func TestNexusLinkToCommonLinkUnsupported(t *testing.T) { require.False(t, ok) } -// ConvertWorkflowLinkToNexusLink used to emit a workflow-event path ending in /history, which -// ConvertNexusLinkToLinkWorkflow rejects, so its output could never be decoded as a Link_Workflow. -func TestConvertWorkflowLinkToNexusLink(t *testing.T) { - input := &commonpb.Link_Workflow{ - Namespace: "ns", - WorkflowId: "wf-id", - RunId: "run-id", - Reason: "rejected update", - } - - link := temporalnexus.ConvertWorkflowLinkToNexusLink(input) - require.Equal(t, temporalnexus.ConvertLinkWorkflowToNexusLink(input), link) - - output, err := temporalnexus.ConvertNexusLinkToLinkWorkflow(link) - require.NoError(t, err) - if diff := cmp.Diff(input, output, protocmp.Transform()); diff != "" { - assert.Fail(t, "Proto mismatch (-want +got):\n"+diff) - } -} - // Every link type builds its path through the same encoder, so escaping is checked for all of // them: a space in a path segment is %20, never the form-encoded +, and a literal + survives. func TestLinkIDEscapingRoundTrip(t *testing.T) {