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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
libopenapi has full support for OpenAPI 3, 3.1 and 3.2. It can handle the largest and most
complex specifications you can think of.

Overlays and Arazzo are also fully supported.

---

## Sponsors & users
Expand Down Expand Up @@ -78,6 +80,7 @@ See all the documentation at https://pb33f.io/libopenapi/
- [Bundling Specs](https://pb33f.io/libopenapi/bundling/)
- [What Changed / Diff Engine](https://pb33f.io/libopenapi/what-changed/)
- [Overlays](https://pb33f.io/libopenapi/overlays/)
- [Arazzo](https://pb33f.io/libopenapi/arazzo/)
- [FAQ](https://pb33f.io/libopenapi/faq/)
- [About libopenapi](https://pb33f.io/libopenapi/about/)
---
Expand Down
46 changes: 46 additions & 0 deletions arazzo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT

package libopenapi

import (
gocontext "context"
"fmt"

high "github.com/pb33f/libopenapi/datamodel/high/arazzo"
"github.com/pb33f/libopenapi/datamodel/low"
lowArazzo "github.com/pb33f/libopenapi/datamodel/low/arazzo"
"go.yaml.in/yaml/v4"
)

// NewArazzoDocument parses raw bytes into a high-level Arazzo document.
func NewArazzoDocument(arazzoBytes []byte) (*high.Arazzo, error) {
var rootNode yaml.Node
if err := yaml.Unmarshal(arazzoBytes, &rootNode); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}

if rootNode.Kind != yaml.DocumentNode || len(rootNode.Content) == 0 {
return nil, fmt.Errorf("invalid YAML document structure")
}

mappingNode := rootNode.Content[0]
if mappingNode.Kind != yaml.MappingNode {
return nil, fmt.Errorf("expected YAML mapping, got %v", mappingNode.Kind)
}

// Build the low-level model
lowDoc := &lowArazzo.Arazzo{}
if err := low.BuildModel(mappingNode, lowDoc); err != nil {
return nil, fmt.Errorf("failed to build low-level model: %w", err)
}

ctx := gocontext.Background()
if err := lowDoc.Build(ctx, nil, mappingNode, nil); err != nil {
return nil, fmt.Errorf("failed to build arazzo document: %w", err)
}

// Build the high-level model
highDoc := high.NewArazzo(lowDoc)
return highDoc, nil
}
261 changes: 261 additions & 0 deletions arazzo/actions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley
// SPDX-License-Identifier: MIT

package arazzo

import (
"context"
"fmt"
"math"
"strings"
"time"

"github.com/pb33f/libopenapi/arazzo/expression"
high "github.com/pb33f/libopenapi/datamodel/high/arazzo"
"github.com/pb33f/libopenapi/orderedmap"
)

// actionTypeRequest groups the parameters for processActionTypeResult,
// normalizing both success and failure actions into a common structure.
type actionTypeRequest struct {
actionType string
workflowId string
stepId string
retryAfterSec float64
retryLimit int64
currentRetries int
}

type stepActionResult struct {
endWorkflow bool
retryCurrent bool
retryAfter time.Duration
jumpToStepIdx int
}

func (e *Engine) processSuccessActions(
ctx context.Context,
step *high.Step,
wf *high.Workflow,
exprCtx *expression.Context,
state *executionState,
stepIndexByID map[string]int,
) (*stepActionResult, error) {
action, err := e.selectSuccessAction(step.OnSuccess, wf.SuccessActions, exprCtx)
if err != nil {
return nil, err
}
if action == nil {
return &stepActionResult{jumpToStepIdx: -1}, nil
}
return e.processActionTypeResult(ctx, &actionTypeRequest{
actionType: action.Type,
workflowId: action.WorkflowId,
stepId: action.StepId,
}, exprCtx, state, stepIndexByID)
}

func (e *Engine) processFailureActions(
ctx context.Context,
step *high.Step,
wf *high.Workflow,
exprCtx *expression.Context,
state *executionState,
stepIndexByID map[string]int,
currentRetries int,
) (*stepActionResult, error) {
action, err := e.selectFailureAction(step.OnFailure, wf.FailureActions, exprCtx)
if err != nil {
return nil, err
}
if action == nil {
return &stepActionResult{jumpToStepIdx: -1}, nil
}
var retryAfterSec float64
if action.RetryAfter != nil {
retryAfterSec = *action.RetryAfter
}
var retryLimit int64
if action.RetryLimit != nil {
retryLimit = *action.RetryLimit
}
return e.processActionTypeResult(ctx, &actionTypeRequest{
actionType: action.Type,
workflowId: action.WorkflowId,
stepId: action.StepId,
retryAfterSec: retryAfterSec,
retryLimit: retryLimit,
currentRetries: currentRetries,
}, exprCtx, state, stepIndexByID)
}

func (e *Engine) processActionTypeResult(
ctx context.Context,
req *actionTypeRequest,
exprCtx *expression.Context,
state *executionState,
stepIndexByID map[string]int,
) (*stepActionResult, error) {
result := &stepActionResult{jumpToStepIdx: -1}
switch req.actionType {
case "end":
result.endWorkflow = true
case "goto":
if req.workflowId != "" {
wfResult, runErr := e.runWorkflow(ctx, req.workflowId, nil, state)
if runErr != nil {
return nil, runErr
}
exprCtx.Workflows = copyWorkflowContexts(state.workflowContexts)
if wfResult != nil && !wfResult.Success {
return nil, workflowFailureError(req.workflowId, wfResult)
}
result.endWorkflow = true
return result, nil
}
if req.stepId != "" {
idx, ok := stepIndexByID[req.stepId]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrStepIdNotInWorkflow, req.stepId)
}
result.jumpToStepIdx = idx
}
case "retry":
limit := req.retryLimit
if limit <= 0 {
limit = 1
}
if int64(req.currentRetries) >= limit {
return &stepActionResult{jumpToStepIdx: -1}, nil
}
result.retryCurrent = true
if req.retryAfterSec > 0 {
retryAfter := time.Duration(math.Round(req.retryAfterSec * float64(time.Second)))
if retryAfter > 0 {
result.retryAfter = retryAfter
}
}
}
return result, nil
}

func (e *Engine) selectSuccessAction(stepActions, workflowActions []*high.SuccessAction, exprCtx *expression.Context) (*high.SuccessAction, error) {
if action, err := e.findMatchingSuccessAction(stepActions, exprCtx); err != nil || action != nil {
return action, err
}
return e.findMatchingSuccessAction(workflowActions, exprCtx)
}

func (e *Engine) selectFailureAction(stepActions, workflowActions []*high.FailureAction, exprCtx *expression.Context) (*high.FailureAction, error) {
if action, err := e.findMatchingFailureAction(stepActions, exprCtx); err != nil || action != nil {
return action, err
}
return e.findMatchingFailureAction(workflowActions, exprCtx)
}

func (e *Engine) findMatchingSuccessAction(actions []*high.SuccessAction, exprCtx *expression.Context) (*high.SuccessAction, error) {
return findMatchingAction(actions, e.resolveSuccessAction,
func(a *high.SuccessAction) []*high.Criterion { return a.Criteria },
e.evaluateActionCriteria, exprCtx)
}

func (e *Engine) findMatchingFailureAction(actions []*high.FailureAction, exprCtx *expression.Context) (*high.FailureAction, error) {
return findMatchingAction(actions, e.resolveFailureAction,
func(a *high.FailureAction) []*high.Criterion { return a.Criteria },
e.evaluateActionCriteria, exprCtx)
}

// findMatchingAction iterates actions, resolves component references, evaluates criteria,
// and returns the first action whose criteria all pass.
func findMatchingAction[T any](
actions []T,
resolve func(T) (T, error),
getCriteria func(T) []*high.Criterion,
evalCriteria func([]*high.Criterion, *expression.Context) (bool, error),
exprCtx *expression.Context,
) (T, error) {
var zero T
for _, action := range actions {
resolved, err := resolve(action)
if err != nil {
return zero, err
}
matches, err := evalCriteria(getCriteria(resolved), exprCtx)
if err != nil {
return zero, err
}
if matches {
return resolved, nil
}
}
return zero, nil
}

func (e *Engine) resolveSuccessAction(action *high.SuccessAction) (*high.SuccessAction, error) {
if action == nil {
return nil, nil
}
if !action.IsReusable() {
return action, nil
}
if e.document == nil || e.document.Components == nil {
return nil, fmt.Errorf("%w: %q", ErrUnresolvedComponent, action.Reference)
}
return lookupComponent(action.Reference, "$components.successActions.",
e.document.Components.SuccessActions)
}

func (e *Engine) resolveFailureAction(action *high.FailureAction) (*high.FailureAction, error) {
if action == nil {
return nil, nil
}
if !action.IsReusable() {
return action, nil
}
if e.document == nil || e.document.Components == nil {
return nil, fmt.Errorf("%w: %q", ErrUnresolvedComponent, action.Reference)
}
return lookupComponent(action.Reference, "$components.failureActions.",
e.document.Components.FailureActions)
}

// lookupComponent resolves a $components reference against an ordered map.
func lookupComponent[T any](ref, prefix string, componentMap *orderedmap.Map[string, T]) (T, error) {
var zero T
if !strings.HasPrefix(ref, prefix) {
return zero, fmt.Errorf("%w: %q", ErrUnresolvedComponent, ref)
}
if componentMap == nil {
return zero, fmt.Errorf("%w: %q", ErrUnresolvedComponent, ref)
}
name := strings.TrimPrefix(ref, prefix)
resolved, ok := componentMap.Get(name)
if !ok {
return zero, fmt.Errorf("%w: %q", ErrUnresolvedComponent, ref)
}
return resolved, nil
}

// evaluateActionCriteria evaluates all criteria for an action, using per-engine caches.
func (e *Engine) evaluateActionCriteria(criteria []*high.Criterion, exprCtx *expression.Context) (bool, error) {
if len(criteria) == 0 {
return true, nil
}
for i, criterion := range criteria {
ok, err := evaluateCriterionImpl(criterion, exprCtx, e.criterionCaches)
if err != nil {
return false, fmt.Errorf("failed to evaluate action criteria[%d]: %w", i, err)
}
if !ok {
return false, nil
}
}
return true, nil
}

func workflowFailureError(workflowID string, wfResult *WorkflowResult) error {
if wfResult != nil && wfResult.Error != nil {
return wfResult.Error
}
return fmt.Errorf("workflow %q failed", workflowID)
}
Loading