Skip to content
Merged
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
120 changes: 119 additions & 1 deletion arazzo-designer-cli/internal/runner/executor/operation_finder.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,38 @@ func (of *OperationFinder) FindByID(operationID string) *OperationInfo {
return nil
}

// parseQualifiedOperationID parses the Arazzo spec form
// "$sourceDescriptions.NAME.operationId" into (sourceName, operationId, true).
// Returns ("", "", false) for any other string.
func parseQualifiedOperationID(expr string) (string, string, bool) {
const prefix = "$sourceDescriptions."
if !strings.HasPrefix(expr, prefix) {
return "", "", false
}
rest := expr[len(prefix):]
dot := strings.Index(rest, ".")
if dot < 0 {
return "", "", false
}
return rest[:dot], rest[dot+1:], true
}

// FindByIDInSource finds an operation by its operationId within a single named
// source description. Used when the step specifies a qualified operationId like
// "$sourceDescriptions.petStoreDescription.loginUser".
// It reuses FindByID by constructing a single-entry finder scoped to that source.
func (of *OperationFinder) FindByIDInSource(sourceName, operationID string) *OperationInfo {
sourceDescRaw, ok := of.SourceDescriptions[sourceName]
if !ok {
log.Printf("Source description %q not found", sourceName)
return nil
}
scoped := &OperationFinder{
SourceDescriptions: map[string]interface{}{sourceName: sourceDescRaw},
}
return scoped.FindByID(operationID)
}

