Skip to content

Commit f0bcc85

Browse files
Port upstream Arazzo operation resolution fixes
Bring over wso2/vscode-extensions#2229 so the runner resolves sourceDescription-qualified operationPath and operationId values in this standalone repo. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b6e2cdd commit f0bcc85

7 files changed

Lines changed: 1134 additions & 103 deletions

File tree

arazzo-designer-cli/internal/runner/executor/operation_finder.go

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,38 @@ func (of *OperationFinder) FindByID(operationID string) *OperationInfo {
8383
return nil
8484
}
8585

86+
// parseQualifiedOperationID parses the Arazzo spec form
87+
// "$sourceDescriptions.NAME.operationId" into (sourceName, operationId, true).
88+
// Returns ("", "", false) for any other string.
89+
func parseQualifiedOperationID(expr string) (string, string, bool) {
90+
const prefix = "$sourceDescriptions."
91+
if !strings.HasPrefix(expr, prefix) {
92+
return "", "", false
93+
}
94+
rest := expr[len(prefix):]
95+
dot := strings.Index(rest, ".")
96+
if dot < 0 {
97+
return "", "", false
98+
}
99+
return rest[:dot], rest[dot+1:], true
100+
}
101+
102+
// FindByIDInSource finds an operation by its operationId within a single named
103+
// source description. Used when the step specifies a qualified operationId like
104+
// "$sourceDescriptions.petStoreDescription.loginUser".
105+
// It reuses FindByID by constructing a single-entry finder scoped to that source.
106+
func (of *OperationFinder) FindByIDInSource(sourceName, operationID string) *OperationInfo {
107+
sourceDescRaw, ok := of.SourceDescriptions[sourceName]
108+
if !ok {
109+
log.Printf("Source description %q not found", sourceName)
110+
return nil
111+
}
112+
scoped := &OperationFinder{
113+
SourceDescriptions: map[string]interface{}{sourceName: sourceDescRaw},
114+
}
115+
return scoped.FindByID(operationID)
116+
}
117+
86118
// FindByHTTPPathAndMethod finds an operation by its HTTP path and method.
87119
func (of *OperationFinder) FindByHTTPPathAndMethod(httpPath, httpMethod string) *OperationInfo {
88120
targetMethod := strings.ToLower(httpMethod)
@@ -166,7 +198,12 @@ func (of *OperationFinder) FindByHTTPPathAndMethod(httpPath, httpMethod string)
166198

167199
// FindByPath finds an operation by source URL and JSON pointer.
168200
// operationPath format: sourceURL#jsonPointer
201+
// sourceURL may be a bare name, a URL, or a braced expression like
202+
// "{$sourceDescriptions.petStoreDescription.url}".
169203
func (of *OperationFinder) FindByPath(sourceURL, jsonPointer string) *OperationInfo {
204+
// Strip surrounding braces and resolve "$sourceDescriptions.NAME.url" to "NAME"
205+
sourceURL = resolveSourceDescriptionRef(strings.Trim(sourceURL, "{}"))
206+
170207
// Find the source description
171208
sourceName, sourceDesc := of.findSourceDescription(sourceURL)
172209
if sourceDesc == nil {
@@ -177,6 +214,18 @@ func (of *OperationFinder) FindByPath(sourceURL, jsonPointer string) *OperationI
177214
return of.parseOperationPointer(jsonPointer, sourceName, sourceDesc)
178215
}
179216

217+
// resolveSourceDescriptionRef converts a "$sourceDescriptions.NAME.url" expression
218+
// (already stripped of surrounding braces) to just "NAME", which directly matches
219+
// the key in the SourceDescriptions map. Any other string is returned unchanged.
220+
func resolveSourceDescriptionRef(expr string) string {
221+
const prefix = "$sourceDescriptions."
222+
const suffix = ".url"
223+
if strings.HasPrefix(expr, prefix) && strings.HasSuffix(expr, suffix) {
224+
return expr[len(prefix) : len(expr)-len(suffix)]
225+
}
226+
return expr
227+
}
228+
180229
// findSourceDescription finds a source description by URL or name.
181230
func (of *OperationFinder) findSourceDescription(sourceURL string) (string, map[string]interface{}) {
182231
// Exact name match
@@ -219,6 +268,13 @@ func (of *OperationFinder) parseOperationPointer(jsonPointer, sourceName string,
219268
return info
220269
}
221270

271+
// Approach 4: Path-only pointer (no HTTP method) — picks the first available method.
272+
// Handles e.g. /paths/~1pet~1findByStatus without a trailing /get.
273+
info = of.resolvePathOnly(jsonPointer, sourceName, sourceDesc)
274+
if info != nil {
275+
return info
276+
}
277+
222278
log.Printf("Could not parse operation pointer: %s", jsonPointer)
223279
return nil
224280
}
@@ -427,6 +483,62 @@ func (of *OperationFinder) handleSpecialCases(jsonPointer, sourceName string, so
427483
return nil
428484
}
429485

486+
// resolvePathOnly handles JSON pointers that reference a path item rather than a
487+
// specific operation, e.g. /paths/~1pet~1findByStatus (no HTTP method suffix).
488+
// It returns the first HTTP method found for the decoded path, in httpMethods order.
489+
func (of *OperationFinder) resolvePathOnly(jsonPointer, sourceName string, sourceDesc map[string]interface{}) *OperationInfo {
490+
if !strings.HasPrefix(jsonPointer, "/paths/") {
491+
return nil
492+
}
493+
494+
// Everything after "/paths/" is the single encoded path token (e.g. ~1pet~1findByStatus).
495+
encodedPath := strings.TrimPrefix(jsonPointer, "/paths/")
496+
if encodedPath == "" {
497+
return nil
498+
}
499+
500+
// Decode JSON Pointer encoding: ~1 → /, ~0 → ~
501+
httpPath := strings.ReplaceAll(encodedPath, "~1", "/")
502+
httpPath = strings.ReplaceAll(httpPath, "~0", "~")
503+
if !strings.HasPrefix(httpPath, "/") {
504+
httpPath = "/" + httpPath
505+
}
506+
507+
paths := toMap(sourceDesc["paths"])
508+
if paths == nil {
509+
return nil
510+
}
511+
512+
pathItem := toMap(paths[httpPath])
513+
if pathItem == nil {
514+
return nil
515+
}
516+
517+
baseURL, err := getBaseURL(sourceDesc)
518+
if err != nil {
519+
return nil
520+
}
521+
522+
// Return the first HTTP method found for this path
523+
for _, method := range httpMethods {
524+
operation := toMap(pathItem[method])
525+
if operation == nil {
526+
continue
527+
}
528+
opID, _ := operation["operationId"].(string)
529+
return &OperationInfo{
530+
Source: sourceName,
531+
Path: httpPath,
532+
Method: method,
533+
URL: baseURL + httpPath,
534+
Operation: operation,
535+
OperationID: opID,
536+
}
537+
}
538+
539+
return nil
540+
}
541+
430542
// GetOperationsForWorkflow finds all operation references in a workflow dict.
431543
func (of *OperationFinder) GetOperationsForWorkflow(workflow map[string]interface{}) []*OperationInfo {
432544
var operations []*OperationInfo
@@ -439,7 +551,13 @@ func (of *OperationFinder) GetOperationsForWorkflow(workflow map[string]interfac
439551
}
440552

441553
if opID, ok := step["operationId"].(string); ok && opID != "" {
442-
if info := of.FindByID(opID); info != nil {
554+
var info *OperationInfo
555+
if sourceName, bareID, ok := parseQualifiedOperationID(opID); ok {
556+
info = of.FindByIDInSource(sourceName, bareID)
557+
} else {
558+
info = of.FindByID(opID)
559+
}
560+
if info != nil {
443561
operations = append(operations, info)
444562
}
445563
} else if opPath, ok := step["operationPath"].(string); ok && opPath != "" {

arazzo-designer-cli/internal/runner/executor/step_executor.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -280,21 +280,25 @@ func (se *StepExecutor) ExecuteStep(step map[string]interface{}, workflow map[st
280280

281281
// findOperation locates the API operation for a step.
282282
func (se *StepExecutor) findOperation(step map[string]interface{}) *OperationInfo {
283-
// Try operationId first
283+
// Try operationId first.
284+
// Supports both plain operationId and the qualified Arazzo spec form
285+
// "$sourceDescriptions.NAME.operationId" which scopes the search to one source.
284286
if opID, ok := step["operationId"].(string); ok && opID != "" {
285287
log.Printf("Looking up operation by ID: %s", opID)
288+
if sourceName, bareID, ok := parseQualifiedOperationID(opID); ok {
289+
return se.OperationFinder.FindByIDInSource(sourceName, bareID)
290+
}
286291
return se.OperationFinder.FindByID(opID)
287292
}
288293

289-
// Try operationPath (e.g. "{$sourceDescriptions.petstore.url}#/pets/{petId}")
294+
// Try operationPath (e.g. "{$sourceDescriptions.petstore.url}#/paths/~1pets/get")
290295
if opPath, ok := step["operationPath"].(string); ok && opPath != "" {
291296
log.Printf("Looking up operation by path: %s", opPath)
292297
// Parse the operationPath: "{sourceURL}#{jsonPointer}" or "sourceURL#jsonPointer"
298+
// FindByPath handles brace-stripping and $sourceDescriptions expression resolution.
293299
parts := strings.SplitN(opPath, "#", 2)
294300
if len(parts) == 2 {
295-
sourceURL := strings.Trim(parts[0], "{}")
296-
jsonPointer := parts[1]
297-
return se.OperationFinder.FindByPath(sourceURL, jsonPointer)
301+
return se.OperationFinder.FindByPath(parts[0], parts[1])
298302
}
299303
}
300304

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
arazzo: 1.0.0
2+
info:
3+
title: Toolshop OperationPath Example
4+
summary: Demonstrates using operationPath with expressions and path-only pointers.
5+
description: This example shows how to use the Arazzo spec compliant operationPath syntax.
6+
version: 1.0.0
7+
8+
sourceDescriptions:
9+
- name: toolshopDescription
10+
url: toolshop-openapi.yaml
11+
type: openapi
12+
13+
workflows:
14+
- workflowId: operationPathWorkflow
15+
summary: Demonstrate operationPath support
16+
description: This workflow retrieves categories and a specific category using operationPath.
17+
steps:
18+
- stepId: getCategoriesStep
19+
description: Retrieve all categories using operationPath with an expression and no method
20+
# Uses expression resolution and defaults to 'get' method since it's the only one available at /categories
21+
operationPath: '{$sourceDescriptions.toolshopDescription.url}#/paths/~1categories'
22+
successCriteria:
23+
- condition: $statusCode == 200
24+
outputs:
25+
firstCategoryId: "$response.body#/0/id"
26+
27+
- stepId: getSpecificCategoryStep
28+
description: Retrieve a specific category using a parameterized path item pointer
29+
# The pointer MUST match the path string in toolshop-openapi.yaml exactly: /categories/tree/{categoryId}
30+
# / is escaped as ~1
31+
operationPath: '{$sourceDescriptions.toolshopDescription.url}#/paths/~1categories~1tree~1{categoryId}'
32+
parameters:
33+
- name: categoryId
34+
in: path
35+
value: $steps.getCategoriesStep.outputs.firstCategoryId
36+
successCriteria:
37+
- condition: $statusCode == 200
38+
39+
outputs:
40+
categoryData: $steps.getSpecificCategoryStep.outputs
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
arazzo: 1.0.0
2+
info:
3+
title: Toolshop Qualified OperationID Example
4+
summary: Demonstrates using scoped operationId references.
5+
description: This example shows how to use the Arazzo spec compliant "$sourceDescriptions.NAME.operationId" syntax.
6+
version: 1.0.0
7+
8+
sourceDescriptions:
9+
- name: toolshopDescription
10+
url: toolshop-openapi.yaml
11+
type: openapi
12+
13+
workflows:
14+
- workflowId: qualifiedOpIdWorkflow
15+
summary: Demonstrate qualified operationId support
16+
description: This workflow retrieves products using a qualified operationId scoped to the toolshop source.
17+
steps:
18+
- stepId: getProductsStep
19+
description: Retrieve products using a scoped operationId
20+
# Uses the qualified form to ensure we pick getProducts from toolshopDescription
21+
operationId: $sourceDescriptions.toolshopDescription.getProducts
22+
parameters:
23+
- name: page
24+
in: query
25+
value: 1
26+
successCriteria:
27+
- condition: $statusCode == 200
28+
outputs:
29+
firstProductId: "$response.body#/data/0/id"
30+
31+
- stepId: getProductDetailStep
32+
description: Retrieve details using another scoped operationId
33+
operationId: $sourceDescriptions.toolshopDescription.getProduct
34+
parameters:
35+
- name: productId
36+
in: path
37+
value: $steps.getProductsStep.outputs.firstProductId
38+
successCriteria:
39+
- condition: $statusCode == 200
40+
41+
outputs:
42+
productName: $steps.getProductDetailStep.outputs.response.body.name

0 commit comments

Comments
 (0)