// FindByHTTPPathAndMethod finds an operation by its HTTP path and method.
func (of *OperationFinder) FindByHTTPPathAndMethod(httpPath, httpMethod string) *OperationInfo {
targetMethod := strings.ToLower(httpMethod)
Expand Down Expand Up @@ -166,7 +198,12 @@ func (of *OperationFinder) FindByHTTPPathAndMethod(httpPath, httpMethod string)

// FindByPath finds an operation by source URL and JSON pointer.
// operationPath format: sourceURL#jsonPointer
// sourceURL may be a bare name, a URL, or a braced expression like
// "{$sourceDescriptions.petStoreDescription.url}".
func (of *OperationFinder) FindByPath(sourceURL, jsonPointer string) *OperationInfo {
// Strip surrounding braces and resolve "$sourceDescriptions.NAME.url" to "NAME"
sourceURL = resolveSourceDescriptionRef(strings.Trim(sourceURL, "{}"))

// Find the source description
sourceName, sourceDesc := of.findSourceDescription(sourceURL)
if sourceDesc == nil {
Expand All @@ -177,6 +214,18 @@ func (of *OperationFinder) FindByPath(sourceURL, jsonPointer string) *OperationI
return of.parseOperationPointer(jsonPointer, sourceName, sourceDesc)
}

// resolveSourceDescriptionRef converts a "$sourceDescriptions.NAME.url" expression
// (already stripped of surrounding braces) to just "NAME", which directly matches
// the key in the SourceDescriptions map. Any other string is returned unchanged.
func resolveSourceDescriptionRef(expr string) string {
const prefix = "$sourceDescriptions."
const suffix = ".url"
if strings.HasPrefix(expr, prefix) && strings.HasSuffix(expr, suffix) {
return expr[len(prefix) : len(expr)-len(suffix)]
}
return expr
}

// findSourceDescription finds a source description by URL or name.
func (of *OperationFinder) findSourceDescription(sourceURL string) (string, map[string]interface{}) {
// Exact name match
Expand Down Expand Up @@ -219,6 +268,13 @@ func (of *OperationFinder) parseOperationPointer(jsonPointer, sourceName string,
return info
}

// Approach 4: Path-only pointer (no HTTP method) — picks the first available method.
// Handles e.g. /paths/~1pet~1findByStatus without a trailing /get.
info = of.resolvePathOnly(jsonPointer, sourceName, sourceDesc)
if info != nil {
return info
}

log.Printf("Could not parse operation pointer: %s", jsonPointer)
return nil
}
Expand Down Expand Up @@ -427,6 +483,62 @@ func (of *OperationFinder) handleSpecialCases(jsonPointer, sourceName string, so
return nil
}

// resolvePathOnly handles JSON pointers that reference a path item rather than a
// specific operation, e.g. /paths/~1pet~1findByStatus (no HTTP method suffix).
// It returns the first HTTP method found for the decoded path, in httpMethods order.
func (of *OperationFinder) resolvePathOnly(jsonPointer, sourceName string, sourceDesc map[string]interface{}) *OperationInfo {
if !strings.HasPrefix(jsonPointer, "/paths/") {
return nil
}

// Everything after "/paths/" is the single encoded path token (e.g. ~1pet~1findByStatus).
encodedPath := strings.TrimPrefix(jsonPointer, "/paths/")
if encodedPath == "" {
return nil
}

// Decode JSON Pointer encoding: ~1 → /, ~0 → ~
httpPath := strings.ReplaceAll(encodedPath, "~1", "/")
httpPath = strings.ReplaceAll(httpPath, "~0", "~")
if !strings.HasPrefix(httpPath, "/") {
httpPath = "/" + httpPath
}

paths := toMap(sourceDesc["paths"])
if paths == nil {
return nil
}

pathItem := toMap(paths[httpPath])
if pathItem == nil {
return nil
}

baseURL, err := getBaseURL(sourceDesc)
if err != nil {
return nil
}

// Return the first HTTP method found for this path
for _, method := range httpMethods {
operation := toMap(pathItem[method])
if operation == nil {
continue
}
opID, _ := operation["operationId"].(string)
return &OperationInfo{
Source: sourceName,
Path: httpPath,
Method: method,
URL: baseURL + httpPath,
Operation: operation,
OperationID: opID,
}
}

return nil
}

// GetOperationsForWorkflow finds all operation references in a workflow dict.
func (of *OperationFinder) GetOperationsForWorkflow(workflow map[string]interface{}) []*OperationInfo {
var operations []*OperationInfo
Expand All @@ -439,7 +551,13 @@ func (of *OperationFinder) GetOperationsForWorkflow(workflow map[string]interfac
}

if opID, ok := step["operationId"].(string); ok && opID != "" {
if info := of.FindByID(opID); info != nil {
var info *OperationInfo
if sourceName, bareID, ok := parseQualifiedOperationID(opID); ok {
info = of.FindByIDInSource(sourceName, bareID)
} else {
info = of.FindByID(opID)
}
if info != nil {
operations = append(operations, info)
}
} else if opPath, ok := step["operationPath"].(string); ok && opPath != "" {
Expand Down
14 changes: 9 additions & 5 deletions arazzo-designer-cli/internal/runner/executor/step_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,21 +280,25 @@ func (se *StepExecutor) ExecuteStep(step map[string]interface{}, workflow map[st

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

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
arazzo: 1.0.0
info:
title: Toolshop OperationPath Example
summary: Demonstrates using operationPath with expressions and path-only pointers.
description: This example shows how to use the Arazzo spec compliant operationPath syntax.
version: 1.0.0

sourceDescriptions:
- name: toolshopDescription
url: toolshop-openapi.yaml
type: openapi

workflows:
- workflowId: operationPathWorkflow
summary: Demonstrate operationPath support
description: This workflow retrieves categories and a specific category using operationPath.
steps:
- stepId: getCategoriesStep
description: Retrieve all categories using operationPath with an expression and no method
# Uses expression resolution and defaults to 'get' method since it's the only one available at /categories
operationPath: '{$sourceDescriptions.toolshopDescription.url}#/paths/~1categories'
successCriteria:
- condition: $statusCode == 200
outputs:
firstCategoryId: "$response.body#/0/id"

- stepId: getSpecificCategoryStep
description: Retrieve a specific category using a parameterized path item pointer
# The pointer MUST match the path string in toolshop-openapi.yaml exactly: /categories/tree/{categoryId}
# / is escaped as ~1
operationPath: '{$sourceDescriptions.toolshopDescription.url}#/paths/~1categories~1tree~1{categoryId}'
parameters:
- name: categoryId
in: path
value: $steps.getCategoriesStep.outputs.firstCategoryId
successCriteria:
- condition: $statusCode == 200

outputs:
categoryData: $steps.getSpecificCategoryStep.outputs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
arazzo: 1.0.0
info:
title: Toolshop Qualified OperationID Example
summary: Demonstrates using scoped operationId references.
description: This example shows how to use the Arazzo spec compliant "$sourceDescriptions.NAME.operationId" syntax.
version: 1.0.0

sourceDescriptions:
- name: toolshopDescription
url: toolshop-openapi.yaml
type: openapi

workflows:
- workflowId: qualifiedOpIdWorkflow
summary: Demonstrate qualified operationId support
description: This workflow retrieves products using a qualified operationId scoped to the toolshop source.
steps:
- stepId: getProductsStep
description: Retrieve products using a scoped operationId
# Uses the qualified form to ensure we pick getProducts from toolshopDescription
operationId: $sourceDescriptions.toolshopDescription.getProducts
parameters:
- name: page
in: query
value: 1
successCriteria:
- condition: $statusCode == 200
outputs:
firstProductId: "$response.body#/data/0/id"

- stepId: getProductDetailStep
description: Retrieve details using another scoped operationId
operationId: $sourceDescriptions.toolshopDescription.getProduct
parameters:
- name: productId
in: path
value: $steps.getProductsStep.outputs.firstProductId
successCriteria:
- condition: $statusCode == 200

outputs:
productName: $steps.getProductDetailStep.outputs.response.body.name
Loading
Loading