diff --git a/README.md b/README.md index 96ae512..8685b3f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ A validation module for [libopenapi](https://github.com/pb33f/libopenapi). Document validation is explicitly covered for OpenAPI 3.0, 3.1, and 3.2. + +The [`arazzo` package](arazzo/README.md) also validates Arazzo 1.0 and 1.1 +workflows, expressions and references to supplied API descriptions. `libopenapi-validator` will validate the following elements against an OpenAPI 3+ specification diff --git a/arazzo/README.md b/arazzo/README.md new file mode 100644 index 0000000..13f0396 --- /dev/null +++ b/arazzo/README.md @@ -0,0 +1,44 @@ +# Validate Arazzo workflows + +The `arazzo` package validates Arazzo 1.0 and 1.1 documents. It checks document +structure, workflow and step names, parameters, actions, expressions, input +schemas and prerequisite cycles. Supply OpenAPI, AsyncAPI or Arazzo source +documents to check referenced operations, channels and workflows. + +```go +result, err := arazzo.ValidateBytes(ctx, spec, "workflows.yaml", + arazzo.WithSources(candidates...)) +if err != nil { + return err +} +if !result.Valid() { + return fmt.Errorf("invalid Arazzo: %v", result.Diagnostics) +} +if !result.Complete { + return fmt.Errorf("validation incomplete: %v", result.Checks) +} +``` + +`Validate` accepts existing nodes through `arazzo.Document`. Source candidates +use `libopenapi/arazzo.CandidateDocument`; they can contain nodes, bytes, models +or adapters. See the [complete example](example_test.go). + +Always check the error, `Valid()` and `Complete`. `Valid()` means no conformance +errors were found. `Complete` means every applicable check ran. Missing sources, +partial adapter metadata and unsupported syntax can leave a valid document +incompletely checked. `Checks` gives the affected source, field and reason. +Errors return a non-nil incomplete result. Diagnostics include codes, source URIs, +JSON Pointers and line/column locations. Caller nodes and models stay unchanged. + +The validator does not run workflows or fetch files or URLs by default. +`WithResolver` enables caller-controlled source retrieval. It does not retrieve +external input schemas. XPath, legacy JSONPath and conditions that need runtime +substitution report incomplete checks. Rootless Arazzo models also have partial +expression coverage; supply original nodes or bytes for full checks. + +`WithLimits` bounds resource use; `WithAdvisories` enables optional authoring +warnings. Put whitespace around logical operators next to bare header +expressions: `&` and `|` are legal header-name characters. + +The [requirements ledger](REQUIREMENTS.md) lists specification clauses, tests and +known schema differences. diff --git a/arazzo/REQUIREMENTS.md b/arazzo/REQUIREMENTS.md new file mode 100644 index 0000000..4703809 --- /dev/null +++ b/arazzo/REQUIREMENTS.md @@ -0,0 +1,85 @@ +# Static validation requirements + +The normative sources are [Arazzo 1.0.1](https://spec.openapis.org/arazzo/v1.0.1.html) +and [Arazzo 1.1.0](https://spec.openapis.org/arazzo/v1.1.0.html). Section names below +refer to their named Object, Runtime Expressions and Specification Extensions +sections. Embedded schema provenance, dates, sizes and hashes are in +[`schemas/metadata.json`](schemas/metadata.json); `TestOfficialSchemaSnapshots` +checks the original bytes and offline compilation. No dependency change is needed. + +This ledger describes static checks. `Complete` means all applicable checks in +this implementation ran; it does not mean that workflows were executed. A skipped +capability always produces an incomplete `Check`. Tests exercise the exported API +except explicit Unit/Contract tests of graph, normalization and defensive internal boundaries. + +## Clause and regression ledger + +| Specification section / requirement | Check and diagnostic | Positive and negative regression families | +| --- | --- | --- | +| Arazzo Object: version, Info Object, required fields, field types and extension shapes | Offline schema; `CodeStructure`; malformed/unsupported versions have typed errors | `TestFoundationVersionsAndJSONParity`, `TestFoundationStructuralLocations`, `TestFoundationInputAndLimits` | +| Arazzo Object 1.1: `$self` and relative base identity | Canonical identity and retrieval attribution remain distinct; duplicate identities fail | `TestSourcesSelfIdentityAndAllCandidates`, `TestSourcesDuplicateIdentityAndLimits`, `TestSourcesApplicationBaseAndRelativeSelf` | +| Source Description Object: unique names, URI references and versioned type enum | Schema formats, identity index, actual supplied kind; `CodeDuplicateID`, `CodeSourceType` | `TestSemanticPublicIdentityInheritanceAndActions`, `TestSourcesAbsenceTransportAndType`, `TestSourcesCachedAliasChecksDeclaredKind` | +| Workflow Object: scoped workflow IDs, nonempty steps and workflow dependencies | Schema and document-qualified identity/prerequisite graph; `CodeDuplicateID`, `CodeReference`, `CodeDependency` | `TestSemanticPublicIdentityInheritanceAndActions`, `TestSemanticDependencyFieldGrammarAndCycles`, `TestSourcesImportCycleAndPrerequisiteCycle` | +| Step Object: scoped step IDs and exactly one target | Versioned schema plus exact target count, full operation/channel pointers and qualified IDs | `TestFoundationStructuralLocations`, `TestSourcesOfflineTargets`, `TestSourcesAdapterCoverageAndExactPointer`, `TestSourcesDottedSourceNames` | +| Parameter Object: location, unique effective identity, inheritance and explicit overrides | Immutable reusable resolution and default merge; consuming context; `CodeParameter` | `TestSemanticInheritedParameterContext`, `TestSemanticUnitReusableOverridesAndDuplicateIdentity`, `TestSemanticPublicWorkflowInputParameters`, `TestSourcesExternalStepMetadata` | +| Parameter Object 1.1: querystring and selector values | Query/querystring exclusion; allowed scalar/selector context | `TestSemanticPublicIdentityInheritanceAndActions`, `TestSelectorAndReplacementSyntax`, `TestFoundationNormativeCorrectionsAndAdvisories` | +| Success/Failure Action Objects: end/goto/retry unions, fields, criteria, targets and parameters | Schema checks every body; local contextual step targets checked at each use; `CodeAction`, `CodeReference`, `CodeParameter` | `TestSemanticActionsUseConsumingWorkflow`, `TestSemanticPublicIdentityInheritanceAndActions`, `TestSemanticPublicWorkflowInputParameters`, `TestCriteriaTraversalAndCapabilities` | +| Components and Reusable Object: correct collection, unused bodies and resolution cycles | Every defined body checked; exact collection/key resolution; `CodeReference` | `TestSemanticUnitReusableCollectionAndCycles`, `TestSemanticUnitReusableOverridesAndDuplicateIdentity`, `TestSemanticPublicImplicitCycleAndOverride` | +| Criterion and Expression Type Objects: language/version, required context, default dialect and syntax | Bounded simple grammar, regex, RFC 9535 JSONPath and RFC 6901 Pointer parsers; `CodeExpression` | `TestCriterionSyntax`, `TestCriteriaTraversalAndCapabilities` | +| Runtime Expressions: version and scoped inputs/outputs/source members | Upstream versioned grammar with 1.0 gates; static symbols checked without guessing runtime response properties; `CodeExpression`, `CodeReference` | `TestExpressionOutputSyntaxAndVersion`, `TestLinkedExpressionSymbols`, `TestLinkedExpressionKnownMissingOperation`, `TestSourcesRequestExpressionParameterDeclarations` | +| Selector Object 1.1: legal positions, context, dialect and extensions | Outputs and declared selector values checked; literal Any objects kept as data; `CodeSelector` | `TestSelectorAndReplacementSyntax`, `TestSelectorExtensionsAndLiteralSignature`, `TestExpressionsDoNotScanExtensionsOrBareDollar` | +| Request Body and Payload Replacement Objects | Expression-bearing payload/replacement values and target syntax; media type controls implicit replacement dialect | `TestSelectorAndReplacementSyntax`, `TestCriteriaTraversalAndCapabilities` | +| Input Schema Object: JSON Schema 2020-12 structure and local resource scope | Offline metaschema and compiler; local pointers, `$id`, anchors and dynamic references; `CodeInputSchema` for missing local refs | `TestInputSchema202012Structure`, `TestInputSchemaLocalRefsAndResourceScope`, `TestInputSchemaMissingLocalRefAndExternalCoverage`, `TestInputSchemaExternalSiblingDoesNotHideMissingLocalRef` | +| Input Schema Object: annotations, literals and conservative input membership | Schema-slot traversal excludes literal values; undeclared input is rejected only when a closed schema proves absence | `TestInputSchemaDataKeywordsDoNotDeclareReferences`, `TestInputUnknownAnnotationsRemainData`, `TestSemanticPublicWorkflowInputParameters`, `TestLinkedExpressionSymbols` | +| Step dependencies 1.1 and implicit output prerequisites | Field-specific grammar, scoped steps, linear shared workflow-start node and explicit/implicit cycle detection; `CodeDependency` | `TestSemanticDependencyFieldGrammarAndCycles`, `TestSemanticImplicitPrerequisitesAndDottedOutputs`, `TestSemanticPublicImplicitCycleAndOverride`, `TestSourcesExternalImplicitPrerequisiteCycle`, `TestDependencyGraphLongChain` | +| AsyncAPI Step Object 1.1: channel, action, correlation and timeout | Versioned schema and caller-supplied AsyncAPI 3 operation/channel metadata; no transport | `TestSourcesOfflineTargets`, `TestSourcesExternalStepMetadata`, `TestSourcesMixedOfflineComplete`, `ExampleValidate` | +| Specification Extensions and optional recommendations | Extension data is not scanned as expressions; advisory names/forward references use warnings | `TestExpressionsDoNotScanExtensionsOrBareDollar`, `TestFoundationNormativeCorrectionsAndAdvisories`, `TestSemanticImplicitPrerequisitesAndDottedOutputs` | + +## Deliberate decisions + +| Text/schema issue | Interpretation and scope | Evidence | +| --- | --- | --- | +| Source Description Object names: prose uses SHOULD; dated schemas enforce a pattern | Remove only that property pattern from the compiler view; naming warnings require `WithAdvisories`. Original snapshots are unchanged. | `TestFoundationNormativeCorrectionsAndAdvisories` | +| Qualified step prerequisites retain a strict source-name pattern after the advisory naming correction | The compiler view permits dotted source names here too. Semantic lookup selects an exact declared source, workflow and step. Runtime-expression ABNF has stricter identifier wording; this follows the package's documented advisory naming interpretation consistently. | `TestSourcesDottedStepDependencies` | +| Parameter Object 1.1 `value`: prose says Any; dated schema permits only Selector objects | Replace only that property's object restriction in the compiler view. Full selector signatures with runtime context are validated; ordinary literal objects retain their data. | `TestFoundationNormativeCorrectionsAndAdvisories`, `TestSelectorExtensionsAndLiteralSignature` | +| Action Object 1.1 `parameters`: schema leaves `items` unconstrained | Compiler view uses the existing Parameter/Reusable union for each item. Contextual input rules also apply. | `TestFoundationNormativeCorrectionsAndAdvisories`, `TestSemanticPublicWorkflowInputParameters` | +| Step Object operationPath/channelPath exclusivity has asymmetric prose | Require exactly one target in either feature set. A second authored target is never silently ignored. | `TestFoundationStructuralLocations`, `TestSourcesOfflineTargets` | +| Runtime Expressions 1.1 examples include structured body/payload property dereference that the upstream parser rejects | Accept the documented structured-source prefix followed by valid property dereference; retain all other upstream grammar checks. | `TestCriterionSyntax`, `TestSelectorAndReplacementSyntax` | +| Input Schema field schemas require objects, while JSON Schema also defines boolean schemas | Keep the authored Arazzo field object contract. Accept boolean schemas inside schema-valued keywords; do not coerce a top-level boolean input into an object. | `TestInputSchema202012Structure` and structural schema assertions | +| Input membership through composition, references or dynamic properties | Do not use absence from a direct properties map as proof. Only an unambiguous closed object excludes a name. | `TestSemanticPublicWorkflowInputParameters`, `TestLinkedExpressionSymbols` | +| AsyncAPI timeout schema has no minimum | Check integer type through the schema; do not add a nonnegative requirement. | `TestSourcesExternalStepMetadata` | +| Forward output ordering guidance is a recommendation; goto/retry and imports can cycle | Advisory forward references are warnings. Only prerequisite cycles are conformance errors. | `TestSemanticImplicitPrerequisitesAndDottedOutputs`, `TestSourcesImportCycleAndPrerequisiteCycle` | + +## Explicit capability records + +| Clause / unavailable evidence | Coverage record | Regression | +| --- | --- | --- | +| Source-linked target or dependency without a supplied document | Affected source reference and URI are incomplete; local checks still run | `TestSourcesAbsenceTransportAndType`, `TestSourcesUnusedResolverAndMissingMemberCapability` | +| Detailed parameters, inputs, steps or outputs with a presence-only adapter | The relevant metadata check is incomplete; a true existence result proves only presence | `TestSourcesAdapterCoverageAndExactPointer`, `TestLinkedExpressionPresenceAdapterCoverage` | +| Rootless Arazzo high models omit expression-bearing fields from their metadata projection | Consumed external prerequisite coverage is incomplete; full node/byte sources provide those checks | `TestSourcesRootlessArazzoPrerequisiteCoverage` | +| Source OpenAPI/AsyncAPI metadata behind unavailable external `$ref` | Detailed target/parameter check is incomplete; validation does not retrieve it | `TestSourcesUnavailableOpenAPIReferences` | +| Input Schema Object external resources | `input-schema-reference` records the resource URI and authored field. No schema downloader is installed. | `TestInputSchemaMissingLocalRefAndExternalCoverage`, `TestFoundationNoRetrieval` | +| XPath and legacy Goessner JSONPath syntax | `selector-syntax` records dialect and location | `TestCriteriaTraversalAndCapabilities`, `TestSelectorAndReplacementSyntax` | +| Criterion syntax after runtime substitution | Embedded expressions are checked; final `criterion-syntax` remains incomplete | `TestCriteriaTraversalAndCapabilities` | +| Replacement target whose dialect depends on unavailable operation content type | `replacement-target` is incomplete | `TestSelectorAndReplacementSyntax` | +| Receive-step criteria omission whose legality depends on message metadata | Source-dependent receive check remains incomplete when metadata cannot prove the condition | `TestSourcesExternalStepMetadata` | +| AsyncAPI parameter/message value compatibility | `asyncapi-parameters` records unavailable detailed compatibility; no runtime values are invented | `TestSourcesExternalStepMetadata` | + +## Operational evidence + +| Contract | Regression | +| --- | --- | +| No default file/network retrieval or operation execution | `TestFoundationNoRetrieval`; `ExampleValidate` uses application-owned file reads and supplied nodes. Production imports no executor or downloader. | +| Caller nodes, merges and aliases remain unchanged during concurrent validation | `TestFoundationMergeAliasImmutability`, `TestSourcesRootImmutable`, `TestSourcesConcurrentMergeRoots`, race run | +| High-model metadata fallback and retained result ownership | `TestSourcesRootlessOpenAPIModelTargets`, `TestResultDoesNotRetainSourceGraphs` (caller primary/source roots are collected while findings remain alive and serializable) | +| One document, precise numbers/null/false/zero, JSON-compatible YAML, duplicate keys and recursive aliases | `TestFoundationYAMLGraphsAndNumbers`, `TestFoundationInputAndLimits`, `TestFoundationVersionsAndJSONParity` | +| Stable detached locations, foreign URIs, escaped pointers and capped ordering | `TestFoundationPointerEscapesAndDeterminism`, `TestSemanticPublicDiagnosticLocations`, `TestSourcesExternalDiagnosticLocation`, `TestExpressionCappedDiagnosticsDeterministic` | +| Per-call source cache: diamond loaded once, legal import cycles, canonical identity and cancellation | `TestSourcesResolverMemoizesURI`, `TestSourcesDiamondCapsAndCancellation`, `TestSourcesImportCycleAndPrerequisiteCycle` | +| Aggregate input, node, depth, source, diagnostic and pattern limits | `TestFoundationCapsBeforeCompilation`, `TestSourcesDepthAndRootByteCaps`, `TestSourcesRootlessModelCollectionCaps`, `TestSourcesRootlessAdditionalOperationCaps`, `TestInputPatternLimitAndLiteralAnnotations` | +| Cross-document implicit prerequisites and RFC header tokens in criteria | `TestSourcesCrossDocumentImplicitCycle`, `TestExpressionHeaderPunctuationInCriteriaAndOutputs`, `TestCriterionHeaderOperatorBoundaries`, `TestSourcesCriterionHeaderTokenIdentity`; external step lookups share a bounded per-workflow index | +| Derived work and byte limits before inherited expansion and repeated parsing | `TestSemanticInheritedWorkLimit`, `TestExpressionInheritedParserByteBudget`; bounded shared graph and reference memoization | +| Cold schema compile and warm scaling for steps/sources/expressions, findings and shared reusable/dependency graphs | `BenchmarkCompileOfficialSchemas`, `BenchmarkValidate`, `BenchmarkValidateManyFindings`, `BenchmarkValidateSharedDependencies`, `BenchmarkValidateExternalSteps`; five samples per case | + +The full module test and build run with `GOWORK=off` and released dependencies. +Vacuum detection, rule bridges, reports, commands and LSP support remain a separate +consumer change. The fixture bundle and `ExampleValidate` are its API handback. diff --git a/arazzo/advice.go b/arazzo/advice.go new file mode 100644 index 0000000..b3f6c74 --- /dev/null +++ b/arazzo/advice.go @@ -0,0 +1,32 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "regexp" + "strconv" +) + +var recommendedName = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`) + +func checkAdvice(v *validation) { + if !v.opts.advisory { + return + } + emit := func(value, path string) { + if !recommendedName.MatchString(value) && v.check() { + v.emit(Diagnostic{Code: CodeAdvisory, Severity: SeverityWarning, Message: "name does not follow the recommended programming convention", Location: v.location(path)}) + } + } + for i, source := range array(v.root["sourceDescriptions"]) { + emit(text(object(source)["name"]), "/sourceDescriptions/"+strconv.Itoa(i)+"/name") + } + for i, workflow := range array(v.root["workflows"]) { + path := "/workflows/" + strconv.Itoa(i) + emit(text(object(workflow)["workflowId"]), path+"/workflowId") + for j, step := range array(object(workflow)["steps"]) { + emit(text(object(step)["stepId"]), path+"/steps/"+strconv.Itoa(j)+"/stepId") + } + } +} diff --git a/arazzo/benchmark_test.go b/arazzo/benchmark_test.go new file mode 100644 index 0000000..b87b879 --- /dev/null +++ b/arazzo/benchmark_test.go @@ -0,0 +1,156 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/pb33f/go-yaml" + upstream "github.com/pb33f/libopenapi/arazzo" +) + +func benchmarkNodes(b *testing.B, data string) *yaml.Node { + b.Helper() + var root yaml.Node + if err := yaml.Unmarshal([]byte(data), &root); err != nil { + b.Fatal(err) + } + return &root +} + +func BenchmarkValidateInputs(b *testing.B) { + data := strings.Replace(foundationYAML("1.1.0"), " steps:", " inputs: {type: object, properties: {id: {type: string}}, additionalProperties: false}\n steps:", 1) + doc := Document{Root: benchmarkNodes(b, data), URI: "https://example.test/main.yaml"} + opts := []Option{WithSources(upstream.CandidateDocument{RootNode: benchmarkNodes(b, sourceOpenAPI), RetrievalURI: "https://example.test/api.yaml"})} + ctx := context.Background() + if r, err := Validate(ctx, doc, opts...); err != nil || !r.Valid() || !r.Complete { + b.Fatalf("%+v %v", r, err) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := Validate(ctx, doc, opts...); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkValidate(b *testing.B) { + for _, count := range []int{10, 100, 1000} { + b.Run(fmt.Sprintf("steps-%d", count), func(b *testing.B) { + var spec, api strings.Builder + spec.WriteString("arazzo: 1.1.0\ninfo: {title: Bench, version: '1'}\nsourceDescriptions: [{name: api, url: 'api.yaml', type: openapi}]\nworkflows:\n- workflowId: run\n steps:\n") + api.WriteString("openapi: 3.1.0\ninfo: {title: Bench, version: '1'}\npaths:\n") + for i := range count { + fmt.Fprintf(&spec, " - stepId: s%d\n operationId: read%d\n successCriteria: [{condition: '$statusCode == 200'}]\n outputs: {id: '$response.body#/id'}\n", i, i) + fmt.Fprintf(&api, " /pets%d:\n get:\n operationId: read%d\n responses: {'200': {description: OK}}\n", i, i) + } + root := benchmarkNodes(b, spec.String()) + source := benchmarkNodes(b, api.String()) + doc := Document{Root: root, URI: "https://example.test/main.yaml"} + opts := []Option{WithSources(upstream.CandidateDocument{RootNode: source, RetrievalURI: "https://example.test/api.yaml"})} + ctx := context.Background() + if r, err := Validate(ctx, doc, opts...); err != nil || !r.Valid() || !r.Complete { + b.Fatalf("%+v %v", r, err) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := Validate(ctx, doc, opts...); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkValidateManyFindings(b *testing.B) { + for _, count := range []int{10, 100, 500} { + b.Run(fmt.Sprintf("findings-%d", count), func(b *testing.B) { + var spec strings.Builder + spec.WriteString("arazzo: 1.1.0\ninfo: {title: Bench, version: '1'}\nsourceDescriptions: [{name: api, url: 'api.yaml'}]\nworkflows:\n- workflowId: run\n steps:\n") + for i := range count { + fmt.Fprintf(&spec, " - {stepId: s%d, operationId: read, outputs: {id: 'not an expression'}}\n", i) + } + doc := Document{Root: benchmarkNodes(b, spec.String())} + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + r, err := Validate(context.Background(), doc) + if err != nil || len(r.Diagnostics) != count { + b.Fatalf("%+v %v", r, err) + } + } + }) + } +} + +// Compile both embedded schemas without the package cache to measure cold cost. +func BenchmarkCompileOfficialSchemas(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := compileOfficialSchemas(schemaFiles); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkValidateSharedDependencies(b *testing.B) { + for _, count := range []int{10, 100, 1000} { + b.Run(fmt.Sprintf("workflows-%d", count), func(b *testing.B) { + var spec strings.Builder + spec.WriteString("arazzo: 1.1.0\ninfo: {title: Bench, version: '1'}\nsourceDescriptions: [{name: api, url: api.yaml}, {name: flow, url: flow.yaml, type: arazzo}]\ncomponents:\n parameters:\n shared: {name: trace, in: header, value: '$response.header.trace'}\nworkflows:\n") + for i := range count { + fmt.Fprintf(&spec, "- workflowId: w%d\n dependsOn: ['$sourceDescriptions.flow.run']\n parameters: [{reference: '$components.parameters.shared'}]\n steps: [{stepId: read, operationId: read}]\n", i) + } + doc := Document{Root: benchmarkNodes(b, spec.String()), URI: "https://example.test/main.yaml"} + sources := []upstream.CandidateDocument{ + {RootNode: benchmarkNodes(b, "openapi: 3.1.0\ninfo: {title: API, version: '1'}\npaths: {/items: {get: {operationId: read, parameters: [{name: trace, in: header, schema: {type: string}}], responses: {'200': {description: OK}}}}}\n"), RetrievalURI: "https://example.test/api.yaml"}, + {RootNode: benchmarkNodes(b, foundationYAML("1.1.0")), RetrievalURI: "https://example.test/flow.yaml"}, + } + opts := []Option{WithSources(sources...)} + ctx := context.Background() + if r, err := Validate(ctx, doc, opts...); err != nil || !r.Valid() || !r.Complete { + b.Fatalf("%+v %v", r, err) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := Validate(ctx, doc, opts...); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkValidateExternalSteps(b *testing.B) { + for _, count := range []int{10, 100, 1000} { + b.Run(fmt.Sprintf("dependencies-%d", count), func(b *testing.B) { + var main, external strings.Builder + main.WriteString("arazzo: 1.1.0\ninfo: {title: Main, version: '1'}\nsourceDescriptions: [{name: other, url: other.yaml, type: arazzo}]\nworkflows:\n- workflowId: noop\n steps: [{stepId: noop, workflowId: noop}]\n- workflowId: run\n steps:\n") + external.WriteString("arazzo: 1.1.0\ninfo: {title: Other, version: '1'}\nsourceDescriptions: [{name: api, url: api.yaml, type: openapi}]\nworkflows:\n- workflowId: target\n steps:\n") + for i := range count { + fmt.Fprintf(&main, " - {stepId: s%d, workflowId: noop, dependsOn: ['$sourceDescriptions.other.target.steps.s%d']}\n", i, count-1) + fmt.Fprintf(&external, " - {stepId: s%d, operationId: read}\n", i) + } + doc := Document{Root: benchmarkNodes(b, main.String()), URI: "https://example.test/main.yaml"} + options := []Option{WithSources(upstream.CandidateDocument{RootNode: benchmarkNodes(b, external.String()), RetrievalURI: "https://example.test/other.yaml"})} + ctx := context.Background() + if result, err := Validate(ctx, doc, options...); err != nil || !result.Valid() || !result.Complete { + b.Fatalf("%+v %v", result, err) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := Validate(ctx, doc, options...); err != nil { + b.Fatal(err) + } + } + }) + } +} diff --git a/arazzo/example_test.go b/arazzo/example_test.go new file mode 100644 index 0000000..c9205dc --- /dev/null +++ b/arazzo/example_test.go @@ -0,0 +1,44 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo_test + +import ( + "context" + "fmt" + "os" + + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi-validator/arazzo" + upstream "github.com/pb33f/libopenapi/arazzo" +) + +func exampleNode(name string) *yaml.Node { + data, err := os.ReadFile("testdata/mixed-source/" + name) + if err != nil { + panic(err) + } + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + panic(err) + } + return &root +} + +func ExampleValidate() { + // The application supplies parsed source documents. Validation needs no + // OpenAPI Doctor model and performs no retrieval or workflow execution. + document := arazzo.Document{Root: exampleNode("workflow.yaml"), URI: "https://retrieval.test/main.yaml"} + var sources []upstream.CandidateDocument + for _, name := range []string{"api.yaml", "events.yaml", "flows.yaml"} { + sources = append(sources, upstream.CandidateDocument{ + RootNode: exampleNode(name), RetrievalURI: "https://example.test/" + name, + }) + } + result, err := arazzo.Validate(context.Background(), document, arazzo.WithSources(sources...)) + if err != nil { + panic(err) + } + fmt.Println(result.Version, result.Valid(), result.Complete) + // Output: 1.1 true true +} diff --git a/arazzo/expressions.go b/arazzo/expressions.go new file mode 100644 index 0000000..53107d2 --- /dev/null +++ b/arazzo/expressions.go @@ -0,0 +1,607 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "encoding/json" + "fmt" + "mime" + "strconv" + "strings" + + "github.com/dlclark/regexp2" + "github.com/pb33f/jsonpath/pkg/jsonpath" + jsonpathconfig "github.com/pb33f/jsonpath/pkg/jsonpath/config" + "github.com/pb33f/libopenapi/arazzo/expression" +) + +type ( + exprScope struct{ workflowID, stepID string } + expressionUse struct { + Expression expression.Expression + Path, WorkflowID, StepID string + } +) + +func checkExpressions(v *validation) { + for i, raw := range array(v.root["workflows"]) { + if !v.check() { + return + } + workflow := object(raw) + path := joinPtr("/workflows", strconv.Itoa(i)) + scope := exprScope{workflowID: text(workflow["workflowId"])} + v.checkOutputs(workflow["outputs"], joinPtr(path, "outputs"), scope) + for j, raw := range array(workflow["parameters"]) { + v.checkParameterExpressions(object(raw), joinPtr(joinPtr(path, "parameters"), strconv.Itoa(j)), scope) + } + for _, field := range []string{"successActions", "failureActions"} { + v.checkActions(workflow[field], joinPtr(path, field), scope) + } + for j, raw := range array(workflow["steps"]) { + step := object(raw) + sp := joinPtr(joinPtr(path, "steps"), strconv.Itoa(j)) + ss := exprScope{scope.workflowID, text(step["stepId"])} + v.checkOutputs(step["outputs"], joinPtr(sp, "outputs"), ss) + v.checkCriteria(step["successCriteria"], joinPtr(sp, "successCriteria"), ss) + for k, raw := range array(step["parameters"]) { + v.checkParameterExpressions(object(raw), joinPtr(joinPtr(sp, "parameters"), strconv.Itoa(k)), ss) + } + for _, field := range []string{"onSuccess", "onFailure"} { + v.checkActions(step[field], joinPtr(sp, field), ss) + } + if s, ok := step["correlationId"].(string); ok { + v.checkExpressionString(s, joinPtr(sp, "correlationId"), ss, true) + } + v.checkRequestBody(object(step["requestBody"]), joinPtr(sp, "requestBody"), ss) + } + } + components := object(v.root["components"]) + for _, name := range sortedKeys(object(components["parameters"])) { + raw := object(components["parameters"])[name] + v.checkParameterExpressions(object(raw), joinPtr("/components/parameters", name), exprScope{}) + } + for _, kind := range []string{"successActions", "failureActions"} { + for _, name := range sortedKeys(object(components[kind])) { + raw := object(components[kind])[name] + v.checkActionExpressions(object(raw), joinPtr(joinPtr("/components", kind), name), exprScope{}) + } + } +} + +func (v *validation) checkParameterExpressions(item map[string]any, path string, scope exprScope) { + v.checkValue(item["value"], joinPtr(path, "value"), scope) +} + +func (v *validation) checkActionExpressions(item map[string]any, path string, scope exprScope) { + v.checkCriteria(item["criteria"], joinPtr(path, "criteria"), scope) + for i, raw := range array(item["parameters"]) { + v.checkParameterExpressions(object(raw), joinPtr(joinPtr(path, "parameters"), strconv.Itoa(i)), scope) + } +} + +func (v *validation) checkActions(raw any, path string, scope exprScope) { + for i, item := range array(raw) { + v.checkActionExpressions(object(item), joinPtr(path, strconv.Itoa(i)), scope) + } +} + +func (v *validation) checkOutputs(raw any, path string, scope exprScope) { + values := object(raw) + for _, name := range sortedKeys(values) { + value := values[name] + p := joinPtr(path, name) + if s, ok := value.(string); ok { + v.checkExpressionString(s, p, scope, true) + } else if v.version == "1.1" { + v.checkSelector(object(value), p, scope) + } + } +} + +// Values are Any slots: literals remain literals. A full, exact Selector shape +// is the only unambiguous selector in these unions; extra members denote data. +func selectorValue(m map[string]any) bool { + context, ok := m["context"].(string) + if !ok || !strings.HasPrefix(context, "$") || context == "$" { + return false + } + if _, ok := m["selector"].(string); !ok { + return false + } + kind, _ := selectorDialect(m["type"], "") + if kind != "jsonpath" && kind != "xpath" && kind != "jsonpointer" { + return false + } + for key := range m { + if key != "context" && key != "selector" && key != "type" && !strings.HasPrefix(key, "x-") { + return false + } + } + return true +} + +func (v *validation) checkValue(value any, path string, scope exprScope) { + if !v.work(1, path) { + return + } + switch val := value.(type) { + case string: + if !v.workBytes(len(val), path) { + return + } + v.checkExpressionString(val, path, scope, false) + case map[string]any: + if v.version == "1.1" && selectorValue(val) { + v.checkSelector(val, path, scope) + return + } + _, hasContext := val["context"] + _, hasSelector := val["selector"] + _, hasType := val["type"] + for _, key := range sortedKeys(val) { + item := val[key] + // In an Any union a selector-looking literal remains ordinary data. + // Its selector string must not become an Arazzo expression merely + // because a JSONPath begins with '$'. Other payload values still + // contain expressions according to the Request Body field contract. + if hasContext && hasSelector && hasType && (key == "context" || key == "selector" || key == "type") { + continue + } + v.checkValue(item, joinPtr(path, key), scope) + } + case []any: + for i, item := range val { + v.checkValue(item, joinPtr(path, strconv.Itoa(i)), scope) + } + } +} + +func (v *validation) expressionVersion() expression.SpecVersion { + if v.version == "1.0" { + return expression.Arazzo10 + } + return expression.Arazzo11 +} + +func (v *validation) recordExpression(s, path string, scope exprScope, condition bool) error { + if !v.work(1, path) || !v.workBytes(len(s), path) { + return v.err + } + if len(s) > v.opts.limits.MaxPatternBytes { + v.err = &Error{Kind: ErrorLimit, Location: v.location(path), Cause: fmt.Errorf("expression exceeds %d bytes", v.opts.limits.MaxPatternBytes)} + return v.err + } + parsed, err := expression.ParseWithVersion(s, v.expressionVersion()) + // The condition/context examples explicitly permit property dereference on + // structured sources. Parse the source grammar before its dereference suffix. + if err != nil && (condition || v.version == "1.1") { + for _, base := range []string{"$request.body", "$response.body", "$request.payload", "$response.payload", "$message.payload", "$message.body"} { + if strings.HasPrefix(s, base+".") { + parsed, err = expression.ParseWithVersion(base, v.expressionVersion()) + if !simplePropertyTail(s[len(base):]) { + err = fmt.Errorf("invalid property dereference") + } + break + } + } + } + if err != nil { + return err + } + if v.version == "1.0" && (parsed.Type == expression.Self || parsed.Type == expression.RequestPayload || parsed.Type == expression.ResponsePayload || strings.HasPrefix(s, "$message.")) { + return fmt.Errorf("runtime expression is not defined in Arazzo 1.0") + } + v.expressionUses = append(v.expressionUses, expressionUse{parsed, path, scope.workflowID, scope.stepID}) + return nil +} + +func simplePropertyTail(s string) bool { + for len(s) > 0 { + if s[0] != '.' { + return false + } + s = s[1:] + i := 0 + for i < len(s) && (s[i] >= 'a' && s[i] <= 'z' || s[i] >= 'A' && s[i] <= 'Z' || s[i] >= '0' && s[i] <= '9' || s[i] == '_' || s[i] == '-') { + i++ + } + if i == 0 { + return false + } + s = s[i:] + } + return true +} + +func (v *validation) checkExpressionString(s, path string, scope exprScope, required bool) { + if strings.HasPrefix(s, "$") && s != "$" { + if err := v.recordExpression(s, path, scope, false); err != nil && v.err == nil { + v.add(CodeExpression, path, err.Error()) + } + return + } + if strings.Contains(s, "{$") { + _, err := v.checkEmbedded(s, path, scope) + if err != nil && v.err == nil { + v.add(CodeExpression, path, err.Error()) + } + return + } + if required { + v.add(CodeExpression, path, "value must be a runtime expression") + } +} + +// Only {$...} blocks are parsed. Other braces can belong to literal JSON or a +// regex quantifier and must not be interpreted as expression delimiters. +func (v *validation) checkEmbedded(s, path string, scope exprScope) (bool, error) { + found := false + for offset := 0; offset < len(s); { + start := strings.Index(s[offset:], "{$") + if start < 0 { + return found, nil + } + start += offset + end := strings.IndexByte(s[start+2:], '}') + if end < 0 { + return true, fmt.Errorf("unclosed embedded runtime expression") + } + end += start + 2 + if err := v.recordExpression(s[start+1:end], path, scope, false); err != nil { + return true, err + } + found = true + offset = end + 1 + } + return found, nil +} + +func (v *validation) checkCriteria(raw any, path string, scope exprScope) { + for i, item := range array(raw) { + if !v.check() { + return + } + c := object(item) + p := joinPtr(path, strconv.Itoa(i)) + condition, ok := c["condition"].(string) + if !ok { + continue + } + if !v.work(1, p) || !v.workBytes(len(condition), p+"/condition") { + return + } + kind, version := selectorDialect(c["type"], "simple") + if context, ok := c["context"].(string); ok { + if err := v.recordExpression(context, joinPtr(p, "context"), scope, true); err != nil && v.err == nil { + v.add(CodeExpression, joinPtr(p, "context"), err.Error()) + } + } else if _, specified := c["type"]; specified { + v.add(CodeExpression, p, "context is required when criterion type is specified") + } + cp := joinPtr(p, "condition") + if kind == "simple" { + parser := simpleParser{input: condition, v: v, path: cp, scope: scope} + if err := parser.parse(); err != nil && v.err == nil { + v.add(CodeExpression, cp, err.Error()) + } + } else { + embedded, err := v.checkEmbedded(condition, cp, scope) + if err != nil && v.err == nil { + v.add(CodeExpression, cp, err.Error()) + continue + } + if embedded { + v.incomplete("criterion-syntax", cp, "", "final condition syntax depends on runtime expression substitution") + continue + } + v.checkDialectSyntax(kind, version, condition, cp, CodeExpression) + } + } +} + +func selectorDialect(raw any, fallback string) (string, string) { + kind := fallback + version := "" + switch val := raw.(type) { + case string: + kind = val + case map[string]any: + kind = text(val["type"]) + version = text(val["version"]) + } + if version == "" { + switch kind { + case "jsonpath": + version = "rfc9535" + case "xpath": + version = "xpath-31" + case "jsonpointer": + version = "rfc6901" + } + } + return kind, version +} + +func (v *validation) checkSelector(m map[string]any, path string, scope exprScope) { + if s, ok := m["context"].(string); ok { + if err := v.recordExpression(s, joinPtr(path, "context"), scope, true); err != nil && v.err == nil { + v.add(CodeExpression, joinPtr(path, "context"), err.Error()) + } + } + if s, ok := m["selector"].(string); ok { + kind, version := selectorDialect(m["type"], "") + v.checkDialectSyntax(kind, version, s, joinPtr(path, "selector"), CodeSelector) + } +} + +func (v *validation) checkDialectSyntax(kind, version, s, path string, code Code) { + if !v.work(1, path) || !v.workBytes(len(s), path) { + return + } + if len(s) > v.opts.limits.MaxPatternBytes { + v.err = &Error{Kind: ErrorLimit, Location: v.location(path), Cause: fmt.Errorf("pattern exceeds %d bytes", v.opts.limits.MaxPatternBytes)} + return + } + var err error + switch kind { + case "regex": + _, err = regexp2.Compile(s, regexp2.ECMAScript) + case "jsonpath": + if version != "rfc9535" { + v.incomplete("selector-syntax", path, "", "JSONPath dialect "+version+" has no static parser") + return + } + _, err = jsonpath.NewPath(s, jsonpathconfig.WithStrictRFC9535(), jsonpathconfig.WithLazyContextTracking()) + case "jsonpointer": + err = validatePointer(s) + case "xpath": + v.incomplete("selector-syntax", path, "", "XPath dialect "+version+" has no static parser") + return + default: + return // structural validation reports unknown dialects + } + if err != nil { + v.add(code, path, "invalid "+kind+" syntax: "+err.Error()) + } +} + +func validatePointer(s string) error { + if _, ok := linkedPointerTokens(s); !ok { + return fmt.Errorf("invalid JSON Pointer syntax or UTF-8") + } + return nil +} + +func (v *validation) checkRequestBody(body map[string]any, path string, scope exprScope) { + v.checkValue(body["payload"], joinPtr(path, "payload"), scope) + media, _, _ := mime.ParseMediaType(text(body["contentType"])) + for i, raw := range array(body["replacements"]) { + r := object(raw) + p := joinPtr(joinPtr(path, "replacements"), strconv.Itoa(i)) + v.checkValue(r["value"], joinPtr(p, "value"), scope) + target, ok := r["target"].(string) + if !ok { + continue + } + kind, version := selectorDialect(r["targetSelectorType"], "") + if kind == "" { + switch { + case media == "application/json" || strings.HasSuffix(media, "+json"): + kind = "jsonpointer" + version = "rfc6901" + case media == "application/xml" || media == "text/xml" || strings.HasSuffix(media, "+xml"): + kind = "xpath" + version = "xpath-31" + default: + v.incomplete("replacement-target", joinPtr(p, "target"), "", "replacement dialect requires the target operation content type") + continue + } + } + v.checkDialectSyntax(kind, version, target, joinPtr(p, "target"), CodeSelector) + } +} + +// simpleParser checks the literal/operator grammar without evaluating any data. +// Recursive nesting shares the document depth cap; expression tokens use the +// upstream versioned runtime-expression parser and the same symbol-use hook. +type simpleParser struct { + input string + pos, depth int + v *validation + path string + scope exprScope +} + +func (p *simpleParser) parse() error { + if len(p.input) > p.v.opts.limits.MaxPatternBytes { + p.v.err = &Error{Kind: ErrorLimit, Location: p.v.location(p.path), Cause: fmt.Errorf("condition exceeds pattern limit")} + return p.v.err + } + if err := p.binary(1); err != nil { + return err + } + p.space() + if p.pos != len(p.input) { + return fmt.Errorf("unexpected token at byte %d", p.pos) + } + return nil +} + +func (p *simpleParser) space() { + for p.pos < len(p.input) && strings.ContainsRune(" \t\r\n", rune(p.input[p.pos])) { + p.pos++ + } +} + +func (p *simpleParser) binary(min int) error { + p.depth++ + defer func() { p.depth-- }() + if p.depth > p.v.opts.limits.MaxDepth { + p.v.err = &Error{Kind: ErrorLimit, Location: p.v.location(p.path), Cause: fmt.Errorf("condition nesting exceeds depth limit")} + return p.v.err + } + if err := p.atom(); err != nil { + return err + } + for { + p.space() + op, priority := p.operator() + if priority < min { + return nil + } + p.pos += len(op) + if err := p.binary(priority + 1); err != nil { + return err + } + } +} + +func (p *simpleParser) operator() (string, int) { + for _, o := range []struct { + s string + n int + }{{"||", 1}, {"&&", 2}, {"==", 3}, {"!=", 3}, {"<=", 3}, {">=", 3}, {"<", 3}, {">", 3}} { + if strings.HasPrefix(p.input[p.pos:], o.s) { + return o.s, o.n + } + } + return "", 0 +} + +func (p *simpleParser) atom() error { + p.space() + if p.pos == len(p.input) { + return fmt.Errorf("missing condition operand") + } + start := p.pos + switch p.input[p.pos] { + case '!': + p.pos++ + return p.binary(4) + case '(': + p.pos++ + if err := p.binary(1); err != nil { + return err + } + p.space() + if p.pos == len(p.input) || p.input[p.pos] != ')' { + return fmt.Errorf("unclosed condition grouping") + } + p.pos++ + case '\'': + p.pos++ + closed := false + for p.pos < len(p.input) { + if p.input[p.pos] == '\'' { + p.pos++ + if p.pos < len(p.input) && p.input[p.pos] == '\'' { + p.pos++ + continue + } + closed = true + break + } + p.pos++ + } + if !closed { + return fmt.Errorf("unclosed string literal") + } + case '$': + if prefix := simpleHeaderPrefix(p.input[start:]); prefix != "" { + p.pos += len(prefix) + // Header names consume the full RFC 7230 tchar token. Whitespace + // separates logical operators from otherwise ambiguous header names. + for p.pos < len(p.input) && simpleHeaderTchar(p.input[p.pos]) { + if strings.HasPrefix(p.input[p.pos:], "!=") && !strings.HasPrefix(p.input[p.pos:], "!==") { + break + } + p.pos++ + } + } else { + p.pos++ + pointer := false + for p.pos < len(p.input) { + c := p.input[p.pos] + if strings.ContainsRune(" \t\r\n()", rune(c)) { + break + } + if c == '#' { + pointer = true + } + if !pointer && strings.ContainsRune("[]!<>=&|", rune(c)) { + break + } + p.pos++ + } + } + if err := p.v.recordExpression(p.input[start:p.pos], p.path, p.scope, true); err != nil { + return err + } + default: + for p.pos < len(p.input) && !strings.ContainsRune(" \t\r\n()[]!<>=&|", rune(p.input[p.pos])) { + p.pos++ + } + token := p.input[start:p.pos] + if token != "true" && token != "false" && token != "null" { + var val any + decoder := json.NewDecoder(strings.NewReader(token)) + decoder.UseNumber() + if !json.Valid([]byte(token)) || decoder.Decode(&val) != nil { + return fmt.Errorf("invalid condition literal at byte %d", start) + } + if _, ok := val.(json.Number); !ok { + return fmt.Errorf("condition strings must use single quotes") + } + } + } + for { + p.space() + if p.pos == len(p.input) { + return nil + } + switch p.input[p.pos] { + case '[': + p.pos++ + p.space() + start = p.pos + for p.pos < len(p.input) && p.input[p.pos] >= '0' && p.input[p.pos] <= '9' { + p.pos++ + } + if p.pos == start { + return fmt.Errorf("index must be a nonnegative integer") + } + p.space() + if p.pos == len(p.input) || p.input[p.pos] != ']' { + return fmt.Errorf("unclosed index") + } + p.pos++ + case '.': + p.pos++ + start = p.pos + for p.pos < len(p.input) && (p.input[p.pos] >= 'a' && p.input[p.pos] <= 'z' || p.input[p.pos] >= 'A' && p.input[p.pos] <= 'Z' || p.input[p.pos] >= '0' && p.input[p.pos] <= '9' || p.input[p.pos] == '_' || p.input[p.pos] == '-') { + p.pos++ + } + if p.pos == start { + return fmt.Errorf("missing property name") + } + default: + return nil + } + } +} + +// simpleHeaderPrefix isolates header-name token rules from other runtime sources. +func simpleHeaderPrefix(s string) string { + for _, prefix := range []string{"$request.header.", "$response.header.", "$message.header."} { + if strings.HasPrefix(s, prefix) { + return prefix + } + } + return "" +} + +func simpleHeaderTchar(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c)) +} diff --git a/arazzo/expressions_linked.go b/arazzo/expressions_linked.go new file mode 100644 index 0000000..871f068 --- /dev/null +++ b/arazzo/expressions_linked.go @@ -0,0 +1,110 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "strings" + + "github.com/pb33f/libopenapi/arazzo/expression" +) + +// finalizeLinkedExpressionUses checks supplied external workflow symbols. Source +// description fields resolve locally; unavailable linked metadata is coverage, +// never evidence that the authored expression names a nonexistent member. +func finalizeLinkedExpressionUses(v *validation) { + if v.sources == nil { + return + } + for _, use := range v.expressionUses { + if !v.check() { + return + } + if use.Expression.Type != expression.SourceDescriptions { + continue + } + name, rest, ok := v.sources.qualified(use.Expression.Raw) + if !ok { + continue + } + description := v.sources.byName[name] + if description == nil { + continue + } // local pass reports unknown names + if _, field := description[rest]; field { + continue + } + source := v.sources.lookup(name, use.Path) + if source == nil { + continue + } + if source.kind != "arazzo" { + if len(source.operations[rest]) > 0 { + continue + } + if source.root == nil { + v.incomplete("expression-symbol", use.Path, name, "adapter does not expose source member metadata") + } else if !source.unresolvedRefs && (v.version == "1.1" || !strings.Contains(rest, ".")) { + v.add(CodeReference, use.Path, "runtime expression references an unknown source member") + } else { + v.incomplete("expression-symbol", use.Path, name, "source member property traversal is not statically available") + } + continue + } + id, tail := semanticReferenceParts(rest, func(id string) bool { _, ok := source.workflows[id]; return ok }) + if id == "" { + // Presence-only adapters can identify a workflow before its member suffix. + for _, field := range []string{".outputs.", ".inputs.", ".steps."} { + if position := strings.Index(rest, field); position >= 0 { + id, tail = rest[:position], rest[position+1:] + break + } + } + if id == "" { + id = rest + } + } + target := v.sources.workflow("$sourceDescriptions."+name+"."+id, use.Path) + if target == nil { + continue + } + if target.node == nil { + v.incomplete("expression-symbol", use.Path, name, "adapter does not expose workflow input, step, or output declarations") + continue + } + to := dependencyID{document: source.identity, workflow: id} + output := false + switch { + case strings.HasPrefix(tail, "outputs."): + if !semanticOutputExists(object(target.node["outputs"]), strings.TrimPrefix(tail, "outputs."), v.version) { + v.add(CodeReference, use.Path, "runtime expression references an unknown external workflow output") + continue + } + output = true + case strings.HasPrefix(tail, "inputs."): + if semanticInputAbsent(object(target.node["inputs"]), strings.TrimPrefix(tail, "inputs.")) { + v.add(CodeReference, use.Path, "runtime expression references an input excluded by the external workflow schema") + continue + } + case strings.HasPrefix(tail, "steps."): + steps := source.steps[id] + sid, member := semanticReferenceParts(strings.TrimPrefix(tail, "steps."), func(id string) bool { _, ok := steps[id]; return ok }) + if sid == "" { + v.add(CodeReference, use.Path, "runtime expression references an unknown external workflow step") + continue + } + if strings.HasPrefix(member, "outputs.") { + if !semanticOutputExists(object(steps[sid]["outputs"]), strings.TrimPrefix(member, "outputs."), v.version) { + v.add(CodeReference, use.Path, "runtime expression references an unknown external step output") + continue + } + to.step = sid + output = true + } + } + if output && v.version == "1.1" && use.StepID != "" { + v.sources.graphSource(source, use.Path, 1) + v.graph.addEdge(dependencyID{document: v.documentID(), workflow: use.WorkflowID, step: use.StepID}, to, use.Path) + } + } +} diff --git a/arazzo/expressions_linked_test.go b/arazzo/expressions_linked_test.go new file mode 100644 index 0000000..b9a6e7f --- /dev/null +++ b/arazzo/expressions_linked_test.go @@ -0,0 +1,121 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "strings" + "testing" + + upstream "github.com/pb33f/libopenapi/arazzo" +) + +const expressionLinkedFixture = `arazzo: 1.1.0 +info: {title: main, version: '1'} +sourceDescriptions: + - {name: flow, url: other.yaml, type: arazzo} +workflows: + - workflowId: main + steps: + - stepId: first + workflowId: $sourceDescriptions.flow.remote + parameters: + - name: id + value: $sourceDescriptions.flow.remote.outputs.id +` + +const expressionLinkedSource = `arazzo: 1.1.0 +info: {title: other, version: '1'} +sourceDescriptions: [{name: api, url: api.yaml, type: openapi}] +workflows: + - workflowId: remote + inputs: + type: object + properties: {id: {type: string}} + additionalProperties: false + steps: + - stepId: produce + operationId: getId + outputs: {id: $response.body#/id} + outputs: {id: $steps.produce.outputs.id} +` + +func TestLinkedExpressionSymbols(t *testing.T) { + for _, test := range []struct { + reference string + valid bool + }{ + {"$sourceDescriptions.flow.remote.outputs.id", true}, + {"$sourceDescriptions.flow.remote.outputs.missing", false}, + {"$sourceDescriptions.flow.remote.inputs.missing", false}, + {"$sourceDescriptions.flow.remote.steps.produce.outputs.id", true}, + {"$sourceDescriptions.flow.remote.steps.missing.outputs.id", false}, + } { + t.Run(test.reference, func(t *testing.T) { + fixture := strings.Replace(expressionLinkedFixture, "$sourceDescriptions.flow.remote.outputs.id", test.reference, 1) + r, err := ValidateBytes(context.Background(), []byte(fixture), "https://example.com/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.com/other.yaml", SourceBytes: []byte(expressionLinkedSource)})) + if err != nil { + t.Fatal(err) + } + if test.valid { + if !r.Valid() { + t.Fatalf("valid external symbol: %+v", r.Diagnostics) + } + } else { + expressionFinding(t, r, CodeReference, "/workflows/0/steps/0/parameters/0/value", 12) + } + }) + } +} + +func TestLinkedExpressionPresenceAdapterCoverage(t *testing.T) { + fixture := strings.ReplaceAll(expressionLinkedFixture, ".remote", ".run") + r, err := ValidateBytes(context.Background(), []byte(fixture), "https://example.com/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.com/other.yaml", Type: "arazzo", Adapter: workflowPresence{}})) + if err != nil || !r.Valid() { + t.Fatalf("presence adapter marked missing metadata invalid: %+v %v", r, err) + } + found := false + for _, check := range r.Checks { + if check.Name == "expression-symbol" && check.Status == CheckIncomplete && check.Pointer == "/workflows/0/steps/0/parameters/0/value" { + found = true + } + } + if !found { + t.Fatalf("missing metadata coverage: %+v", r.Checks) + } +} + +func TestSelectorExtensionsAndLiteralSignature(t *testing.T) { + r := expressionValidate(t, "1.1.0", ` parameters: + - name: result + in: query + value: + context: $response.body + selector: $.id + type: jsonpath + x-note: '$bad-extension-expression' + - name: literal + in: query + value: + context: literal text + selector: $.id + type: jsonpath + extra: true +`) + if !r.Valid() { + t.Fatalf("selector extension or literal object rejected: %+v", r.Diagnostics) + } +} + +func TestLinkedExpressionKnownMissingOperation(t *testing.T) { + fixture := strings.Replace(foundationYAML("1.1.0"), "operationId: read}", "operationId: read, outputs: {id: '$sourceDescriptions.api.missing'}}", 1) + result, err := ValidateBytes(context.Background(), []byte(fixture), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{ + RetrievalURI: "https://example.test/api.yaml", + SourceBytes: []byte("openapi: 3.1.0\ninfo: {title: API, version: '1'}\npaths: {/items: {get: {operationId: read, responses: {'200': {description: OK}}}}}\n"), + })) + if err != nil || !result.Complete { + t.Fatalf("full source did not complete the absent-member check: %+v %v", result, err) + } + foundationFinding(t, result, CodeReference, "/workflows/0/steps/0/outputs/id") +} diff --git a/arazzo/expressions_test.go b/arazzo/expressions_test.go new file mode 100644 index 0000000..073f90b --- /dev/null +++ b/arazzo/expressions_test.go @@ -0,0 +1,305 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" +) + +func TestExpressionInheritedParserByteBudget(t *testing.T) { + for _, value := range []string{ + "'$response.body#/" + strings.Repeat("id", 200) + "'", + "{context: $response.body, selector: '$." + strings.Repeat("id", 200) + "', type: jsonpath}", + } { + var fixture strings.Builder + fixture.WriteString("arazzo: 1.1.0\ninfo: {title: Budget, version: '1'}\nsourceDescriptions: [{name: api, url: api.yaml}]\nworkflows:\n- workflowId: run\n parameters:\n - name: id\n in: query\n value: " + value + "\n steps:\n") + for i := range 40 { + fmt.Fprintf(&fixture, " - {stepId: s%d, operationId: read}\n", i) + } + data := []byte(fixture.String()) + baseline, err := ValidateBytes(context.Background(), data, "budget.yaml") + if err != nil || !baseline.Valid() { + t.Fatalf("valid inherited value rejected: %+v %v", baseline, err) + } + limits := DefaultLimits() + limits.MaxBytes = len(data) * 2 + result, err := ValidateBytes(context.Background(), data, "budget.yaml", WithLimits(limits)) + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorLimit || result.Complete || !strings.Contains(tool.Cause.Error(), "derived validation byte") { + t.Fatalf("repeated parser input was not bounded: %+v %v", result, err) + } + } +} + +const expressionFixture = `arazzo: 1.1.0 +info: + title: expressions + version: '1' +sourceDescriptions: + - name: api + url: https://example.com/api.yaml + type: openapi +workflows: + - workflowId: run + steps: + - stepId: first + operationId: getPet +` + +func expressionValidate(t *testing.T, version, tail string) *Result { + t.Helper() + fixture := strings.Replace(expressionFixture, "1.1.0", version, 1) + tail + result, err := ValidateBytes(context.Background(), []byte(fixture), "https://example.com/workflow.yaml") + if err != nil { + t.Fatal(err) + } + return result +} + +func expressionFinding(t *testing.T, result *Result, code Code, path string, line int) { + t.Helper() + for _, d := range result.Diagnostics { + if d.Code == code && d.Pointer == path { + if d.Line != line || d.Column < 1 { + t.Fatalf("location = %d:%d, want line %d", d.Line, d.Column, line) + } + return + } + } + t.Fatalf("missing %s at %s: %+v", code, path, result.Diagnostics) +} + +func TestExpressionOutputSyntaxAndVersion(t *testing.T) { + for _, test := range []struct { + name, version, value string + valid bool + }{ + {"dynamic body", "1.0.99", "$response.body#/items/0", true}, + {"broad step reference", "1.0.1", "$steps.first", true}, + {"literal", "1.1.0", "definitely-not-an-expression", false}, + {"self in 1.0", "1.0.1", "$self", false}, + {"message in 1.0", "1.0.1", "$message.payload", false}, + {"request payload in 1.0", "1.0.1", "$request.payload", false}, + {"response payload in 1.0", "1.0.1", "$response.payload", false}, + {"body pointer escape", "1.1.0", "$response.body#/bad~2key", false}, + } { + t.Run(test.name, func(t *testing.T) { + r := expressionValidate(t, test.version, " outputs:\n result: "+test.value+"\n") + if test.valid { + if !r.Valid() { + t.Fatalf("valid output: %+v", r.Diagnostics) + } + } else { + expressionFinding(t, r, CodeExpression, "/workflows/0/steps/0/outputs/result", 15) + } + }) + } +} + +func TestCriterionSyntax(t *testing.T) { + for _, test := range []struct { + name, criterion string + valid bool + code Code + field string + line int + }{ + {"compound", " - condition: \"($statusCode == 200 || $statusCode == 201) && !false\"\n", true, "", "", 0}, + {"pointer operator characters", " - condition: '$response.body#/a==b[0] == 200'\n", true, "", "", 0}, + {"large numeric literal", " - condition: '1e400 > 1e300'\n", true, "", "", 0}, + {"invalid numeric token", " - condition: '1abc > 2'\n", false, CodeExpression, "condition", 15}, + {"strings", " - condition: \"'isn''t' != 'yes'\"\n", true, "", "", 0}, + {"property indexing", " - condition: '$response.body.items[0].id != null'\n", true, "", "", 0}, + {"missing operand", " - condition: '$statusCode == '\n", false, CodeExpression, "condition", 15}, + {"unbalanced", " - condition: '($statusCode == 200'\n", false, CodeExpression, "condition", 15}, + {"invalid regex", " - context: $statusCode\n type: regex\n condition: '['\n", false, CodeExpression, "condition", 17}, + {"regex lookahead", " - context: $statusCode\n type: regex\n condition: '^(?!400)\\d+$'\n", true, "", "", 0}, + {"JSONPath", " - context: $response.body\n type: jsonpath\n condition: '$.items[*]'\n", true, "", "", 0}, + {"invalid JSONPath", " - context: $response.body\n type: jsonpath\n condition: '$.items['\n", false, CodeExpression, "condition", 17}, + {"strict JSONPath", " - context: $response.body\n type: jsonpath\n condition: '$.items[?(@.id === 1)]'\n", false, CodeExpression, "condition", 17}, + {"required context", " - type: simple\n condition: 'true'\n", false, CodeStructure, "", 15}, + } { + t.Run(test.name, func(t *testing.T) { + r := expressionValidate(t, "1.1.0", " successCriteria:\n"+test.criterion) + if test.valid { + if !r.Valid() { + t.Fatalf("valid criterion: %+v", r.Diagnostics) + } + } else { + path := "/workflows/0/steps/0/successCriteria/0" + if test.field != "" { + path += "/" + test.field + } + expressionFinding(t, r, test.code, path, test.line) + } + }) + } +} + +func TestCriteriaTraversalAndCapabilities(t *testing.T) { + r := expressionValidate(t, "1.1.0", ` onSuccess: + - name: finish + type: end + criteria: + - condition: '$statusCode == ' + failureActions: + - name: stop + type: end + criteria: + - condition: '$statusCode != ' +components: + successActions: + unused: + name: unused + type: end + criteria: + - context: $response.body + type: xpath + condition: /root/item + failureActions: + invalid: + name: invalid + type: end + criteria: + - condition: '$statusCode > ' +`) + expressionFinding(t, r, CodeExpression, "/workflows/0/steps/0/onSuccess/0/criteria/0/condition", 18) + expressionFinding(t, r, CodeExpression, "/workflows/0/failureActions/0/criteria/0/condition", 23) + expressionFinding(t, r, CodeExpression, "/components/failureActions/invalid/criteria/0/condition", 38) + found := false + for _, check := range r.Checks { + if check.Status == CheckIncomplete && check.Pointer == "/components/successActions/unused/criteria/0/condition" { + found = true + } + } + if !found { + t.Fatal("XPath capability was not reported") + } +} + +func TestSelectorAndReplacementSyntax(t *testing.T) { + r := expressionValidate(t, "1.1.0", ` outputs: + selected: + context: $response.body + selector: /bad~2token + type: jsonpointer + parameters: + - name: id + in: query + value: + context: $inputs.body + selector: '$.items[' + type: jsonpath + requestBody: + contentType: application/json + payload: + invoice: + context: $inputs.invoice + selector: /id + type: jsonpointer + literal: + context: this is literal + selector: '[' + type: invalid + extra: true + jsonTemplate: '{"id": "{$inputs.id}"}' + replacements: + - target: /bad~2token + value: '$request.header.' +`) + expressionFinding(t, r, CodeSelector, "/workflows/0/steps/0/outputs/selected/selector", 17) + expressionFinding(t, r, CodeSelector, "/workflows/0/steps/0/parameters/0/value/selector", 24) + expressionFinding(t, r, CodeSelector, "/workflows/0/steps/0/requestBody/replacements/0/target", 40) + expressionFinding(t, r, CodeExpression, "/workflows/0/steps/0/requestBody/replacements/0/value", 41) + for _, d := range r.Diagnostics { + if strings.Contains(d.Pointer, "/payload/literal") { + t.Fatalf("literal map interpreted as selector: %+v", d) + } + } +} + +func TestExpressionsDoNotScanExtensionsOrBareDollar(t *testing.T) { + r := expressionValidate(t, "1.1.0", ` x-note: '$not-a-runtime-expression' + requestBody: + contentType: application/json + payload: + currency: '$' + object: '{literal braces}' + x-data: plain text +`) + if !r.Valid() { + t.Fatalf("literal values: %+v", r.Diagnostics) + } +} + +func TestExpressionCappedDiagnosticsDeterministic(t *testing.T) { + limits := DefaultLimits() + limits.MaxDiagnostics = 1 + fixture := expressionFixture + ` outputs: + gamma: invalid + beta: invalid + alpha: invalid +` + for i := 0; i < 30; i++ { + result, err := ValidateBytes(context.Background(), []byte(fixture), "https://example.com/workflow.yaml", WithLimits(limits)) + if err == nil { + t.Fatal("expected diagnostic cap error") + } + if len(result.Diagnostics) != 1 || result.Diagnostics[0].Pointer != "/workflows/0/steps/0/outputs/alpha" { + t.Fatalf("nondeterministic capped diagnostics: %+v", result.Diagnostics) + } + } +} + +func TestExpressionHeaderPunctuationInCriteriaAndOutputs(t *testing.T) { + for _, version := range []string{"1.0.1", "1.1.0"} { + for _, source := range []string{"$request.header.", "$response.header."} { + for _, name := range []string{"X!Id", "X&Id", "X|Id", "X&&Id", "X||Id", "X!#$%&'*+-.^_`|~Id", "X!"} { + t.Run(version+"/"+source+name, func(t *testing.T) { + tail := fmt.Sprintf(" outputs:\n result: \"%s%s\"\n successCriteria:\n - condition: \"%s%s == 'x'\"\n", source, name, source, name) + result := expressionValidate(t, version, tail) + if !result.Valid() { + t.Fatalf("RFC header name rejected in criterion or output: %+v", result.Diagnostics) + } + }) + } + } + } +} + +func TestCriterionHeaderOperatorBoundaries(t *testing.T) { + for _, version := range []string{"1.0.1", "1.1.0"} { + for _, test := range []struct { + condition string + valid bool + }{ + {"$response.header.X!Id!='x'", true}, + {"$response.header.X!=='x'", true}, + {"$response.header.X&Id=='x'&&$response.header.X|Id=='y'", true}, + {"$response.header.X!Id||false", true}, + {"$response.header.X&Id&&true", true}, + {"$response.header.X!Id = 'x'", false}, + {"$response.header.X&Id & true", false}, + {"$response.header.X|Id | true", false}, + {"$response.header.X!Id &&", false}, + {"$response.header.X&Id || || true", false}, + {"$response.header.X|Id !== 'x'", false}, + } { + t.Run(version+"/"+test.condition, func(t *testing.T) { + result := expressionValidate(t, version, fmt.Sprintf(" successCriteria:\n - condition: \"%s\"\n", test.condition)) + if test.valid { + if !result.Valid() { + t.Fatalf("operator boundary rejected: %+v", result.Diagnostics) + } + } else { + expressionFinding(t, result, CodeExpression, "/workflows/0/steps/0/successCriteria/0/condition", 15) + } + }) + } + } +} diff --git a/arazzo/foundation_contracts_test.go b/arazzo/foundation_contracts_test.go new file mode 100644 index 0000000..f4dd042 --- /dev/null +++ b/arazzo/foundation_contracts_test.go @@ -0,0 +1,310 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "reflect" + "strings" + "testing" + "testing/fstest" + + "github.com/pb33f/go-yaml" + "github.com/pb33f/jsonschema/v6" +) + +func TestFoundationConfigurationAndNodeErrors(t *testing.T) { + if (*Result)(nil).Valid() { + t.Fatal("nil result is not valid") + } + for _, option := range []Option{WithBaseURI("http://[invalid"), WithResolver(nil)} { + r, err := ValidateBytes(context.Background(), nil, "config.yaml", option) + var tool *Error + if r == nil || r.Complete || !errors.As(err, &tool) || tool.Kind != ErrorConfiguration { + t.Fatalf("%+v %v", r, err) + } + } + for _, doc := range []Document{{URI: "nil.yaml"}, {URI: "http://[invalid", Root: &yaml.Node{Kind: yaml.MappingNode}}} { + r, err := Validate(context.Background(), doc) + var tool *Error + if !errors.As(err, &tool) || r.Complete || tool.Location.URI != doc.URI { + t.Fatalf("%+v %v", r, err) + } + } + l := DefaultLimits() + l.MaxNodes = 2 + r, err := ValidateBytes(context.Background(), []byte("[a,b,c]"), "wide.yaml", WithLimits(l)) + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorLimit || r.Complete { + t.Fatalf("%+v %v", r, err) + } +} + +func TestFoundationOverflowNumbersRemainNumeric(t *testing.T) { + // The metaschema requires numeric bounds. Retagging a plain overflow number + // as a string must fail this test, even though Parameter.value accepts Any. + for _, number := range []string{strings.Repeat("9", 320), "1e309"} { + for _, quoted := range []bool{false, true} { + value := number + if quoted { + value = "'" + value + "'" + } + data := strings.Replace(foundationYAML("1.1.0"), " steps:", " inputs: {type: number, minimum: "+value+"}\n steps:", 1) + r, err := ValidateBytes(context.Background(), []byte(data), "numeric.yaml") + if err != nil || r.Valid() == quoted { + t.Fatalf("quoted=%v number=%s: %+v %v", quoted, number, r, err) + } + if quoted { + foundationFinding(t, r, CodeStructure, "/workflows/0/inputs/minimum") + } + } + } +} + +func TestSchemaPatternAdapters(t *testing.T) { + for _, tc := range []struct{ pattern, accepted, rejected string }{ + {`^(?!\$).+$`, "literal", "$expression"}, {`^a+$`, "aa", "b"}, + } { + p, err := compileSchemaPattern(tc.pattern) + if err != nil || p.String() != tc.pattern || !p.MatchString(tc.accepted) || p.MatchString(tc.rejected) { + t.Fatalf("pattern %s: %v %v", tc.pattern, p, err) + } + } + for _, pattern := range []string{"[", strings.Repeat("a", DefaultLimits().MaxPatternBytes+1)} { + if _, err := compileSchemaPattern(pattern); err == nil { + t.Fatal("invalid/unbounded pattern accepted") + } + } + _, err := (denyLoader{}).Load("https://example.test/not-supplied") + var unavailable *externalResourceError + if !errors.As(err, &unavailable) || !strings.Contains(err.Error(), unavailable.URI) { + t.Fatal(err) + } +} + +func TestStructureCompilerFailureAndBudget(t *testing.T) { + original := officialSchemas + defer func() { officialSchemas = original }() + compilerErr := errors.New("invalid embedded schema") + officialSchemas = func() (map[string]*jsonschema.Schema, error) { return nil, compilerErr } + r, err := ValidateBytes(context.Background(), []byte(foundationYAML("1.1.0")), "schema.yaml") + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorOperational || !errors.Is(err, compilerErr) || r.Complete { + t.Fatalf("%+v %v", r, err) + } + officialSchemas = original + data := foundationYAML("1.1.0") + "components:\n parameters:\n '': {name: q, in: query, value: a}\n" + r, err = ValidateBytes(context.Background(), []byte(data), "key.yaml") + if err != nil || r.Valid() { + t.Fatalf("empty component key: %+v %v", r, err) + } + d := foundationFinding(t, r, CodeStructure, "/components/parameters/") + if d.Line != 11 || d.Column != 5 || d.SchemaPointer == "" { + t.Fatalf("property-name coordinates: %+v", d) + } + l := DefaultLimits() + l.MaxDiagnostics = 1 + data = strings.Replace(foundationYAML("1.1.0"), "title: Example", "title: 123, one: true, two: false", 1) + r, err = ValidateBytes(context.Background(), []byte(data), "invalid.yaml", WithLimits(l)) + if !errors.As(err, &tool) || tool.Kind != ErrorLimit || r.Complete || len(r.Diagnostics) != 1 { + t.Fatalf("bounded structural errors: %+v %v", r, err) + } +} + +func TestValidationSharedBudgetAndOrdering(t *testing.T) { + v := &validation{ctx: context.Background(), doc: Document{URI: "main.yaml"}, opts: options{limits: DefaultLimits()}, result: &Result{}, nodes: map[string]nodeLocation{}} + if !v.workBytes(3, "") || v.budget.bytes != 3 { + t.Fatal("byte work was not charged") + } + v.opts.limits.MaxBytes = 3 + if v.workBytes(1, "/payload") { + t.Fatal("byte work exceeded cap") + } + var tool *Error + if !errors.As(v.err, &tool) || tool.Kind != ErrorLimit || tool.Location.Pointer != "/payload" { + t.Fatalf("%v", v.err) + } + if v.workBytes(1, "") { + t.Fatal("failed budget resumed") + } + v.addKey(CodeStructure, "/key", "cannot emit after failure") + if len(v.result.Diagnostics) != 0 { + t.Fatal("failed operation emitted another error") + } + v.err, v.budget = nil, &workBudget{} + v.result.Diagnostics = []Diagnostic{ + {Location: Location{URI: "z"}, Code: CodeStructure}, + {Location: Location{URI: "a", Pointer: "/b"}, Code: CodeStructure}, + {Location: Location{URI: "a", Pointer: "/a"}, Code: CodeReference}, + {Location: Location{URI: "a", Pointer: "/a"}, Code: CodeStructure, Message: "z"}, + {Location: Location{URI: "a", Pointer: "/a", Line: 2}, Code: CodeStructure, Message: "a"}, + {Location: Location{URI: "a", Pointer: "/a", Line: 1, Column: 2}, Code: CodeStructure, Message: "a"}, + {Location: Location{URI: "a", Pointer: "/a", Line: 1, Column: 1}, Code: CodeStructure, Message: "a"}, + } + v.result.Checks = []Check{ + {Name: "c", URI: "z"}, + {Name: "c", URI: "a", Pointer: "/b"}, + {Name: "c", URI: "a", Pointer: "/a", Source: "z"}, + {Name: "c", URI: "a", Pointer: "/a", Source: "a", Reason: "z"}, + {Name: "c", URI: "a", Pointer: "/a", Source: "a", Reason: "a"}, + {Name: "c", URI: "a", Pointer: "/a", Status: CheckIncomplete, Source: "a"}, + {Name: "c", URI: "a", Pointer: "/a", Status: CheckComplete, Source: "a"}, + } + v.finish() + if len(v.result.Diagnostics) != 5 || v.result.Diagnostics[1].Column != 1 || v.result.Checks[0].Reason != "a" { + t.Fatalf("ordering/dedup: %+v", v.result) + } + if v.result.Checks[3].Status != CheckComplete || v.result.Checks[4].Status != CheckIncomplete || v.result.Complete { + t.Fatalf("mixed coverage status ordering/completeness: %+v", v.result) + } + before := *v.result + before.Diagnostics = append([]Diagnostic(nil), before.Diagnostics...) + before.Checks = append([]Check(nil), before.Checks...) + v.finish() + if !reflect.DeepEqual(before, *v.result) { + t.Fatal("finish is not idempotent") + } +} + +func TestDependencyGraphBudgetAndUnnamedCycle(t *testing.T) { + for _, max := range []int{1, 2} { + v := &validation{ctx: context.Background(), opts: options{limits: DefaultLimits()}, result: &Result{}} + v.opts.limits.MaxNodes = max + g := newDependencyGraph() + g.run = v + g.addEdge(dependencyID{step: "a"}, dependencyID{step: "b"}, "/dependsOn") + var tool *Error + if !errors.As(v.err, &tool) || tool.Kind != ErrorLimit || len(g.edges[dependencyID{step: "a"}]) != 0 { + t.Fatalf("graph exceeded shared budget: %v %+v", v.err, g) + } + } + g := newDependencyGraph() + a, b := dependencyID{step: "a"}, dependencyID{step: "b"} + g.addEdge(a, b, "") + g.addEdge(b, a, "") + if err := g.check(context.Background(), func(_, _ string) { t.Error("no authored edge exists for a diagnostic") }); err != nil { + t.Fatal(err) + } +} + +func TestStructureDiagnosticAdapter(t *testing.T) { + // Exercise compiler diagnostics independent of the current snapshot's shape. + // These fixtures protect locations if a later official snapshot changes keywords. + original := officialSchemas + defer func() { officialSchemas = original }() + for _, tc := range []struct { + schema map[string]any + value map[string]any + paths []string + }{ + {map[string]any{"type": "object", "additionalProperties": false}, map[string]any{"b": true, "a": true}, []string{"/a", "/b"}}, + {map[string]any{"anyOf": []any{map[string]any{"type": "string"}, map[string]any{"type": "number"}}}, map[string]any{}, []string{""}}, + } { + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("https://example.test/schema", tc.schema); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile("https://example.test/schema") + if err != nil { + t.Fatal(err) + } + officialSchemas = func() (map[string]*jsonschema.Schema, error) { + return map[string]*jsonschema.Schema{"1.1": schema}, nil + } + v := &validation{ctx: context.Background(), opts: options{limits: DefaultLimits()}, root: tc.value, result: &Result{}, version: "1.1"} + checkStructure(v) + for _, path := range tc.paths { + foundationFinding(t, v.result, CodeStructure, path) + } + if v.err != nil { + t.Fatal(v.err) + } + } +} + +func TestFoundationCancellationAtEveryPhase(t *testing.T) { + // Cancel at each observed context check, including checks inside normalization, + // diagnostic collection, expression parsing, source indexing and graph traversal. + data := strings.Replace(foundationYAML("1.1.0"), "operationId: read", "operationId: read, parameters: [{name: id, in: query, value: $statusCode}]", 1) + root := sourceContractYAML(t, data) + probe := newSourceCancelContext(t, 100000) + if _, err := Validate(probe, Document{Root: root, URI: "cancel.yaml"}); err != nil { + t.Fatal(err) + } + for check := 1; check <= probe.checks; check++ { + ctx := newSourceCancelContext(t, check) + r, err := Validate(ctx, Document{Root: root, URI: "cancel.yaml"}) + if !errors.Is(err, context.Canceled) || r.Complete { + t.Fatalf("cancellation check %d: %+v %v", check, r, err) + } + } +} + +func TestOfficialSchemaBundleFailures(t *testing.T) { + const first = "schemas/arazzo-1.0-2025-10-15.json" + const second = "schemas/arazzo-1.1-2026-04-15.json" + data, err := schemaFiles.ReadFile(first) + if err != nil { + t.Fatal(err) + } + other, err := schemaFiles.ReadFile(second) + if err != nil { + t.Fatal(err) + } + for _, defect := range []string{"missing", "json", "resource", "compile"} { + t.Run(defect, func(t *testing.T) { + files := fstest.MapFS{ + first: &fstest.MapFile{Data: data}, + second: &fstest.MapFile{Data: other}, + } + switch defect { + case "missing": + delete(files, first) + case "json": + files[first] = &fstest.MapFile{Data: []byte("!")} + case "resource", "compile": + var document map[string]any + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + if defect == "resource" { + document["$id"] = "http://[invalid" + } else { + document["pattern"] = "[" + } + modified, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + files[first] = &fstest.MapFile{Data: modified} + } + _, err := compileOfficialSchemas(files) + if err == nil { + t.Fatal("malformed schema bundle accepted") + } + var syntax *json.SyntaxError + switch defect { + case "missing": + if !errors.Is(err, fs.ErrNotExist) { + t.Fatal(err) + } + case "json": + if !errors.As(err, &syntax) { + t.Fatal(err) + } + case "resource": + if !strings.Contains(err.Error(), "register embedded schema "+first) { + t.Fatal(err) + } + case "compile": + if !strings.Contains(err.Error(), "compile embedded schema "+first) || !strings.Contains(err.Error(), "error parsing regexp") { + t.Fatal(err) + } + } + }) + } +} diff --git a/arazzo/graph.go b/arazzo/graph.go new file mode 100644 index 0000000..0849f4b --- /dev/null +++ b/arazzo/graph.go @@ -0,0 +1,107 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import "context" + +// dependencyID keeps document and authored names separate: dots can be part of +// an identifier. An empty step identifies workflow completion. +type dependencyID struct { + document string + workflow string + step string + prerequisites bool +} + +type dependencyEdge struct { + target dependencyID + path string +} + +type dependencyGraph struct { + order []dependencyID + edges map[dependencyID][]dependencyEdge + run *validation +} + +func newDependencyGraph() *dependencyGraph { + return &dependencyGraph{edges: make(map[dependencyID][]dependencyEdge)} +} + +func (g *dependencyGraph) addNode(id dependencyID) { + if _, exists := g.edges[id]; !exists { + if g.run != nil && !g.run.work(1, "") { + return + } + g.order = append(g.order, id) + g.edges[id] = nil + } +} + +func (g *dependencyGraph) addEdge(from, to dependencyID, path string) { + if g.run != nil && !g.run.work(1, path) { + return + } + g.addNode(from) + g.addNode(to) + if g.run != nil && g.run.err != nil { + return + } + g.edges[from] = append(g.edges[from], dependencyEdge{target: to, path: path}) +} + +// check uses an iterative DFS so a document with a long dependency chain does +// not consume one stack frame per step. Control-flow goto/retry edges do not +// belong in this prerequisite graph. +func (g *dependencyGraph) check(ctx context.Context, emit func(string, string)) error { + type frame struct { + id dependencyID + next int + via string + } + state := make(map[dependencyID]uint8, len(g.edges)) + for _, root := range g.order { + if state[root] != 0 { + continue + } + stack := []frame{{id: root}} + state[root] = 1 + for len(stack) != 0 { + if err := ctx.Err(); err != nil { + return err + } + top := &stack[len(stack)-1] + out := g.edges[top.id] + if top.next == len(out) { + state[top.id] = 2 + stack = stack[:len(stack)-1] + continue + } + edge := out[top.next] + top.next++ + switch state[edge.target] { + case 0: + state[edge.target] = 1 + stack = append(stack, frame{id: edge.target, via: edge.path}) + case 1: + path := edge.path + if path == "" { + for i := len(stack) - 1; i >= 0; i-- { + if stack[i].via != "" { + path = stack[i].via + break + } + if stack[i].id == edge.target { + break + } + } + } + if path != "" { + emit(path, "prerequisite dependency contains a cycle") + } + } + } + } + return nil +} diff --git a/arazzo/graph_test.go b/arazzo/graph_test.go new file mode 100644 index 0000000..9a0f09d --- /dev/null +++ b/arazzo/graph_test.go @@ -0,0 +1,55 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "testing" +) + +func TestDependencyGraphIdentityAndCancellation(t *testing.T) { + g := newDependencyGraph() + a := dependencyID{document: "a", workflow: "same", step: "same"} + b := dependencyID{document: "b", workflow: "same", step: "same"} + g.addEdge(a, b, "/dependsOn/0") + if err := g.check(context.Background(), func(path, message string) { t.Fatal("different documents conflated") }); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := g.check(ctx, func(path, message string) {}); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } +} + +func TestDependencyGraphLongChain(t *testing.T) { + g := newDependencyGraph() + for i := 0; i < 10000; i++ { + g.addEdge(dependencyID{workflow: string(rune(i + 1))}, dependencyID{workflow: string(rune(i + 2))}, "/dependsOn/0") + } + if err := g.check(context.Background(), func(path, message string) { t.Fatal("linear chain reported as cycle") }); err != nil { + t.Fatal(err) + } +} + +func TestDependencyGraphCompletionBackEdge(t *testing.T) { + g := newDependencyGraph() + s := dependencyID{workflow: "run", step: "step"} + w := dependencyID{workflow: "run"} + g.addEdge(s, w, "/outputs/id") + g.addEdge(w, s, "") + found := false + if err := g.check(context.Background(), func(path, message string) { + found = true + if path != "/outputs/id" { + t.Fatalf("wrong cycle origin: %s", path) + } + }); err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("cycle closing through workflow completion was missed") + } +} diff --git a/arazzo/inputs.go b/arazzo/inputs.go new file mode 100644 index 0000000..88757f6 --- /dev/null +++ b/arazzo/inputs.go @@ -0,0 +1,320 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "errors" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/dlclark/regexp2" + "github.com/pb33f/jsonschema/v6" +) + +type inputSchemaIndex struct{ resources, anchors map[string]string } + +type inputReference struct{ uri, path string } + +type inputSlot struct { + value any + path string +} +type inputResourceUnavailable struct{ uri string } + +func (e *inputResourceUnavailable) Error() string { + return "input schema resource is not supplied: " + e.uri +} + +type inputSchemaLoader struct{} + +func (inputSchemaLoader) Load(uri string) (any, error) { return nil, &inputResourceUnavailable{uri} } + +type inputRegexpState struct { + v *validation + err error +} +type inputRegexp struct { + re *regexp2.Regexp + state *inputRegexpState +} + +func (r *inputRegexp) String() string { return r.re.String() } + +func (r *inputRegexp) MatchString(s string) bool { + if !r.state.v.check() { + r.state.err = r.state.v.err + return false + } + ok, err := r.re.MatchString(s) + if err != nil { + r.state.err = err + } + return ok +} + +func (r *inputRegexpState) compile(pattern string) (jsonschema.Regexp, error) { + if len(pattern) > r.v.opts.limits.MaxPatternBytes { + r.v.err = &Error{Kind: ErrorLimit, Cause: fmt.Errorf("input schema pattern exceeds %d bytes", r.v.opts.limits.MaxPatternBytes)} + return nil, r.v.err + } + re, err := regexp2.Compile(pattern, regexp2.ECMAScript) + if err != nil { + return nil, err + } + re.MatchTimeout = 50 * time.Millisecond + return &inputRegexp{re: re, state: r}, nil +} + +func checkInputs(v *validation) { + slots := inputSlots(v) + if len(slots) == 0 { + return + } + compiler := jsonschema.NewCompiler() + compiler.DefaultDraft(jsonschema.Draft2020) + compiler.UseLoader(inputSchemaLoader{}) + regex := &inputRegexpState{v: v} + compiler.UseRegexpEngine(regex.compile) + base := inputSchemaBase(v) + if err := compiler.AddResource(base, v.root); err != nil { + v.err = &Error{Kind: ErrorOperational, Cause: err} + return + } + // Register all schema resources and anchors before reporting any missing + // reference. Compile first collects the targeted subschema, even when a + // reference in its body cannot yet resolve. No custom vocabulary is used: + // arbitrary annotations named workflows/components must remain annotations. + for _, slot := range slots { + if !v.check() { + return + } + _, _ = compiler.Compile(base + "#" + url.PathEscape(slot.path)) + } + // Resolve each authored reference separately. An unavailable external sibling + // must not prevent diagnostics for a definite missing local target. + var refs []inputReference + index := inputSchemaIndex{resources: map[string]string{base: ""}, anchors: make(map[string]string)} + for _, slot := range slots { + collectInputReferences(slot.value, slot.path, base, &refs, index) + } + for _, ref := range refs { + if !v.check() { + return + } + _, err := compiler.Compile(inputReferenceLocation(ref.uri, base, index)) + if err != nil { + reportInputCompile(v, regex, err, ref.path) + } + } + for _, slot := range slots { + if !v.check() { + return + } + _, err := compiler.Compile(base + "#" + url.PathEscape(slot.path)) + if err == nil { + continue + } + reportInputCompile(v, regex, err, schemaReferencePath(slot.value, slot.path)) + } +} + +func inputSchemaBase(v *validation) string { + base := v.doc.URI + if v.opts.baseURI != "" { + if b, err := url.Parse(v.opts.baseURI); err == nil { + if u, err := url.Parse(base); err == nil { + base = b.ResolveReference(u).String() + } + } + } + if v.version == "1.1" { + if self := text(v.root["$self"]); self != "" { + if b, err := url.Parse(base); err == nil { + if s, err := url.Parse(self); err == nil { + base = b.ResolveReference(s).String() + } + } + } + } + u, err := url.Parse(base) + if err != nil || !u.IsAbs() { + return "https://arazzo.invalid/document" + } + u.Fragment = "" + return u.String() +} + +func schemaReferencePath(value any, path string) string { + m := object(value) + for _, keyword := range []string{"$ref", "$dynamicRef", "$id", "$anchor", "$dynamicAnchor", "$schema"} { + if _, ok := m[keyword]; ok { + return joinPtr(path, keyword) + } + } + found := "" + forEachSubschema(m, path, func(child any, childPath string) bool { + if candidate := schemaReferencePath(child, childPath); candidate != childPath { + found = candidate + return false + } + return true + }) + if found != "" { + return found + } + return strings.TrimSuffix(path, "/") +} + +func reportInputCompile(v *validation, regex *inputRegexpState, err error, path string) { + if regex.err != nil { + v.err = &Error{Kind: ErrorOperational, Location: v.location(path), Cause: regex.err} + return + } + if v.err != nil { + return + } + var unavailable *inputResourceUnavailable + // LoadURLError deliberately has no Unwrap; inspect its public cause. + var load *jsonschema.LoadURLError + if errors.As(err, &load) { + errors.As(load.Err, &unavailable) + } + if errors.As(err, &unavailable) || unavailable != nil { + v.incomplete("input-schema-reference", path, unavailable.uri, "external JSON Schema resource is not supplied") + return + } + v.add(CodeInputSchema, path, err.Error()) +} + +func collectInputReferences(value any, path, base string, out *[]inputReference, index inputSchemaIndex) { + m := object(value) + if id, ok := m["$id"].(string); ok { + if b, err := url.Parse(base); err == nil { + if u, err := url.Parse(id); err == nil { + base = b.ResolveReference(u).String() + index.resources[base] = path + } + } + } + for _, keyword := range []string{"$anchor", "$dynamicAnchor"} { + if anchor, ok := m[keyword].(string); ok { + index.anchors[base+"#"+anchor] = path + } + } + for _, keyword := range []string{"$ref", "$dynamicRef"} { + if ref, ok := m[keyword].(string); ok { + if b, err := url.Parse(base); err == nil { + if u, err := url.Parse(ref); err == nil { + *out = append(*out, inputReference{b.ResolveReference(u).String(), joinPtr(path, keyword)}) + } + } + } + } + forEachSubschema(m, path, func(child any, childPath string) bool { + collectInputReferences(child, childPath, base, out, index) + return true + }) +} + +// checkInputLimits runs before Arazzo structural validation invokes its embedded +// JSON Schema metaschema. Data annotations are deliberately outside this walk. +func checkInputLimits(v *validation) { + for _, slot := range inputSlots(v) { + walkInputSchemas(v, slot.value, slot.path, func(schema map[string]any, path string) { + if pattern, ok := schema["pattern"].(string); ok && len(pattern) > v.opts.limits.MaxPatternBytes { + v.err = &Error{Kind: ErrorLimit, Location: v.location(joinPtr(path, "pattern")), Cause: fmt.Errorf("input schema pattern exceeds %d bytes", v.opts.limits.MaxPatternBytes)} + return + } + for _, key := range sortedKeys(object(schema["patternProperties"])) { + if len(key) > v.opts.limits.MaxPatternBytes { + v.err = &Error{Kind: ErrorLimit, Location: v.location(joinPtr(joinPtr(path, "patternProperties"), key)), Cause: fmt.Errorf("input schema property pattern exceeds %d bytes", v.opts.limits.MaxPatternBytes)} + return + } + } + }) + if v.err != nil { + return + } + } +} + +func inputSlots(v *validation) []inputSlot { + var slots []inputSlot + for i, raw := range array(v.root["workflows"]) { + m := object(raw) + if schema, ok := m["inputs"]; ok { + slots = append(slots, inputSlot{schema, "/workflows/" + strconv.Itoa(i) + "/inputs"}) + } + } + for key, schema := range object(object(v.root["components"])["inputs"]) { + slots = append(slots, inputSlot{schema, joinPtr("/components/inputs", key)}) + } + sort.Slice(slots, func(i, j int) bool { return slots[i].path < slots[j].path }) + return slots +} + +func walkInputSchemas(v *validation, value any, path string, visit func(map[string]any, string)) { + if !v.check() { + return + } + m := object(value) + if m == nil { + return + } + visit(m, path) + if v.err != nil { + return + } + forEachSubschema(m, path, func(child any, childPath string) bool { + walkInputSchemas(v, child, childPath, visit) + return v.err == nil + }) +} + +// forEachSubschema visits only schema-bearing keywords. Annotation data such as +// const, default and examples must not become schemas or source references. +func forEachSubschema(m map[string]any, path string, visit func(any, string) bool) { + for _, keyword := range []string{"$defs", "properties", "patternProperties", "dependentSchemas"} { + children := object(m[keyword]) + for _, key := range sortedKeys(children) { + if !visit(children[key], joinPtr(joinPtr(path, keyword), key)) { + return + } + } + } + for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { + for i, child := range array(m[keyword]) { + if !visit(child, joinPtr(joinPtr(path, keyword), strconv.Itoa(i))) { + return + } + } + } + for _, keyword := range []string{"items", "contains", "not", "if", "then", "else", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "propertyNames", "contentSchema"} { + if child, ok := m[keyword]; ok { + if !visit(child, joinPtr(path, keyword)) { + return + } + } + } +} + +func inputReferenceLocation(uri, base string, index inputSchemaIndex) string { + if pointer, ok := index.anchors[uri]; ok { + return base + "#" + url.PathEscape(pointer) + } + u, err := url.Parse(uri) + if err != nil { + return uri + } + fragment := u.Fragment + u.Fragment = "" + if pointer, ok := index.resources[u.String()]; ok && (fragment == "" || strings.HasPrefix(fragment, "/")) { + return base + "#" + url.PathEscape(pointer+fragment) + } + return uri +} diff --git a/arazzo/inputs_test.go b/arazzo/inputs_test.go new file mode 100644 index 0000000..e5867a3 --- /dev/null +++ b/arazzo/inputs_test.go @@ -0,0 +1,154 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "strings" + "testing" +) + +func inputValidate(t *testing.T, schema, components string) *Result { + t.Helper() + fixture := strings.Replace(expressionFixture, " steps:\n", " inputs:\n"+schema+" steps:\n", 1) + components + result, err := ValidateBytes(context.Background(), []byte(fixture), "https://example.com/workflow.yaml") + if err != nil { + t.Fatal(err) + } + return result +} + +func TestInputSchema202012Structure(t *testing.T) { + r := inputValidate(t, " type: object\n properties:\n count:\n type: not-a-json-schema-type\n", "") + expressionFinding(t, r, CodeStructure, "/workflows/0/inputs/properties/count/type", 15) + valid := inputValidate(t, ` type: object + properties: + nullable: + type: [string, 'null'] + choice: + oneOf: + - type: string + - type: integer + forbidden: false + additionalProperties: true +`, "") + if !valid.Valid() { + t.Fatalf("JSON Schema 2020-12 rejected: %+v", valid.Diagnostics) + } +} + +func TestInputSchemaLocalRefsAndResourceScope(t *testing.T) { + for _, test := range []struct{ name, schema, components string }{ + {"component pointer", " $ref: '#/components/inputs/shared'\n", `components: + inputs: + shared: + type: object + properties: + id: {type: integer} +`}, + {"component anchor", " $ref: '#shared'\n", `components: + inputs: + shared: + $anchor: shared + type: object +`}, + {"nested resource", " $ref: 'schemas/shared.json#value'\n", `components: + inputs: + shared: + $id: schemas/shared.json + $defs: + value: + $anchor: value + type: string +`}, + {"recursive dynamic", " $dynamicAnchor: node\n type: object\n properties:\n child:\n $dynamicRef: '#node'\n", ""}, + } { + t.Run(test.name, func(t *testing.T) { + r := inputValidate(t, test.schema, test.components) + if !r.Valid() { + t.Fatalf("valid input schema: %+v", r.Diagnostics) + } + for _, check := range r.Checks { + if check.Name == "input-schema-reference" && check.Status == CheckIncomplete { + t.Fatalf("local reference skipped: %+v", check) + } + } + }) + } +} + +func TestInputSchemaMissingLocalRefAndExternalCoverage(t *testing.T) { + missing := inputValidate(t, " $ref: '#/components/inputs/missing'\n", "") + expressionFinding(t, missing, CodeInputSchema, "/workflows/0/inputs/$ref", 12) + external := inputValidate(t, " $ref: 'https://example.com/external-schema.json'\n", "") + if !external.Valid() { + t.Fatalf("unavailable resource is not malformed: %+v", external.Diagnostics) + } + found := false + for _, check := range external.Checks { + if check.Name == "input-schema-reference" && check.Status == CheckIncomplete && check.Pointer == "/workflows/0/inputs/$ref" && check.Source == "https://example.com/external-schema.json" { + found = true + } + } + if !found { + t.Fatalf("external resource coverage missing: %+v", external.Checks) + } +} + +func TestInputSchemaDataKeywordsDoNotDeclareReferences(t *testing.T) { + r := inputValidate(t, ` type: object + const: + $ref: https://example.com/not-a-schema.json + properties: + id: {type: not-a-schema} +`, "") + if !r.Valid() { + t.Fatalf("schema literal data scanned as schema: %+v", r.Diagnostics) + } + for _, check := range r.Checks { + if check.Name == "input-schema-reference" { + t.Fatalf("data caused resource load: %+v", check) + } + } +} + +func TestInputSchemaExternalSiblingDoesNotHideMissingLocalRef(t *testing.T) { + r := inputValidate(t, ` type: object + properties: + remote: + $ref: https://example.com/missing-schema.json + local: + $ref: '#/$defs/missing' +`, "") + expressionFinding(t, r, CodeInputSchema, "/workflows/0/inputs/properties/local/$ref", 17) +} + +func TestInputPatternLimitAndLiteralAnnotations(t *testing.T) { + limits := DefaultLimits() + limits.MaxPatternBytes = 4 + fixture := strings.Replace(expressionFixture, " steps:\n", " inputs:\n type: string\n pattern: '12345'\n steps:\n", 1) + r, err := ValidateBytes(context.Background(), []byte(fixture), "https://example.com/workflow.yaml", WithLimits(limits)) + if err == nil || r.Complete { + t.Fatalf("pattern limit was not operational: result=%+v err=%v", r, err) + } + fixture = strings.Replace(expressionFixture, " steps:\n", " inputs:\n type: string\n default: {pattern: '12345'}\n steps:\n", 1) + r, err = ValidateBytes(context.Background(), []byte(fixture), "https://example.com/workflow.yaml", WithLimits(limits)) + if err != nil || !r.Valid() { + t.Fatalf("literal schema default capped as pattern: result=%+v err=%v", r, err) + } +} + +func TestInputUnknownAnnotationsRemainData(t *testing.T) { + r := inputValidate(t, ` type: object + workflows: + - inputs: + $id: ':invalid URI' + components: + inputs: + literal: {$anchor: 'invalid anchor'} +`, "") + if !r.Valid() { + t.Fatalf("unknown JSON Schema annotation treated as schema: %+v", r.Diagnostics) + } +} diff --git a/arazzo/nodes.go b/arazzo/nodes.go new file mode 100644 index 0000000..b5d689f --- /dev/null +++ b/arazzo/nodes.go @@ -0,0 +1,271 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strconv" + "strings" + + "github.com/pb33f/go-yaml" +) + +func object(v any) map[string]any { m, _ := v.(map[string]any); return m } +func array(v any) []any { a, _ := v.([]any); return a } +func text(v any) string { s, _ := v.(string); return s } +func joinPtr(path, token string) string { + return path + "/" + strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1") +} + +type normalizer struct { + ctx context.Context + doc Document + limits Limits + nodes map[string]nodeLocation + active map[*yaml.Node]bool + diagnostics []Diagnostic + count, bytes int +} + +// normalizeWithStats converts the JSON-compatible node graph once, indexing each instance +// pointer. Alias visits count against the budget, so a small graph cannot expand +// into an unbounded JSON tree. Merge keys are expanded without editing any node. +type nodeStats struct{ nodes, bytes int } + +func normalizeWithStats(ctx context.Context, doc Document, limits Limits) (any, map[string]nodeLocation, []Diagnostic, nodeStats, error) { + n := normalizer{ctx: ctx, doc: doc, limits: limits, nodes: make(map[string]nodeLocation), active: make(map[*yaml.Node]bool)} + value, err := n.value(doc.Root, "", 0) + return value, n.nodes, n.diagnostics, nodeStats{n.count, n.bytes}, err +} + +func (n *normalizer) issue(path, message string, node *yaml.Node) error { + if len(n.diagnostics) >= n.limits.MaxDiagnostics { + return &Error{Kind: ErrorLimit, Cause: fmt.Errorf("diagnostic limit exceeded")} + } + loc := Location{URI: n.doc.URI, Pointer: path} + if node != nil { + loc.Line, loc.Column = node.Line, node.Column + } + n.diagnostics = append(n.diagnostics, Diagnostic{Code: CodeStructure, Severity: SeverityError, Message: message, Location: loc}) + return nil +} + +func (n *normalizer) value(node *yaml.Node, path string, depth int) (any, error) { + if err := n.ctx.Err(); err != nil { + return nil, err + } + if node == nil { + return nil, &Error{Kind: ErrorInput, Cause: fmt.Errorf("source contains a nil node")} + } + n.count++ + n.bytes += len(node.Value) + if depth > n.limits.MaxDepth || n.count > n.limits.MaxNodes || n.bytes > n.limits.MaxBytes { + return nil, &Error{Kind: ErrorLimit, Cause: fmt.Errorf("node, depth or expanded byte limit exceeded")} + } + if n.active[node] { + return nil, n.issue(path, "recursive YAML alias or node graph is not a JSON value", node) + } + n.active[node] = true + defer delete(n.active, node) + loc := n.nodes[path] + loc.value = node + n.nodes[path] = loc + switch node.Kind { + case yaml.DocumentNode: + if len(node.Content) != 1 { + return nil, &Error{Kind: ErrorInput, Cause: fmt.Errorf("expected one document root")} + } + return n.value(node.Content[0], path, depth) + case yaml.AliasNode: + value, err := n.value(node.Alias, path, depth+1) + // Findings at the alias use point refer to the authored alias itself. + loc.value = node + n.nodes[path] = loc + return value, err + case yaml.MappingNode: + if node.ShortTag() != "!!map" && node.Tag != "" { + return nil, n.issue(path, "YAML mapping tag is not JSON-compatible", node) + } + return n.mapping(node, path, depth) + case yaml.SequenceNode: + if node.ShortTag() != "!!seq" && node.Tag != "" { + return nil, n.issue(path, "YAML sequence tag is not JSON-compatible", node) + } + if len(node.Content) > n.limits.MaxNodes-n.count { + return nil, &Error{Kind: ErrorLimit, Cause: fmt.Errorf("node limit exceeded")} + } + items := make([]any, len(node.Content)) + for i, child := range node.Content { + v, err := n.value(child, joinPtr(path, strconv.Itoa(i)), depth+1) + if err != nil { + return nil, err + } + items[i] = v + } + return items, nil + case yaml.ScalarNode: + switch node.ShortTag() { + case "!!str": + return node.Value, nil + case "!!null": + return nil, nil + case "!!bool": + if node.Value == "true" || node.Value == "True" || node.Value == "TRUE" { + return true, nil + } + if node.Value == "false" || node.Value == "False" || node.Value == "FALSE" { + return false, nil + } + case "!!int": + clean := strings.ReplaceAll(node.Value, "_", "") + base := 10 + unsigned := strings.TrimLeft(clean, "+-") + if strings.HasPrefix(unsigned, "0x") || strings.HasPrefix(unsigned, "0o") || strings.HasPrefix(unsigned, "0b") { + base = 0 + } + if integer, ok := new(big.Int).SetString(clean, base); ok { + return json.Number(integer.String()), nil + } + case "!!float": + clean := strings.ReplaceAll(node.Value, "_", "") + clean = strings.TrimPrefix(clean, "+") + if strings.HasPrefix(clean, ".") { + clean = "0" + clean + } + if strings.HasPrefix(clean, "-.") { + clean = "-0" + clean[1:] + } + if strings.HasSuffix(clean, ".") { + clean += "0" + } + // JSON's number grammar rejects NaN, infinity and YAML-only spellings. + if json.Valid([]byte(clean)) && len(clean) > 0 && (clean[0] == '-' || clean[0] >= '0' && clean[0] <= '9') { + // Schema integer and numeric checks use arbitrary-precision numbers. + // Charge exponent expansion before those checks allocate 10^exponent. + if i := strings.IndexAny(clean, "eE"); i >= 0 { + exponent := strings.TrimLeft(clean[i+1:], "+-") + growth, err := strconv.Atoi(exponent) + if err != nil || growth > n.limits.MaxBytes-n.bytes { + return nil, &Error{Kind: ErrorLimit, Cause: fmt.Errorf("numeric exponent exceeds expanded byte limit")} + } + n.bytes += growth + } + return json.Number(clean), nil + } + } + return nil, n.issue(path, "YAML scalar is not a JSON-compatible value", node) + default: + return nil, n.issue(path, "unsupported YAML node kind", node) + } +} + +func (n *normalizer) mapping(node *yaml.Node, path string, depth int) (any, error) { + if len(node.Content)%2 != 0 { + return nil, &Error{Kind: ErrorInput, Cause: fmt.Errorf("mapping has an unpaired key")} + } + if len(node.Content) > n.limits.MaxNodes-n.count { + return nil, &Error{Kind: ErrorLimit, Cause: fmt.Errorf("node limit exceeded")} + } + result := make(map[string]any, len(node.Content)/2) + explicit := make(map[string]bool, len(node.Content)/2) + for i := 0; i < len(node.Content); i += 2 { + key, child := node.Content[i], node.Content[i+1] + if key == nil { + return nil, &Error{Kind: ErrorInput, Cause: fmt.Errorf("nil mapping key")} + } + n.count++ + n.bytes += len(key.Value) + if n.count > n.limits.MaxNodes || n.bytes > n.limits.MaxBytes { + return nil, &Error{Kind: ErrorLimit, Cause: fmt.Errorf("key node or byte limit exceeded")} + } + if key.ShortTag() == "!!merge" { + continue + } + if key.Kind != yaml.ScalarNode || key.ShortTag() != "!!str" { + if err := n.issue(path, "mapping keys must be scalar strings", key); err != nil { + return nil, err + } + continue + } + p := joinPtr(path, key.Value) + if explicit[key.Value] { + if err := n.issue(p, "duplicate mapping key", key); err != nil { + return nil, err + } + continue + } + explicit[key.Value] = true + n.nodes[p] = nodeLocation{key: key} + v, err := n.value(child, p, depth+1) + if err != nil { + return nil, err + } + result[key.Value] = v + } + // Explicit fields take precedence. Earlier merge maps take precedence over + // later maps, as defined by YAML merge semantics. + mergeSeen := false + for i := 0; i < len(node.Content); i += 2 { + key, child := node.Content[i], node.Content[i+1] + if key.ShortTag() != "!!merge" { + continue + } + if mergeSeen { + if err := n.issue(path, "duplicate merge key", key); err != nil { + return nil, err + } + continue + } + mergeSeen = true + v, err := n.value(child, joinPtr(path, "<<"), depth+1) + if err != nil { + return nil, err + } + maps := []any{v} + if seq, ok := v.([]any); ok { + maps = seq + } + for j, item := range maps { + m, ok := item.(map[string]any) + if !ok { + if err := n.issue(path, "merge value must be a mapping or sequence of mappings", child); err != nil { + return nil, err + } + continue + } + origin := joinPtr(path, "<<") + if _, seq := v.([]any); seq { + origin = joinPtr(origin, strconv.Itoa(j)) + } + for name, value := range m { + if _, exists := result[name]; exists { + continue + } + result[name] = value + n.copyLocations(value, joinPtr(origin, name), joinPtr(path, name)) + } + } + } + return result, nil +} + +func (n *normalizer) copyLocations(value any, from, to string) { + // Visit this expanded subtree only. Scanning the whole document per merged + // field would make repeated small merges quadratic. + n.nodes[to] = n.nodes[from] + if m, ok := value.(map[string]any); ok { + for key, child := range m { + n.copyLocations(child, joinPtr(from, key), joinPtr(to, key)) + } + } + if a, ok := value.([]any); ok { + for i, child := range a { + token := strconv.Itoa(i) + n.copyLocations(child, joinPtr(from, token), joinPtr(to, token)) + } + } +} diff --git a/arazzo/nodes_boundaries_test.go b/arazzo/nodes_boundaries_test.go new file mode 100644 index 0000000..8bc9adb --- /dev/null +++ b/arazzo/nodes_boundaries_test.go @@ -0,0 +1,121 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/pb33f/go-yaml" +) + +func TestNodeMalformedGraphs(t *testing.T) { + str := func(s string) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: s} } + merge := func() *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!merge", Value: "<<"} } + for _, tc := range []struct { + name string + root *yaml.Node + kind ErrorKind + message string + }{ + {"nil", nil, ErrorInput, ""}, + {"empty document", &yaml.Node{Kind: yaml.DocumentNode}, ErrorInput, ""}, + {"unpaired key", &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{str("a")}}, ErrorInput, ""}, + {"nil key", &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{nil, str("a")}}, ErrorInput, ""}, + {"nil value", &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{str("a"), nil}}, ErrorInput, ""}, + {"unknown kind", &yaml.Node{Kind: 128}, "", "unsupported YAML node kind"}, + {"mapping tag", &yaml.Node{Kind: yaml.MappingNode, Tag: "!custom"}, "", "mapping tag"}, + {"sequence tag", &yaml.Node{Kind: yaml.SequenceNode, Tag: "!custom"}, "", "sequence tag"}, + {"bad bool", &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "yes"}, "", "scalar"}, + {"bad int", &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: "0xinvalid"}, "", "scalar"}, + {"bad merge", &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{merge(), str("invalid")}}, "", "merge value"}, + {"duplicate merge", &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{merge(), {Kind: yaml.MappingNode}, merge(), {Kind: yaml.MappingNode}}}, "", "duplicate merge"}, + } { + t.Run(tc.name, func(t *testing.T) { + before := cloneNodeGraph(tc.root) + _, _, ds, _, err := normalizeWithStats(context.Background(), Document{Root: tc.root, URI: "nodes.yaml"}, DefaultLimits()) + if tc.kind != "" { + var tool *Error + if !errors.As(err, &tool) || tool.Kind != tc.kind { + t.Fatalf("want %s: %v", tc.kind, err) + } + } else if err != nil || len(ds) != 1 || !strings.Contains(ds[0].Message, tc.message) || ds[0].URI != "nodes.yaml" { + t.Fatalf("diagnostics %+v, error %v", ds, err) + } + if !reflect.DeepEqual(before, tc.root) { + t.Fatal("normalization modified caller nodes") + } + }) + } +} + +func TestNodeNumericSpellings(t *testing.T) { + for _, tc := range []struct{ tag, value, want string }{ + {"!!float", "+.5", "0.5"}, + {"!!float", "-.5", "-0.5"}, + {"!!float", "1.", "1.0"}, + {"!!float", "1_000.5e+2", "1000.5e+2"}, + {"!!int", "-0b101", "-5"}, + {"!!int", "0o17", "15"}, + } { + root := &yaml.Node{Kind: yaml.ScalarNode, Tag: tc.tag, Value: tc.value} + v, _, ds, _, err := normalizeWithStats(context.Background(), Document{Root: root}, DefaultLimits()) + if err != nil || len(ds) != 0 || v != json.Number(tc.want) { + t.Fatalf("%s: %v %+v %v", tc.value, v, ds, err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _, _, _, err := normalizeWithStats(ctx, Document{Root: &yaml.Node{}}, DefaultLimits()) + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +func TestNodeMergePrecedenceAndLocations(t *testing.T) { + data := "first: &first {list: [{child: first}], explicit: inherited}\nsecond: &second {list: [second], other: second}\nmerged: {<<: [*first, *second], explicit: authored}\n" + var root yaml.Node + if err := yaml.Unmarshal([]byte(data), &root); err != nil { + t.Fatal(err) + } + v, locations, ds, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) + if err != nil || len(ds) != 0 { + t.Fatalf("%+v %v", ds, err) + } + m := object(object(v)["merged"]) + if m["explicit"] != "authored" || m["other"] != "second" || object(array(m["list"])[0])["child"] != "first" { + t.Fatalf("merge precedence: %+v", m) + } + if locations["/merged/list/0/child"].value != locations["/merged/< DefaultLimits().MaxPatternBytes { + return nil, fmt.Errorf("pattern exceeds compile limit") + } + if pattern == `^(?!\$).+$` { + return nonDollarPattern{}, nil + } + return regexp.Compile(pattern) +} + +func checkStructure(v *validation) { + invalidTargets := make(map[string]bool) + for wi, raw := range array(v.root["workflows"]) { + for si, raw := range array(object(raw)["steps"]) { + count := 0 + for _, name := range []string{"operationId", "operationPath", "workflowId", "channelPath"} { + if _, exists := object(raw)[name]; exists { + count++ + } + } + if count != 1 { + path := "/workflows/" + strconv.Itoa(wi) + "/steps/" + strconv.Itoa(si) + invalidTargets[path] = true + v.add(CodeStructure, path, "step must specify exactly one operation, workflow or channel target") + } + } + } + schemas, err := officialSchemas() + if err != nil { + v.err = &Error{Kind: ErrorOperational, Cause: err} + return + } + err = schemas[v.version].Validate(v.root) + if err == nil { + return + } + // Schema.Validate returns only *jsonschema.ValidationError on failure. + validationError := err.(*jsonschema.ValidationError) + printer := message.NewPrinter(language.English) + seen := make(map[struct{ path, message string }]bool) + var collect func(*jsonschema.ValidationError) + collect = func(failure *jsonschema.ValidationError) { + if !v.check() { + return + } + path := "" + for _, token := range failure.InstanceLocation { + path = joinPtr(path, token) + } + schemaPath := failure.SchemaURL + for _, token := range failure.ErrorKind.KeywordPath() { + schemaPath = joinPtr(schemaPath, token) + } + emit := func(path, msg string, key bool) { + // Suppress only the target alternatives' fallout. Common step fields + // still need diagnostics even when the step selects several targets. + for parent := path; parent != ""; parent = parent[:strings.LastIndex(parent, "/")] { + if !invalidTargets[parent] { + continue + } + field, _, _ := strings.Cut(strings.TrimPrefix(path[len(parent):], "/"), "/") + switch field { + case "", "operationId", "operationPath", "workflowId", "channelPath", "action": + return + case "stepId", "description", "timeout", "dependsOn", "parameters", "requestBody", "successCriteria", "onSuccess", "onFailure", "outputs", "correlationId": + // Parameter item shape depends on the selected target. The base + // array contract still applies while that selection is invalid. + if field == "parameters" && path != joinPtr(parent, field) { + return + } + // Failed property validation leaves a known field unevaluated. + // Keep its type/shape error, without also calling it unknown. + if _, ok := failure.ErrorKind.(*kind.FalseSchema); ok && strings.Contains(failure.SchemaURL, "/unevaluatedProperties") && path == joinPtr(parent, field) { + return + } + } + break + } + identity := struct{ path, message string }{path, msg} + if seen[identity] { + return + } + seen[identity] = true + before := len(v.result.Diagnostics) + if key { + v.addKey(CodeStructure, path, msg) + } else { + v.add(CodeStructure, path, msg) + } + if len(v.result.Diagnostics) > before { + v.result.Diagnostics[before].SchemaPointer = schemaPath + } + } + switch detail := failure.ErrorKind.(type) { + case *kind.Required: + for _, name := range detail.Missing { + emit(joinPtr(path, name), "missing required property "+name, false) + } + return + case *kind.AdditionalProperties: + names := append([]string(nil), detail.Properties...) + sort.Strings(names) + for _, name := range names { + emit(joinPtr(path, name), "property is not permitted", true) + } + return + case *kind.PropertyNames: + emit(joinPtr(path, detail.Property), detail.LocalizedString(printer), true) + return + } + if len(failure.Causes) > 0 { + children := append([]*jsonschema.ValidationError(nil), failure.Causes...) + sort.Slice(children, func(i, j int) bool { + a, b := children[i], children[j] + pa, pb := strings.Join(a.InstanceLocation, "\x00"), strings.Join(b.InstanceLocation, "\x00") + if pa != pb { + return pa < pb + } + if a.SchemaURL != b.SchemaURL { + return a.SchemaURL < b.SchemaURL + } + return a.ErrorKind.LocalizedString(printer) < b.ErrorKind.LocalizedString(printer) + }) + for _, child := range children { + collect(child) + } + return + } + switch failure.ErrorKind.(type) { + case *kind.FalseSchema: + if strings.Contains(failure.SchemaURL, "/unevaluatedProperties") { + emit(path, "property is not permitted", true) + return + } + } + emit(path, failure.ErrorKind.LocalizedString(printer), false) + } + collect(validationError) +} diff --git a/arazzo/schemas/LICENSE b/arazzo/schemas/LICENSE new file mode 100644 index 0000000..b0fa971 --- /dev/null +++ b/arazzo/schemas/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 - The go-yaml Project Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/arazzo/schemas/NOTICE b/arazzo/schemas/NOTICE new file mode 100644 index 0000000..f91ba5a --- /dev/null +++ b/arazzo/schemas/NOTICE @@ -0,0 +1,10 @@ +The schema snapshots in this directory are published by the OpenAPI Initiative +under the Apache License, Version 2.0. The license text is in LICENSE. + +Original resources: +https://spec.openapis.org/arazzo/1.0/schema/2025-10-15 +https://spec.openapis.org/arazzo/1.1/schema/2026-04-15 + +The snapshots are unmodified. metadata.json records their provenance, sizes and +SHA-256 checksums. The compilation view makes the small normative corrections +documented in ../REQUIREMENTS.md. These corrections do not change the snapshots. diff --git a/arazzo/schemas/arazzo-1.0-2025-10-15.json b/arazzo/schemas/arazzo-1.0-2025-10-15.json new file mode 100644 index 0000000..907fe2c --- /dev/null +++ b/arazzo/schemas/arazzo-1.0-2025-10-15.json @@ -0,0 +1,792 @@ +{ + "$id": "https://spec.openapis.org/arazzo/1.0/schema/2025-10-15", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of Arazzo v1.0.x documents", + "type": "object", + "properties": { + "arazzo": { + "description": "The version number of the Arazzo Specification", + "type": "string", + "pattern": "^1\\.0\\.\\d+(-.+)?$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "sourceDescriptions": { + "description": "A list of source descriptions such as Arazzo or OpenAPI", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/source-description-object" + } + }, + "workflows": { + "description": "A list of workflows", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/workflow-object" + } + }, + "components": { + "$ref": "#/$defs/components-object" + } + }, + "required": [ + "arazzo", + "info", + "sourceDescriptions", + "workflows" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "info": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#info-object", + "description": "Provides metadata about the Arazzo description", + "type": "object", + "properties": { + "title": { + "description": "A human readable title of the Arazzo Description", + "type": "string" + }, + "summary": { + "description": "A short summary of the Arazzo Description", + "type": "string" + }, + "description": { + "description": "A description of the purpose of the workflows defined. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "version": { + "description": "The version identifier of the Arazzo document (which is distinct from the Arazzo Specification version)", + "type": "string" + } + }, + "required": [ + "title", + "version" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "source-description-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#source-description-object", + "description": "Describes a source description (such as an OpenAPI description)\nthat will be referenced by one or more workflows described within\nan Arazzo description", + "type": "object", + "properties": { + "name": { + "description": "A unique name for the source description", + "type": "string", + "pattern": "^[A-Za-z0-9_\\-]+$" + }, + "url": { + "description": "A URL to a source description to be used by a workflow", + "type": "string", + "format": "uri-reference" + }, + "type": { + "description": "The type of source description", + "enum": [ + "arazzo", + "openapi" + ] + } + }, + "required": [ + "name", + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "workflow-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#workflow-object", + "description": "Describes the steps to be taken across one or more APIs to achieve an objective", + "type": "object", + "properties": { + "workflowId": { + "description": "Unique string to represent the workflow", + "$anchor": "workflowId", + "type": "string" + }, + "summary": { + "description": "A summary of the purpose or objective of the workflow", + "type": "string" + }, + "description": { + "description": "A description of the workflow. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "inputs": { + "description": "A JSON Schema 2020-12 object representing the input parameters used by this workflow", + "$ref": "#/$defs/schema" + }, + "dependsOn": { + "description": "A list of workflows that MUST be completed before this workflow can be processed", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "steps": { + "description": "An ordered list of steps where each step represents a call to an API operation or to another workflow", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/step-object" + } + }, + "successActions": { + "description": "A list of success actions that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/success-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "failureActions": { + "description": "A list of failure actions that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/failure-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "outputs": { + "description": "A map between a friendly name and a dynamic output value", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "type": "string" + } + } + }, + "parameters": { + "description": "A list of parameters that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/parameter-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + } + }, + "required": [ + "workflowId", + "steps" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "step-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#step-object'", + "description": "Describes a single workflow step which MAY be a call to an\nAPI operation (OpenAPI Operation Object or another Workflow Object)", + "type": "object", + "properties": { + "stepId": { + "description": "Unique string to represent the step", + "$anchor": "stepId", + "type": "string" + }, + "description": { + "description": "A description of the step. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "operationId": { + "description": "The name of an existing, resolvable operation, as defined with a unique operationId and existing within one of the sourceDescriptions", + "type": "string" + }, + "operationPath": { + "description": "A reference to a Source combined with a JSON Pointer to reference an operation", + "type": "string" + }, + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description", + "$ref": "#workflowId" + }, + "parameters": { + "description": "A list of parameters that MUST be passed to an operation or workflow as referenced by operationId, operationPath, or workflowId", + "type": "array", + "uniqueItems": true, + "items": true + }, + "requestBody": { + "$ref": "#/$defs/request-body-object" + }, + "successCriteria": { + "description": "A list of assertions to determine the success of the step", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/criterion-object" + } + }, + "onSuccess": { + "description": "An array of success action objects that specify what to do upon step success", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/success-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "onFailure": { + "description": "An array of failure action objects that specify what to do upon step failure", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/failure-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "outputs": { + "description": "A map between a friendly name and a dynamic output value defined using a runtime expression", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "type": "string" + } + } + } + }, + "required": [ + "stepId" + ], + "oneOf": [ + { + "required": [ + "operationId" + ] + }, + { + "required": [ + "operationPath" + ] + }, + { + "required": [ + "workflowId" + ] + } + ], + "allOf": [ + { + "if": { + "oneOf": [ + { + "required": [ + "operationPath" + ] + }, + { + "required": [ + "operationId" + ] + } + ] + }, + "then": { + "properties": { + "parameters": { + "items": { + "oneOf": [ + { + "$ref": "#/$defs/reusable-object" + }, + { + "$ref": "#/$defs/parameter-object", + "required": [ + "in" + ] + } + ] + } + } + } + } + }, + { + "if": { + "required": [ + "workflowId" + ] + }, + "then": { + "properties": { + "parameters": { + "items": { + "oneOf": [ + { + "$ref": "#/$defs/parameter-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + } + } + } + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "request-body-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#request-body-object", + "description": "The request body to pass to an operation as referenced by operationId or operationPath", + "type": "object", + "properties": { + "contentType": { + "description": "The Content-Type for the request content", + "type": "string" + }, + "payload": true, + "replacements": { + "description": "A list of locations and values to set within a payload", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/payload-replacement-object" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "criterion-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#criterion-object", + "description": "An object used to specify the context, conditions, and condition types\nthat can be used to prove or satisfy assertions specified in Step Object successCriteria,\nSuccess Action Object criteria, and Failure Action Object criteria", + "type": "object", + "properties": { + "context": { + "description": "A runtime expression used to set the context for the condition to be applied on", + "type": "string" + }, + "condition": { + "description": "The condition to apply", + "type": "string" + } + }, + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "description": "The type of condition to be applied", + "enum": [ + "simple", + "regex", + "jsonpath", + "xpath" + ], + "default": "simple" + } + } + }, + { + "$ref": "#/$defs/criterion-expression-type-object" + } + ], + "required": [ + "condition" + ], + "dependentRequired": { + "type": [ + "context" + ] + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "criterion-expression-type-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#criterion-expression-type-object", + "description": "An object used to describe the type and version of an expression used within a Criterion Object", + "type": "object", + "properties": { + "type": { + "description": "The type of condition to be applied", + "enum": [ + "jsonpath", + "xpath" + ] + }, + "version": { + "description": "A short hand string representing the version of the expression type", + "type": "string" + } + }, + "required": [ + "type", + "version" + ], + "allOf": [ + { + "if": { + "required": [ + "type" + ], + "properties": { + "type": { + "const": "jsonpath" + } + } + }, + "then": { + "properties": { + "version": { + "const": "draft-goessner-dispatch-jsonpath-00" + } + } + } + }, + { + "if": { + "required": [ + "type" + ], + "properties": { + "type": { + "const": "xpath" + } + } + }, + "then": { + "properties": { + "version": { + "enum": [ + "xpath-10", + "xpath-20", + "xpath-30" + ] + } + } + } + } + ], + "$ref": "#/$defs/specification-extensions" + }, + "success-action-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#success-action-object", + "description": "A single success action which describes an action to take upon success of a workflow step", + "type": "object", + "properties": { + "name": { + "description": "The name of the success action", + "type": "string" + }, + "type": { + "description": "The type of action to take", + "enum": [ + "end", + "goto" + ] + }, + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description to transfer to upon success of the step", + "$ref": "#workflowId" + }, + "stepId": { + "description": "The stepId to transfer to upon success of the step", + "$ref": "#stepId" + }, + "criteria": { + "description": "A list of assertions to determine if this action SHALL be executed", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/criterion-object" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "goto" + } + } + }, + "then": { + "oneOf": [ + { + "required": [ + "workflowId" + ] + }, + { + "required": [ + "stepId" + ] + } + ] + } + } + ], + "required": [ + "name", + "type" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "failure-action-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#failure-action-object", + "description": "A single failure action which describes an action to take upon failure of a workflow step", + "type": "object", + "properties": { + "name": { + "description": "The name of the failure action", + "type": "string" + }, + "type": { + "description": "The type of action to take", + "enum": [ + "end", + "goto", + "retry" + ] + }, + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description to transfer to upon failure of the step", + "$ref": "#workflowId" + }, + "stepId": { + "description": "The stepId to transfer to upon failure of the step", + "$ref": "#stepId" + }, + "retryAfter": { + "description": "A non-negative decimal indicating the seconds to delay after the step failure before another attempt SHALL be made", + "type": "number", + "minimum": 0 + }, + "retryLimit": { + "description": "A non-negative integer indicating how many attempts to retry the step MAY be attempted before failing the overall step", + "type": "integer", + "minimum": 0 + }, + "criteria": { + "description": "A list of assertions to determine if this action SHALL be executed", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/criterion-object" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "goto" + ] + } + } + }, + "then": { + "oneOf": [ + { + "required": [ + "workflowId" + ] + }, + { + "required": [ + "stepId" + ] + } + ] + } + } + ], + "required": [ + "name", + "type" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reusable-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#reusable-object", + "description": "A simple object to allow referencing of objects contained within the Components Object", + "type": "object", + "properties": { + "reference": { + "description": "A runtime expression used to reference the desired object", + "type": "string" + }, + "value": { + "description": "Sets a value of the referenced parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "reference" + ], + "unevaluatedProperties": false + }, + "parameter-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#parameter-object", + "description": "Describes a single step parameter", + "type": "object", + "properties": { + "name": { + "description": "The name of the parameter", + "type": "string" + }, + "in": { + "description": "The named location of the parameter", + "enum": [ + "path", + "query", + "header", + "cookie" + ] + }, + "value": { + "description": "The value to pass in the parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "name", + "value" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "payload-replacement-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#payload-replacement-object", + "description": "Describes a location within a payload (e.g., a request body) and a value to set within the location", + "type": "object", + "properties": { + "target": { + "description": "A JSON Pointer or XPath Expression which MUST be resolved against the request body", + "type": "string" + }, + "value": { + "description": "The value set within the target location", + "type": "string" + } + }, + "required": [ + "target", + "value" + ], + "unevaluatedProperties": false, + "$ref": "#/$defs/specification-extensions" + }, + "components-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#components-object", + "description": "Holds a set of reusable objects for different aspects of the Arazzo Specification", + "type": "object", + "properties": { + "inputs": { + "description": "An object to hold reusable JSON Schema 2020-12 schemas to be referenced from workflow inputs", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/schema" + } + }, + "parameters": { + "description": "An object to hold reusable Parameter Objects", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/parameter-object" + } + }, + "successActions": { + "description": "An object to hold reusable Success Actions Objects", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/success-action-object" + } + }, + "failureActions": { + "description": "An object to hold reusable Failure Actions Objects", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/failure-action-object" + } + } + }, + "patternProperties": { + "^(inputs|parameters|successActions|failureActions)$": { + "$comment": "Enumerating all of the property names in the regex is necessary for unevaluatedProperties to work as expected", + "propertyNames": { + "pattern": "^[a-zA-Z0-9\\.\\-_]+$" + } + } + }, + "unevaluatedProperties": false, + "$ref": "#/$defs/specification-extensions" + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#specification-extensions", + "description": "While the Arazzo Specification tries to accommodate most use cases, additional data can be added to extend the specification at certain points", + "patternProperties": { + "^x-": true + } + }, + "schema": { + "$comment": "https://spec.openapis.org/arazzo/v1.0#schema-object", + "$ref": "https://json-schema.org/draft/2020-12/schema" + } + } +} diff --git a/arazzo/schemas/arazzo-1.1-2026-04-15.json b/arazzo/schemas/arazzo-1.1-2026-04-15.json new file mode 100644 index 0000000..c9c206d --- /dev/null +++ b/arazzo/schemas/arazzo-1.1-2026-04-15.json @@ -0,0 +1,1042 @@ +{ + "$id": "https://spec.openapis.org/arazzo/1.1/schema/2026-04-15", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of Arazzo v1.1.x documents", + "type": "object", + "properties": { + "arazzo": { + "description": "The version number of the Arazzo Specification", + "type": "string", + "pattern": "^1\\.1\\.\\d+(-.+)?$" + }, + "$self": { + "type": "string", + "format": "uri-reference", + "$comment": "MUST NOT contain a fragment", + "pattern": "^[^#]*$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "sourceDescriptions": { + "description": "A list of source descriptions such as Arazzo or OpenAPI", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/source-description-object" + } + }, + "workflows": { + "description": "A list of workflows", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/workflow-object" + } + }, + "components": { + "$ref": "#/$defs/components-object" + } + }, + "required": [ + "arazzo", + "info", + "sourceDescriptions", + "workflows" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "info": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#info-object", + "description": "Provides metadata about the Arazzo description", + "type": "object", + "properties": { + "title": { + "description": "A human readable title of the Arazzo Description", + "type": "string" + }, + "summary": { + "description": "A short summary of the Arazzo Description", + "type": "string" + }, + "description": { + "description": "A description of the purpose of the workflows defined. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "version": { + "description": "The version identifier of the Arazzo document (which is distinct from the Arazzo Specification version)", + "type": "string" + } + }, + "required": [ + "title", + "version" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "source-description-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#source-description-object", + "description": "Describes a source description (such as an OpenAPI description)\nthat will be referenced by one or more workflows described within\nan Arazzo description", + "type": "object", + "properties": { + "name": { + "description": "A unique name for the source description", + "type": "string", + "pattern": "^[A-Za-z0-9_\\-]+$" + }, + "url": { + "description": "A URL to a source description to be used by a workflow", + "type": "string", + "format": "uri-reference" + }, + "type": { + "description": "The type of source description", + "enum": [ + "arazzo", + "openapi", + "asyncapi" + ] + } + }, + "required": [ + "name", + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "workflow-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#workflow-object", + "description": "Describes the steps to be taken across one or more APIs to achieve an objective", + "type": "object", + "properties": { + "workflowId": { + "description": "Unique string to represent the workflow", + "$anchor": "workflowId", + "type": "string" + }, + "summary": { + "description": "A summary of the purpose or objective of the workflow", + "type": "string" + }, + "description": { + "description": "A description of the workflow. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "inputs": { + "description": "A JSON Schema 2020-12 object representing the input parameters used by this workflow", + "$ref": "#/$defs/schema" + }, + "dependsOn": { + "description": "A list of workflows that MUST be completed before this workflow can be processed", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "steps": { + "description": "An ordered list of steps where each step represents a call to an API operation or to another workflow", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/step-object" + } + }, + "successActions": { + "description": "A list of success actions that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/success-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "failureActions": { + "description": "A list of failure actions that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/failure-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "outputs": { + "description": "A map between a friendly name and a dynamic output value defined using a Runtime Expression or Selector Object. Keys must match the regex: ^[a-zA-Z0-9\\.\\-_]+$\n", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/selector-object" + } + ] + } + } + }, + "parameters": { + "description": "A list of parameters that are applicable for all steps described under this workflow", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/parameter-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + } + }, + "required": [ + "workflowId", + "steps" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "step-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#step-object'", + "description": "Describes a single workflow step which MAY be a call to an\nAPI operation (OpenAPI Operation Object or AsyncAPI Operation Object or another Workflow Object)", + "oneOf": [ + { + "$ref": "#/$defs/openapi-step-object" + }, + { + "$ref": "#/$defs/asyncapi-step-object" + }, + { + "$ref": "#/$defs/workflow-step-object" + } + ] + }, + "step-object-base": { + "type": "object", + "properties": { + "stepId": { + "description": "Unique string to represent the step", + "$anchor": "stepId", + "type": "string" + }, + "description": { + "description": "A description of the step. CommonMark syntax MAY be used for rich text representation", + "type": "string" + }, + "timeout": { + "description": "The duration in milliseconds to wait before timing out the step", + "type": "integer" + }, + "dependsOn": { + "description": "Specifies a list of step identifiers that must complete (or be waited for) before the current step can begin execution. `dependsOn` only establishes a prerequisite relationship for the current step and does not trigger execution of the referenced steps. Steps referred by dependsOn SHOULD be non-blocking/async steps. Steps in the current workflow MUST be referenced directly by stepId. Steps in another workflow in the same Arazzo document MUST use $workflows..steps.. Steps in another Arazzo document MUST use $sourceDescriptions...steps..", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "string", + "pattern": "^(?!\\$).+$" + }, + { + "type": "string", + "pattern": "^\\$workflows\\.[^.]+\\.steps\\.[^.]+$" + }, + { + "type": "string", + "pattern": "^\\$sourceDescriptions\\.[^.]+\\.[^.]+\\.steps\\.[^.]+$" + } + ] + } + }, + "parameters": { + "description": "A list of parameters that MUST be passed to an operation or workflow as referenced by operationId, operationPath, or workflowId", + "type": "array", + "uniqueItems": true, + "items": true + }, + "requestBody": { + "$ref": "#/$defs/request-body-object" + }, + "successCriteria": { + "description": "A list of assertions to determine the success of the step", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/criterion-object" + } + }, + "onSuccess": { + "description": "An array of success action objects that specify what to do upon step success", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/success-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "onFailure": { + "description": "An array of failure action objects that specify what to do upon step failure", + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/failure-action-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + }, + "outputs": { + "description": "A map between a friendly name and a dynamic output value defined using a Runtime Expression or Selector Object. Keys must match the regex: ^[a-zA-Z0-9\\.\\-_]+$\n", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9\\.\\-_]+$": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/selector-object" + } + ] + } + } + } + }, + "required": [ + "stepId" + ] + }, + "operation-step-parameters": { + "type": "array", + "uniqueItems": true, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/reusable-object" + }, + { + "$ref": "#/$defs/parameter-object", + "required": [ + "in" + ] + } + ] + } + }, + "openapi-step-object": { + "allOf": [ + { + "$ref": "#/$defs/step-object-base" + }, + { + "type": "object", + "properties": { + "operationId": { + "description": "The name of an existing, resolvable operation, as defined with a unique operationId and existing within one of the sourceDescriptions", + "type": "string" + }, + "operationPath": { + "description": "A reference to a Source combined with a JSON Pointer to reference an operation", + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/operation-step-parameters" + } + } + }, + { + "oneOf": [ + { + "required": [ + "operationId" + ] + }, + { + "required": [ + "operationPath" + ] + } + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "asyncapi-step-object": { + "allOf": [ + { + "$ref": "#/$defs/step-object-base" + }, + { + "type": "object", + "properties": { + "operationId": { + "description": "The name of an existing, resolvable operation, as defined with a unique operationId and existing within one of the sourceDescriptions", + "type": "string" + }, + "channelPath": { + "description": "A reference to a Source combined with a JSON Pointer to reference an async channel", + "type": "string" + }, + "correlationId": { + "description": "ID to correlate async responses with their requests, only specified for async receive steps", + "type": [ + "string", + "number", + "boolean", + "object", + "array" + ] + }, + "action": { + "description": "Specifies the intended operation on the async channel, indicating whether the action is sending data to the channel or receiving data from the channel", + "enum": [ + "send", + "receive" + ] + }, + "parameters": { + "$ref": "#/$defs/operation-step-parameters" + } + }, + "required": [ + "action" + ] + }, + { + "if": { + "required": [ + "correlationId" + ] + }, + "then": { + "properties": { + "action": { + "const": "receive" + } + }, + "required": [ + "action" + ] + } + }, + { + "oneOf": [ + { + "required": [ + "operationId" + ] + }, + { + "required": [ + "channelPath" + ] + } + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "workflow-step-object": { + "allOf": [ + { + "$ref": "#/$defs/step-object-base" + }, + { + "type": "object", + "properties": { + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description", + "$ref": "#workflowId" + }, + "parameters": { + "items": { + "oneOf": [ + { + "$ref": "#/$defs/parameter-object" + }, + { + "$ref": "#/$defs/reusable-object" + } + ] + } + } + }, + "required": [ + "workflowId" + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "request-body-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#request-body-object", + "description": "The request body to pass to an operation as referenced by operationId or operationPath or channelPath", + "type": "object", + "properties": { + "contentType": { + "description": "The Content-Type for the request content", + "type": "string" + }, + "payload": true, + "replacements": { + "description": "A list of locations and values to set within a payload", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/payload-replacement-object" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "criterion-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#criterion-object", + "description": "An object used to specify the context, conditions, and condition types\nthat can be used to prove or satisfy assertions specified in Step Object successCriteria,\nSuccess Action Object criteria, and Failure Action Object criteria", + "type": "object", + "properties": { + "context": { + "description": "A runtime expression used to set the context for the condition to be applied on", + "type": "string" + }, + "condition": { + "description": "The condition to apply", + "type": "string" + }, + "type": { + "description": "The type of condition to be applied or a reference to an expression type object", + "oneOf": [ + { + "type": "string", + "enum": [ + "simple", + "regex", + "jsonpath", + "xpath" + ], + "default": "simple" + }, + { + "$ref": "#/$defs/expression-type-object" + } + ] + } + }, + "required": [ + "condition" + ], + "dependentRequired": { + "type": [ + "context" + ] + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "expression-type-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#expression-type-object", + "description": "An object used to describe the type and version of an expression used within a Criterion Object or Selector Object. If the `version` is omitted, a default value is assumed based on the expression `type`", + "type": "object", + "properties": { + "type": { + "description": "The type of selector to use", + "enum": [ + "jsonpath", + "xpath", + "jsonpointer" + ] + }, + "version": { + "description": "A short hand string representing the version of the expression type. If omitted, the default for the selected type will be used.\n", + "type": "string" + } + }, + "required": [ + "type", + "version" + ], + "allOf": [ + { + "if": { + "required": [ + "type" + ], + "properties": { + "type": { + "const": "jsonpath" + } + } + }, + "then": { + "properties": { + "version": { + "enum": [ + "rfc9535", + "draft-goessner-dispatch-jsonpath-00" + ] + } + } + } + }, + { + "if": { + "required": [ + "type" + ], + "properties": { + "type": { + "const": "xpath" + } + } + }, + "then": { + "properties": { + "version": { + "enum": [ + "xpath-10", + "xpath-20", + "xpath-30", + "xpath-31" + ] + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "jsonpointer" + } + } + }, + "then": { + "properties": { + "version": { + "const": "rfc6901" + } + } + } + } + ], + "$ref": "#/$defs/specification-extensions" + }, + "success-action-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#success-action-object", + "description": "A single success action which describes an action to take upon success of a workflow step", + "type": "object", + "properties": { + "name": { + "description": "The name of the success action", + "type": "string" + }, + "type": { + "description": "The type of action to take", + "enum": [ + "end", + "goto" + ] + }, + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description to transfer to upon success of the step", + "$ref": "#workflowId" + }, + "stepId": { + "description": "The stepId to transfer to upon success of the step", + "$ref": "#stepId" + }, + "parameters": { + "description": "A list of parameters that MUST be passed to a workflow as referenced by workflowId", + "type": "array", + "uniqueItems": true, + "items": true + }, + "criteria": { + "description": "A list of assertions to determine if this action SHALL be executed", + "type": "array", + "uniqueItems": true, + "minItems": 1, + "items": { + "$ref": "#/$defs/criterion-object" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "goto" + } + } + }, + "then": { + "oneOf": [ + { + "required": [ + "workflowId" + ] + }, + { + "required": [ + "stepId" + ] + } + ] + } + }, + { + "if": { + "required": [ + "parameters" + ] + }, + "then": { + "required": [ + "workflowId" + ] + } + } + ], + "required": [ + "name", + "type" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "failure-action-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#failure-action-object", + "description": "A single failure action which describes an action to take upon failure of a workflow step", + "type": "object", + "properties": { + "name": { + "description": "The name of the failure action", + "type": "string" + }, + "type": { + "description": "The type of action to take", + "enum": [ + "end", + "goto", + "retry" + ] + }, + "workflowId": { + "description": "The workflowId referencing an existing workflow within the Arazzo description to transfer to upon failure of the step", + "$ref": "#workflowId" + }, + "stepId": { + "description": "The stepId to transfer to upon failure of the step", + "$ref": "#stepId" + }, + "parameters": { + "description": "A list of parameters that MUST be passed to a workflow as referenced by workflowId", + "type": "array", + "uniqueItems": true, + "items": true + }, + "retryAfter": { + "description": "A non-negative decimal indicating the seconds to delay after the step failure before another attempt SHALL be made", + "type": "number", + "minimum": 0 + }, + "retryLimit": { + "description": "A non-negative integer indicating how many attempts to retry the step MAY be attempted before failing the overall step", + "type": "integer", + "minimum": 0 + }, + "criteria": { + "description": "A list of assertions to determine if this action SHALL be executed", + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/criterion-object" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "goto" + ] + } + } + }, + "then": { + "oneOf": [ + { + "required": [ + "workflowId" + ] + }, + { + "required": [ + "stepId" + ] + } + ] + } + }, + { + "if": { + "required": [ + "parameters" + ] + }, + "then": { + "required": [ + "workflowId" + ] + } + } + ], + "required": [ + "name", + "type" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reusable-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#reusable-object", + "description": "A simple object to allow referencing of objects contained within the Components Object", + "type": "object", + "properties": { + "reference": { + "description": "A runtime expression used to reference the desired object", + "type": "string" + }, + "value": { + "description": "Sets a value of the referenced parameter", + "type": [ + "string", + "boolean", + "object", + "array", + "number", + "null" + ] + } + }, + "required": [ + "reference" + ], + "unevaluatedProperties": false + }, + "parameter-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#parameter-object", + "description": "Describes a single step parameter", + "type": "object", + "properties": { + "name": { + "description": "The name of the parameter", + "type": "string" + }, + "in": { + "description": "The named location of the parameter", + "enum": [ + "path", + "query", + "querystring", + "header", + "cookie", + "channel" + ] + }, + "value": { + "description": "The value to pass in the parameter", + "oneOf": [ + { + "type": [ + "string", + "boolean", + "array", + "number", + "null" + ] + }, + { + "$ref": "#/$defs/selector-object" + } + ] + } + }, + "required": [ + "name", + "value" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "payload-replacement-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#payload-replacement-object", + "description": "Describes a location within a payload (e.g., a request body) and a value to set within the location", + "type": "object", + "properties": { + "target": { + "description": "A JSONPath, JSON Pointer, or XPath Expression which MUST be resolved against the request body", + "type": "string" + }, + "targetSelectorType": { + "description": "The selector expression type to use (e.g., `jsonpath`, `xpath`, or `jsonpointer`). Should an alternate version be required, the Expression Type Object may be used instead. If omitted, defaults to JSON Pointer for `application/json` or XPath for XML-based media types.\n", + "$ref": "#/$defs/selector-type" + }, + "value": { + "description": "The value to set at the location defined by the target. May be a literal, a runtime expression string, or a selector object.\n", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/selector-object" + } + ] + } + }, + "required": [ + "target", + "value" + ], + "unevaluatedProperties": false, + "$ref": "#/$defs/specification-extensions" + }, + "selector-type": { + "description": "A selector expression type that may be a simple string enum or an Expression Type Object for version-specific support", + "oneOf": [ + { + "type": "string", + "enum": [ + "jsonpointer", + "jsonpath", + "xpath" + ] + }, + { + "$ref": "#/$defs/expression-type-object" + } + ] + }, + "selector-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#selector-object", + "description": "An object which enables fine-grained traversal and precise data selection from structured data", + "type": "object", + "properties": { + "context": { + "description": "A valid Runtime Expression which MUST evaluate to structured data (e.g., `$response.body`), and sets the context for the selector to be applied on", + "type": "string" + }, + "selector": { + "description": "A selector expression (e.g., `$.items[0].id`, `/Envelope/Item`) in the form of JSONPath expression, XPath expression, or JSON Pointer expression", + "type": "string" + }, + "type": { + "description": "The selector expression type to use (e.g., `jsonpath`, `xpath`, or `jsonpointer`) or an Expression Type Object for older version support", + "$ref": "#/$defs/selector-type" + } + }, + "required": [ + "context", + "selector", + "type" + ], + "unevaluatedProperties": false, + "$ref": "#/$defs/specification-extensions" + }, + "components-object": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#components-object", + "description": "Holds a set of reusable objects for different aspects of the Arazzo Specification", + "type": "object", + "properties": { + "inputs": { + "description": "An object to hold reusable JSON Schema 2020-12 schemas to be referenced from workflow inputs", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/schema" + } + }, + "parameters": { + "description": "An object to hold reusable Parameter Objects", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/parameter-object" + } + }, + "successActions": { + "description": "An object to hold reusable Success Actions Objects", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/success-action-object" + } + }, + "failureActions": { + "description": "An object to hold reusable Failure Actions Objects", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/failure-action-object" + } + } + }, + "patternProperties": { + "^(inputs|parameters|successActions|failureActions)$": { + "$comment": "Enumerating all of the property names in the regex is necessary for unevaluatedProperties to work as expected", + "propertyNames": { + "pattern": "^[a-zA-Z0-9\\.\\-_]+$" + } + } + }, + "unevaluatedProperties": false, + "$ref": "#/$defs/specification-extensions" + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#specification-extensions", + "description": "While the Arazzo Specification tries to accommodate most use cases, additional data can be added to extend the specification at certain points", + "patternProperties": { + "^x-": true + } + }, + "schema": { + "$comment": "https://spec.openapis.org/arazzo/v1.1#schema-object", + "$ref": "https://json-schema.org/draft/2020-12/schema" + } + } +} diff --git a/arazzo/schemas/metadata.json b/arazzo/schemas/metadata.json new file mode 100644 index 0000000..179f52f --- /dev/null +++ b/arazzo/schemas/metadata.json @@ -0,0 +1,16 @@ +{ + "arazzo-1.0": { + "specification": "https://spec.openapis.org/arazzo/v1.0.1.html", + "schema": "https://spec.openapis.org/arazzo/1.0/schema/2025-10-15", + "iteration": "2025-10-15", + "sha256": "b8715bd824fffcb2accf5077977d37c9e7a15be60d785e7a3a51cf600fd46ad4", + "bytes": 23972 + }, + "arazzo-1.1": { + "specification": "https://spec.openapis.org/arazzo/v1.1.0.html", + "schema": "https://spec.openapis.org/arazzo/1.1/schema/2026-04-15", + "iteration": "2026-04-15", + "sha256": "37be908409bdb2f7bffe61fa23685c7e84cbeebfafac475a1d01dbc50ff7ab9e", + "bytes": 32347 + } +} diff --git a/arazzo/semantic_contracts_test.go b/arazzo/semantic_contracts_test.go new file mode 100644 index 0000000..cae7bef --- /dev/null +++ b/arazzo/semantic_contracts_test.go @@ -0,0 +1,560 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/pb33f/jsonschema/v6" +) + +func contractValidation(root map[string]any) *validation { + return &validation{ctx: context.Background(), doc: Document{URI: "https://example.com/workflow.yaml"}, opts: options{limits: DefaultLimits()}, root: root, nodes: map[string]nodeLocation{}, result: &Result{}, version: "1.1"} +} + +func TestContractInputSchemaDiagnostics(t *testing.T) { + r := inputValidate(t, " properties: {count: {type: unknown}}\n", "") + foundationFinding(t, r, CodeStructure, "/workflows/0/inputs/properties/count/type") + c := jsonschema.NewCompiler() + meta, err := c.Compile("https://json-schema.org/draft/2020-12/schema") + if err != nil { + t.Fatal(err) + } + err = meta.Validate(map[string]any{"type": "unknown"}) + if err == nil { + t.Fatal("invalid schema accepted") + } + v := contractValidation(nil) + reportInputCompile(v, &inputRegexpState{v: v}, &jsonschema.SchemaValidationError{Err: err}, "/inputs") + if !semanticHas(v, CodeInputSchema, "/inputs") { + t.Fatalf("nested compiler diagnostic lost: %+v", v.result.Diagnostics) + } + sentinel := errors.New("regex failure") + v = contractValidation(nil) + reportInputCompile(v, &inputRegexpState{v: v, err: sentinel}, errors.New("compile failure"), "/inputs") + var tool *Error + if !errors.As(v.err, &tool) || tool.Kind != ErrorOperational || !errors.Is(v.err, sentinel) || tool.Location.Pointer != "/inputs" { + t.Fatalf("regex failure lost: %v", v.err) + } + v = contractValidation(nil) + v.err = sentinel + reportInputCompile(v, &inputRegexpState{v: v}, errors.New("later failure"), "/inputs") + if !errors.Is(v.err, sentinel) || len(v.result.Diagnostics) != 0 { + t.Fatalf("first failure replaced: %v %+v", v.err, v.result.Diagnostics) + } +} + +func TestContractInputResourceAndPatternContracts(t *testing.T) { + unavailable := &inputResourceUnavailable{uri: "https://example.com/missing"} + if got := unavailable.Error(); got != "input schema resource is not supplied: https://example.com/missing" { + t.Fatal(got) + } + v := contractValidation(nil) + state := &inputRegexpState{v: v} + v.opts.limits.MaxPatternBytes = 3 + if _, err := state.compile("abcd"); err == nil { + t.Fatal("oversize pattern compiled") + } + var tool *Error + if !errors.As(v.err, &tool) || tool.Kind != ErrorLimit { + t.Fatalf("limit classification: %v", v.err) + } + v = contractValidation(nil) + state = &inputRegexpState{v: v} + if _, err := state.compile("["); err == nil { + t.Fatal("bad ECMAScript pattern compiled") + } + re, err := state.compile("^abc$") + if err != nil { + t.Fatal(err) + } + if re.String() != "^abc$" || !re.MatchString("abc") || re.MatchString("abcd") { + t.Fatalf("pattern contract broken: %s", re.String()) + } + ctx, cancel := context.WithCancel(context.Background()) + v.ctx = ctx + cancel() + if re.MatchString("abc") || !errors.Is(state.err, context.Canceled) { + t.Fatalf("canceled regex returned match: %v", state.err) + } + limits := DefaultLimits() + limits.MaxPatternBytes = 3 + doc := strings.Replace(expressionFixture, " steps:\n", " inputs:\n patternProperties:\n abcd: {type: string}\n steps:\n", 1) + result, err := ValidateBytes(context.Background(), []byte(doc), "schema.yaml", WithLimits(limits)) + if !errors.As(err, &tool) || tool.Kind != ErrorLimit || tool.Location.Pointer != "/workflows/0/inputs/patternProperties/abcd" || result.Complete { + t.Fatalf("property-pattern limit lost: %+v %v", result, err) + } +} + +func TestContractInputSchemaResourceBases(t *testing.T) { + for _, tc := range []struct{ uri, base, self, version, want string }{ + {"workflow.yaml", "https://example.com/root/", "", "1.0", "https://example.com/root/workflow.yaml"}, + {"https://example.com/workflow.yaml#old", "", "schemas/self.json", "1.1", "https://example.com/schemas/self.json"}, + {"local.yaml", "", "", "1.0", "https://arazzo.invalid/document"}, + {"%", "", "", "1.0", "https://arazzo.invalid/document"}, + } { + v := contractValidation(map[string]any{"$self": tc.self}) + v.doc.URI = tc.uri + v.opts.baseURI = tc.base + v.version = tc.version + if got := inputSchemaBase(v); got != tc.want { + t.Fatalf("base=%q want %q", got, tc.want) + } + } + if got := inputReferenceLocation("%", "https://example.com/base", inputSchemaIndex{}); got != "%" { + t.Fatalf("malformed URI rewritten: %q", got) + } + for _, tc := range []struct { + schema any + want string + }{ + {map[string]any{"oneOf": []any{map[string]any{"type": "string"}, map[string]any{"$ref": "#absent"}}}, "/inputs/oneOf/1/$ref"}, + {map[string]any{"items": map[string]any{"not": map[string]any{"$ref": "#absent"}}}, "/inputs/items/not/$ref"}, + {map[string]any{"default": map[string]any{"$ref": "#data"}}, "/inputs"}, + } { + if got := schemaReferencePath(tc.schema, "/inputs"); got != tc.want { + t.Fatalf("schema reference path=%q want %q", got, tc.want) + } + } +} + +func TestContractSemanticTargetsAndContext(t *testing.T) { + v := semanticUnit(t, `{"sourceDescriptions":[{"name":"api"},{"name":"api"}],"workflows":[{"workflowId":"run","steps":[{"stepId":"first","workflowId":"missing","parameters":[{"reference":"$components.parameters.absent"}]}]},{"workflowId":"run","steps":[]}]}`, "1.1") + for _, tc := range []struct { + code Code + path string + }{{CodeDuplicateID, "/sourceDescriptions/1/name"}, {CodeDuplicateID, "/workflows/1/workflowId"}, {CodeReference, "/workflows/0/steps/0/workflowId"}, {CodeReference, "/workflows/0/steps/0/parameters/0/reference"}} { + if !semanticHas(v, tc.code, tc.path) { + t.Fatalf("missing %s %s: %+v", tc.code, tc.path, v.result.Diagnostics) + } + } + v = semanticUnit(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","parameters":[{"name":"raw","in":"querystring","value":42},{"name":"id","in":"query","value":1}],"onSuccess":[{"name":"bad","type":"goto","stepId":"first","workflowId":"run","parameters":[{"name":"id","in":"query","value":1}]},{"name":"no-target","type":"end","parameters":[]}]}]}]}`, "1.1") + for _, tc := range []struct { + code Code + path string + }{{CodeParameter, "/workflows/0/steps/0/parameters/0/value"}, {CodeParameter, "/workflows/0/steps/0/parameters/0/in"}, {CodeAction, "/workflows/0/steps/0/onSuccess/0"}, {CodeParameter, "/workflows/0/steps/0/onSuccess/0/parameters/0/in"}, {CodeAction, "/workflows/0/steps/0/onSuccess/1/parameters"}} { + if !semanticHas(v, tc.code, tc.path) { + t.Fatalf("missing %s %s: %+v", tc.code, tc.path, v.result.Diagnostics) + } + } + for _, ref := range []string{"$workflows.absent.steps.first", "$steps.first", "missing"} { + v = semanticUnit(t, fmt.Sprintf(`{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","dependsOn":[%q]}]}]}`, ref), "1.1") + if !semanticHas(v, CodeDependency, "/workflows/0/steps/0/dependsOn/0") { + t.Fatalf("bad dependency accepted %q: %+v", ref, v.result.Diagnostics) + } + } +} + +func TestContractRuntimeSymbolResolution(t *testing.T) { + for _, tc := range []struct { + value string + want bool + }{ + {"$steps.first.outputs.absent", true}, + {"$workflows.absent.outputs.id", true}, + {"$workflows.target.outputs.absent", true}, + {"$workflows.target.outputs.id", false}, + {"$workflows.target.inputs.absent", true}, + {"$outputs.absent", true}, + {"$inputs.absent", true}, + {"$components.parameters.absent", true}, + {"$components.successActions.absent", true}, + {"$components.failureActions.absent", true}, + {"$sourceDescriptions.absent.workflows.run", true}, + } { + v := semanticUnit(t, fmt.Sprintf(`{"sourceDescriptions":[{"name":"api"}],"workflows":[{"workflowId":"run","inputs":{"type":"object","additionalProperties":false},"steps":[{"stepId":"first","operationId":"read","outputs":{"id":"$statusCode"}},{"stepId":"second","operationId":"read","parameters":[{"name":"id","in":"query","value":%q}]}]},{"workflowId":"target","inputs":{"type":"object","additionalProperties":false},"outputs":{"id":"$steps.producer.outputs.id"},"steps":[{"stepId":"producer","operationId":"read","outputs":{"id":"$statusCode"}}]}]}`, tc.value), "1.1") + path := "/workflows/0/steps/1/parameters/0/value" + if got := semanticHas(v, CodeReference, path); got != tc.want { + t.Fatalf("reference %q finding=%v want %v: %+v", tc.value, got, tc.want, v.result.Diagnostics) + } + } + if !semanticOutputExists(map[string]any{"id": "$statusCode"}, "id.part", "1.0") || semanticOutputExists(map[string]any{"id": "$statusCode"}, "id.part", "1.1") { + t.Fatal("versioned output contract lost") + } + for _, schema := range []map[string]any{{"allOf": []any{}, "additionalProperties": false}, {"additionalProperties": true}, {"type": "object"}} { + if semanticInputAbsent(schema, "id") { + t.Fatalf("open/composed schema proves absent input: %+v", schema) + } + } +} + +func TestContractExpressionLiteralSelectorsAndContainers(t *testing.T) { + for _, value := range []string{ + `{"context":"$response.body","type":"jsonpath"}`, + `{"context":"$response.body","selector":"$.item","type":"unknown"}`, + `{"context":"$response.body","selector":"$.item","type":"jsonpath","literal":true}`, + } { + v := semanticUnit(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","parameters":[{"name":"data","in":"query","value":`+value+`}]}]}]}`, "1.1") + if len(v.result.Diagnostics) != 0 { + t.Fatalf("literal selector rejected: %+v", v.result.Diagnostics) + } + } + v := semanticUnit(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","correlationId":"$response.header.X-ID","requestBody":{"payload":["$steps.absent.outputs.id"]},"outputs":{"invalid":{"context":"$not-a-runtime-source","type":"jsonpointer","selector":"/id"}}}]}]}`, "1.1") + if !semanticHas(v, CodeReference, "/workflows/0/steps/0/requestBody/payload/0") || !semanticHas(v, CodeExpression, "/workflows/0/steps/0/outputs/invalid/context") { + t.Fatalf("container expressions unchecked: %+v", v.result.Diagnostics) + } +} + +func TestContractCriteriaEmbeddingAndReplacementCapabilities(t *testing.T) { + for _, tc := range []struct { + criterion string + code Code + path, check string + }{ + {`{"condition":"prefix {$invalid-source}","type":"regex","context":"$response.body"}`, CodeExpression, "/criteria/0/condition", ""}, + {`{"condition":"prefix {$statusCode}","type":"regex","context":"$response.body"}`, "", "", "criterion-syntax"}, + {`{"condition":".*","type":"regex","context":"$invalid-source"}`, CodeExpression, "/criteria/0/context", ""}, + {`{"condition":".*","type":"regex"}`, CodeExpression, "/criteria/0", ""}, + {`{"condition":"$.item","type":{"type":"jsonpath","version":"unsupported"},"context":"$response.body"}`, "", "", "selector-syntax"}, + } { + // semanticRun parses JSON and checks all applicable criterion scopes. + v := semanticUnit(t, `{"components":{"successActions":{"test":{"name":"test","type":"end","criteria":[`+tc.criterion+`]}}}}`, "1.1") + path := strings.Replace(tc.path, "/criteria", "/components/successActions/test/criteria", 1) + if tc.code != "" && !semanticHas(v, tc.code, path) { + t.Fatalf("criterion finding lost: %+v", v.result.Diagnostics) + } + if tc.check != "" { + found := false + for _, check := range v.result.Checks { + found = found || check.Name == tc.check && check.Status == CheckIncomplete + } + if !found { + t.Fatalf("capability gap lost: %+v", v.result.Checks) + } + } + } + for _, tc := range []struct{ media, want string }{{"application/xml", "selector-syntax"}, {"application/octet-stream", "replacement-target"}} { + v := semanticUnit(t, fmt.Sprintf(`{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","requestBody":{"contentType":%q,"replacements":[{"target":"/root/id","value":"literal"}]}}]}]}`, tc.media), "1.1") + found := false + for _, check := range v.result.Checks { + found = found || check.Name == tc.want && check.Status == CheckIncomplete + } + if !found { + t.Fatalf("replacement capability gap lost: %+v", v.result.Checks) + } + } +} + +func TestContractExpressionGrammarErrors(t *testing.T) { + for _, tc := range []struct{ input, want string }{ + {"(true", "unclosed condition grouping"}, + {"$request.body..id == 1", "invalid property dereference"}, + {"true[ ]", "index must be a nonnegative integer"}, + {"true[1", "unclosed index"}, + {"(true).", "missing property name"}, + {"true != 'unterminated", "unclosed string literal"}, + {`true == "double"`, "condition strings must use single quotes"}, + } { + v := contractValidation(nil) + p := simpleParser{input: tc.input, v: v, path: "/condition"} + err := p.parse() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("condition %q: %v want %q", tc.input, err, tc.want) + } + } + for _, tc := range []struct { + input string + valid bool + }{{"", true}, {"/id~0/~1", true}, {"id", false}, {string([]byte{0xff}), false}, {"/bad~", false}} { + if got := validatePointer(tc.input) == nil; got != tc.valid { + t.Fatalf("pointer %q valid=%v", tc.input, got) + } + } + for _, text := range []string{"prefix {$statusCode", "prefix {$invalid-source}", "{$statusCode}"} { + v := contractValidation(nil) + v.checkExpressionString(text, "/value", exprScope{}, false) + want := text != "{$statusCode}" + if got := semanticHas(v, CodeExpression, "/value"); got != want { + t.Fatalf("embedded %q finding=%v: %+v", text, got, v.result.Diagnostics) + } + } +} + +func TestContractExpressionResourceLimits(t *testing.T) { + for _, run := range []func(*validation){ + func(v *validation) { _ = v.recordExpression("$statusCode", "/expression", exprScope{}, false) }, + func(v *validation) { v.checkDialectSyntax("regex", "", "abcdef", "/pattern", CodeExpression) }, + func(v *validation) { + p := simpleParser{input: "true == false", v: v, path: "/condition"} + _ = p.parse() + }, + } { + v := contractValidation(nil) + v.opts.limits.MaxPatternBytes = 4 + run(v) + var tool *Error + if !errors.As(v.err, &tool) || tool.Kind != ErrorLimit { + t.Fatalf("pattern cap not applied: %v", v.err) + } + } + v := contractValidation(nil) + v.opts.limits.MaxDepth = 2 + p := simpleParser{input: "(((true)))", v: v, path: "/condition"} + if err := p.parse(); err == nil || v.err == nil { + t.Fatal("condition nesting unbounded") + } + v = contractValidation(nil) + v.opts.limits.MaxBytes = 2 + v.checkValue("literal", "/value", exprScope{}) + var tool *Error + if !errors.As(v.err, &tool) || tool.Kind != ErrorLimit || tool.Location.Pointer != "/value" { + t.Fatalf("Any string budget lost: %v", v.err) + } + v = contractValidation(nil) + v.opts.limits.MaxBytes = 2 + v.checkCriteria([]any{map[string]any{"condition": "true"}}, "/criteria", exprScope{}) + if !errors.As(v.err, &tool) || tool.Location.Pointer != "/criteria/0/condition" { + t.Fatalf("criterion budget lost: %v", v.err) + } +} + +func TestContractSemanticForwardOutputAdvisory(t *testing.T) { + data := strings.Replace(expressionFixture, " operationId: getPet\n", ` operationId: getPet + parameters: + - {name: id, in: query, value: $steps.second.outputs.id} + - stepId: second + operationId: getPet + outputs: {id: $statusCode} +`, 1) + r, err := ValidateBytes(context.Background(), []byte(data), "forward.yaml", WithAdvisories()) + if err != nil || !r.Valid() { + t.Fatalf("valid forward reference rejected: %+v %v", r, err) + } + found := false + for _, d := range r.Diagnostics { + if d.Code == CodeForwardReference { + found = true + if d.Severity != SeverityWarning || d.Pointer != "/workflows/0/steps/0/parameters/0/value" { + t.Fatalf("bad advisory: %+v", d) + } + } + } + if !found { + t.Fatalf("sequential ordering advice missing: %+v", r.Diagnostics) + } +} + +func TestContractSemanticResolutionBudgets(t *testing.T) { + for _, tc := range []struct { + name string + nodes int + run func(*validation) + path string + }{ + {"reference", 1, func(v *validation) { + _, _ = v.resolveReusable(map[string]any{"reference": "$components.parameters.id"}, "/use", "parameters") + }, "/use/reference"}, + {"clone", 4, func(v *validation) { + _, _ = v.resolveReusable(map[string]any{"reference": "$components.parameters.id"}, "/use", "parameters") + }, "/use"}, + {"action", 0, func(v *validation) { + v.checkAction(effectiveObject{value: map[string]any{"type": "end"}, path: "/action"}, nil) + }, "/action"}, + {"workflow input", 0, func(v *validation) { + v.checkWorkflowInputParameters([]effectiveObject{{value: map[string]any{"name": "id"}, path: "/parameter"}}, "run") + }, "/parameter"}, + } { + t.Run(tc.name, func(t *testing.T) { + v := contractValidation(nil) + v.local = &semanticIndex{components: map[string]map[string]any{"parameters": {"id": map[string]any{"name": "id", "in": "query", "value": true}}}, workflows: map[string]*semanticWorkflow{"run": {value: map[string]any{"inputs": map[string]any{"additionalProperties": false}}}}} + v.opts.limits.MaxNodes = tc.nodes + tc.run(v) + var tool *Error + if !errors.As(v.err, &tool) || tool.Kind != ErrorLimit || tool.Location.Pointer != tc.path { + t.Fatalf("budget %s lost: %v", tc.name, v.err) + } + }) + } +} + +func TestContractPassesStopOnCancellation(t *testing.T) { + for _, tc := range []struct { + name string + run func(*validation) + }{ + {"semantics", checkSemantics}, + {"expressions", checkExpressions}, + {"input schemas", checkInputs}, + {"input walk", func(v *validation) { + walkInputSchemas(v, map[string]any{"type": "string"}, "/inputs", func(map[string]any, string) { t.Fatal("canceled schema visited") }) + }}, + {"criteria", func(v *validation) { + v.checkCriteria([]any{map[string]any{"condition": "true"}}, "/criteria", exprScope{}) + }}, + {"reference resolution", func(v *validation) { + _, _ = v.resolveReusable(map[string]any{"reference": "$components.parameters.id"}, "/reference", "parameters") + }}, + } { + t.Run(tc.name, func(t *testing.T) { + v := contractValidation(map[string]any{"workflows": []any{map[string]any{"workflowId": "run", "inputs": map[string]any{"type": "string"}}}}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + v.ctx = ctx + tc.run(v) + if !errors.Is(v.err, context.Canceled) || len(v.result.Diagnostics) != 0 { + t.Fatalf("cancellation changed into findings: %v %+v", v.err, v.result.Diagnostics) + } + }) + } +} + +func TestContractInputResourceCollision(t *testing.T) { + r, err := ValidateBytes(context.Background(), []byte(strings.Replace(expressionFixture, " steps:\n", " inputs: {type: string}\n steps:\n", 1)), "https://json-schema.org/draft/2020-12/schema") + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorOperational || r.Complete || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("resource collision hidden: %+v %v", r, err) + } +} + +func TestContractInputRegexTimeout(t *testing.T) { + v := contractValidation(nil) + state := &inputRegexpState{v: v} + compiled, err := state.compile("^(a+)+$") + if err != nil { + t.Fatal(err) + } + re := compiled.(*inputRegexp) + // Negative MatchTimeout is already expired. This exercises propagation + // without depending on scheduler speed or waiting for catastrophic backtracking. + re.re.MatchTimeout = -1 + if re.MatchString(strings.Repeat("a", 64)+"!") || state.err == nil || !strings.Contains(state.err.Error(), "match timeout") { + t.Fatalf("timeout became mismatch without cause: %v", state.err) + } +} + +func TestContractPassStructuralOwnership(t *testing.T) { + v := contractValidation(nil) + v.checkCriteria([]any{map[string]any{"condition": 42}}, "/criteria", exprScope{}) + v.checkDialectSyntax("unknown", "", "literal", "/selector", CodeSelector) + v.checkRequestBody(map[string]any{"replacements": []any{map[string]any{"target": 42, "value": "literal"}}}, "/requestBody", exprScope{}) + if v.err != nil || len(v.result.Diagnostics) != 0 || len(v.result.Checks) != 0 { + t.Fatalf("semantic pass duplicated structural failures: %+v %+v %v", v.result.Diagnostics, v.result.Checks, v.err) + } + v.checkExpressionString("$response.body.id[0]", "/value", exprScope{}, true) + if !semanticHas(v, CodeExpression, "/value") { + t.Fatalf("invalid runtime dereference accepted: %+v", v.result.Diagnostics) + } +} + +func TestContractSemanticComponentScopeAndGraphCancellation(t *testing.T) { + v := semanticUnit(t, `{"components":{"parameters":{"id":{"name":"id","in":"query","value":"$steps.first.outputs.id"}}}}`, "1.1") + if len(v.result.Diagnostics) != 0 { + t.Fatalf("unconsumed component acquired workflow scope: %+v", v.result.Diagnostics) + } + finalizeExpressionUses(contractValidation(nil)) // A missing semantic index has no symbols to resolve. + v = contractValidation(nil) + checkDependencyGraph(v) + if v.err != nil { + t.Fatalf("absent dependency graph failed: %v", v.err) + } + v.graph = newDependencyGraph() + v.graph.addNode(dependencyID{document: v.doc.URI, workflow: "run"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + v.ctx = ctx + checkDependencyGraph(v) + if !errors.Is(v.err, context.Canceled) { + t.Fatalf("graph cancellation lost: %v", v.err) + } + v = contractValidation(map[string]any{"components": map[string]any{"successActions": map[string]any{"done": map[string]any{"name": "done", "type": "end"}}}, "workflows": []any{map[string]any{"workflowId": "run"}}}) + v.opts.limits.MaxNodes = 0 + checkSemantics(v) + var tool *Error + if !errors.As(v.err, &tool) || tool.Kind != ErrorLimit || len(v.graph.order) != 0 { + t.Fatalf("semantics continued after component work cap: %v %+v", v.err, v.graph.order) + } +} + +func TestContractFinalizationCancellation(t *testing.T) { + v := semanticUnit(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","outputs":{"id":"$statusCode"}}]}]}`, "1.1") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + v.ctx = ctx + finalizeExpressionUses(v) + if !errors.Is(v.err, context.Canceled) { + t.Fatalf("canceled symbols finalized: %v", v.err) + } + w := &semanticWorkflow{steps: map[string]*semanticStep{}} + if semanticPreExecution(w, expressionUse{StepID: "absent", Path: "/workflows/0/steps/0/parameters/0/value"}) { + t.Fatal("unknown step classified as pre-execution") + } +} + +// contractScheduledCancel lets the caller cancel at each observed validation +// checkpoint. It uses the standard cancellation context to retain Err semantics. +type contractScheduledCancel struct { + context.Context + cancel context.CancelFunc + remaining int +} + +func (c *contractScheduledCancel) Err() error { + if c.remaining > 0 { + c.remaining-- + if c.remaining == 0 { + c.cancel() + } + } + return c.Context.Err() +} + +func TestContractInputCancellationCheckpoints(t *testing.T) { + reachedEnd := false + for checkpoint := 1; checkpoint <= 200; checkpoint++ { + ctx, cancel := context.WithCancel(context.Background()) + scheduled := &contractScheduledCancel{Context: ctx, cancel: cancel, remaining: checkpoint} + v := contractValidation(map[string]any{"workflows": []any{ + map[string]any{"inputs": map[string]any{"$anchor": "first", "$ref": "https://example.com/external.json", "type": "object", "properties": map[string]any{"id": map[string]any{"$ref": "#absent"}}}}, + map[string]any{"inputs": map[string]any{"$anchor": "second", "type": "string"}}, + }}) + v.ctx = scheduled + checkInputs(v) + wasCanceled := ctx.Err() != nil + cancel() + if !wasCanceled { + reachedEnd = true + break + } + if !errors.Is(v.err, context.Canceled) { + t.Fatalf("checkpoint %d lost cancellation: %v", checkpoint, v.err) + } + } + if !reachedEnd { + t.Fatal("input cancellation sweep did not reach normal completion") + } +} + +func TestContractReusableCancellationCheckpoints(t *testing.T) { + reachedEnd := false + for checkpoint := 1; checkpoint <= 40; checkpoint++ { + ctx, cancel := context.WithCancel(context.Background()) + scheduled := &contractScheduledCancel{Context: ctx, cancel: cancel, remaining: checkpoint} + v := contractValidation(nil) + v.ctx = scheduled + v.local = &semanticIndex{components: map[string]map[string]any{"parameters": { + "outer": map[string]any{"reference": "$components.parameters.inner", "value": false}, + "inner": map[string]any{"name": "id", "in": "query", "value": true}, + }}} + items := v.resolveList([]any{map[string]any{"reference": "$components.parameters.outer"}}, "/parameters", "parameters") + wasCanceled := ctx.Err() != nil + cancel() + if !wasCanceled { + reachedEnd = true + if len(items) != 1 || items[0].value["value"] != false || items[0].definition != "/components/parameters/inner" { + t.Fatalf("completed reusable resolution lost override: %+v", items) + } + break + } + if !errors.Is(v.err, context.Canceled) || len(items) != 0 { + t.Fatalf("checkpoint %d lost cancellation/returned partial item: %v %+v", checkpoint, v.err, items) + } + } + if !reachedEnd { + t.Fatal("reusable cancellation sweep did not reach normal completion") + } +} diff --git a/arazzo/semantics.go b/arazzo/semantics.go new file mode 100644 index 0000000..43a87fe --- /dev/null +++ b/arazzo/semantics.go @@ -0,0 +1,617 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "fmt" + "sort" + "strings" + + "github.com/pb33f/libopenapi/arazzo/expression" +) + +// CodeAction identifies invalid contextual action targets or action fields. +const CodeAction Code = "arazzo-action" + +// CodeForwardReference identifies optional guidance for sequential workflows. +const CodeForwardReference Code = "arazzo-forward-reference" + +type semanticStep struct { + id string + value map[string]any + path string + position int +} + +type semanticWorkflow struct { + id string + value map[string]any + path string + steps map[string]*semanticStep + order []*semanticStep + hasStepDependencies bool +} + +type semanticIndex struct { + workflows map[string]*semanticWorkflow + order []*semanticWorkflow + components map[string]map[string]any + sources map[string]string +} + +// effectiveObject retains the consuming slot independently from its component +// definition. Overrides copy only the object envelope, leaving values immutable. +type effectiveObject struct { + value map[string]any + path string + definition string + authoredValue bool +} + +func checkSemantics(v *validation) { + v.local = &semanticIndex{workflows: make(map[string]*semanticWorkflow), components: make(map[string]map[string]any)} + v.graph = newDependencyGraph() + v.graph.run = v + sources := make(map[string]string) + v.local.sources = sources + for i, value := range array(v.root["sourceDescriptions"]) { + m := object(value) + path := fmt.Sprintf("/sourceDescriptions/%d/name", i) + id := text(m["name"]) + if previous, ok := sources[id]; ok { + v.semanticDuplicate(CodeDuplicateID, path, "source description name is not unique", previous) + } else { + sources[id] = path + } + } + for i, value := range array(v.root["workflows"]) { + if !v.check() { + return + } + m := object(value) + w := &semanticWorkflow{id: text(m["workflowId"]), value: m, path: fmt.Sprintf("/workflows/%d", i), steps: make(map[string]*semanticStep)} + if previous, ok := v.local.workflows[w.id]; ok { + v.semanticDuplicate(CodeDuplicateID, w.path+"/workflowId", "workflowId is not unique", previous.path+"/workflowId") + } else { + v.local.workflows[w.id] = w + } + v.local.order = append(v.local.order, w) + for j, value := range array(m["steps"]) { + sm := object(value) + s := &semanticStep{id: text(sm["stepId"]), value: sm, path: fmt.Sprintf("%s/steps/%d", w.path, j), position: j} + if previous, ok := w.steps[s.id]; ok { + v.semanticDuplicate(CodeDuplicateID, s.path+"/stepId", "stepId is not unique within its workflow", previous.path+"/stepId") + } else { + w.steps[s.id] = s + } + w.order = append(w.order, s) + w.hasStepDependencies = w.hasStepDependencies || len(array(sm["dependsOn"])) != 0 + } + } + components := object(v.root["components"]) + for _, kind := range []string{"parameters", "successActions", "failureActions"} { + v.local.components[kind] = object(components[kind]) + } + // Every component body is checked, including definitions with no uses. + for _, kind := range []string{"successActions", "failureActions"} { + for _, key := range sortedKeys(v.local.components[kind]) { + item := effectiveObject{value: object(v.local.components[kind][key]), path: joinPtr("/components/"+kind, key)} + v.checkAction(item, nil) + } + } + for _, w := range v.local.order { + if !v.check() { + return + } + wid := dependencyID{document: v.documentID(), workflow: w.id} + v.graph.addNode(wid) + start := dependencyID{document: v.documentID(), workflow: w.id, prerequisites: true} + for _, s := range w.order { + step := dependencyID{document: v.documentID(), workflow: w.id, step: s.id} + v.graph.addEdge(wid, step, "") + if len(array(w.value["dependsOn"])) > 0 { + v.graph.addEdge(step, start, "") + } + } + for i, raw := range array(w.value["dependsOn"]) { + path := fmt.Sprintf("%s/dependsOn/%d", w.path, i) + target := text(raw) + if strings.HasPrefix(target, "$sourceDescriptions.") { + continue + } + if v.checkLocalWorkflow(target, path) { + to := dependencyID{document: v.documentID(), workflow: target} + v.graph.addEdge(start, to, path) + } + } + defaults := v.resolveList(w.value["parameters"], w.path+"/parameters", "parameters") + success := v.resolveList(w.value["successActions"], w.path+"/successActions", "successActions") + failure := v.resolveList(w.value["failureActions"], w.path+"/failureActions", "failureActions") + v.uniqueParameters(defaults) + v.uniqueActions(success) + v.uniqueActions(failure) + for _, group := range [][]effectiveObject{success, failure} { + for _, action := range group { + v.checkAction(action, w) + } + } + for _, s := range w.order { + if !v.check() { + return + } + if target, ok := s.value["workflowId"]; ok { + v.checkLocalWorkflow(text(target), s.path+"/workflowId") + } + params := v.resolveList(s.value["parameters"], s.path+"/parameters", "parameters") + v.uniqueParameters(params) + effective := v.mergeEffective(defaults, params, parameterIdentity, s.path+"/parameters") + _, workflowCall := s.value["workflowId"] + v.checkParameterContext(effective, workflowCall, false) + if workflowCall { + v.checkWorkflowInputParameters(effective, text(s.value["workflowId"])) + } + for _, p := range effective { + // The expression pass owns authored inline values. Only inherited + // or reusable values need an additional check in the consuming scope. + if p.definition != "" && !p.authoredValue || !strings.HasPrefix(p.path, s.path+"/parameters/") { + v.checkParameterExpressions(p.value, p.path, exprScope{workflowID: w.id, stepID: s.id}) + } + } + for _, group := range []struct { + field, kind string + defaults []effectiveObject + }{{"onSuccess", "successActions", success}, {"onFailure", "failureActions", failure}} { + inline := v.resolveList(s.value[group.field], s.path+"/"+group.field, group.kind) + v.uniqueActions(inline) + for _, action := range v.mergeEffective(group.defaults, inline, actionIdentity, s.path+"/"+group.field) { + v.checkAction(action, w) + scope := exprScope{workflowID: w.id, stepID: s.id} + inherited := action.definition != "" || !strings.HasPrefix(action.path, s.path+"/"+group.field+"/") + if inherited { + v.checkCriteria(action.value["criteria"], action.path+"/criteria", scope) + } + for _, p := range v.resolveList(action.value["parameters"], action.path+"/parameters", "parameters") { + if inherited || p.definition != "" && !p.authoredValue { + v.checkParameterExpressions(p.value, p.path, scope) + } + } + } + } + if v.version == "1.1" { + for i, raw := range array(s.value["dependsOn"]) { + path := fmt.Sprintf("%s/dependsOn/%d", s.path, i) + if target, ok := v.localStepDependency(w, text(raw), path); ok { + v.graph.addEdge(dependencyID{document: v.documentID(), workflow: w.id, step: s.id}, target, path) + } + } + } + } + } +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func (v *validation) checkLocalWorkflow(id, path string) bool { + if strings.HasPrefix(id, "$sourceDescriptions.") { + return false + } + if _, ok := v.local.workflows[id]; !ok { + v.add(CodeReference, path, fmt.Sprintf("workflow %q does not exist in this document", id)) + return false + } + return true +} + +// Reusable references select an exact collection and key. The remaining suffix +// is a key, rather than dot-separated path segments. +func (v *validation) resolveList(raw any, path, kind string) []effectiveObject { + items := array(raw) + if !v.work(len(items), path) { + return nil + } + result := make([]effectiveObject, 0, len(items)) + for i, item := range items { + if !v.check() { + return result + } + p := fmt.Sprintf("%s/%d", path, i) + m := object(item) + if _, ok := m["reference"]; !ok { + result = append(result, effectiveObject{value: m, path: p}) + continue + } + resolved, ok := v.resolveReusable(m, p, kind) + if ok { + result = append(result, resolved) + } + } + return result +} + +func (v *validation) resolveReusable(m map[string]any, path, kind string) (effectiveObject, bool) { + _, authoredValue := m["value"] + if !v.work(1, path+"/reference") { + return effectiveObject{}, false + } + seen := make(map[string]bool) + var overlays []map[string]any + definition := "" + for { + if !v.check() { + return effectiveObject{}, false + } + ref, present := m["reference"] + if !present { + break + } + if !v.work(1, path+"/reference") { + return effectiveObject{}, false + } + name := text(ref) + prefix := "$components." + kind + "." + if !strings.HasPrefix(name, prefix) || len(name) == len(prefix) { + v.add(CodeReference, path+"/reference", "reusable reference must select components/"+kind) + return effectiveObject{}, false + } + if seen[name] { + v.add(CodeReference, path+"/reference", "reusable reference contains a cycle") + return effectiveObject{}, false + } + seen[name] = true + key := strings.TrimPrefix(name, prefix) + next, exists := v.local.components[kind][key] + if !exists { + v.add(CodeReference, path+"/reference", fmt.Sprintf("component %q does not exist in %s", key, kind)) + return effectiveObject{}, false + } + overlays = append(overlays, m) + m = object(next) + definition = joinPtr("/components/"+kind, key) + } + if kind == "parameters" { + if !v.work(len(m), path) { + return effectiveObject{}, false + } + clone := make(map[string]any, len(m)) + for k, value := range m { + clone[k] = value + } + for i := len(overlays) - 1; i >= 0; i-- { + if value, ok := overlays[i]["value"]; ok { + clone["value"] = value + } + } + m = clone + } + return effectiveObject{value: m, path: path, definition: definition, authoredValue: authoredValue}, true +} + +type parameterKey struct{ name, location string } + +func parameterIdentity(item effectiveObject) any { + return parameterKey{text(item.value["name"]), text(item.value["in"])} +} +func actionIdentity(item effectiveObject) any { return text(item.value["name"]) } + +func (v *validation) mergeEffective(defaults, overrides []effectiveObject, key func(effectiveObject) any, path string) []effectiveObject { + if !v.work(len(defaults), path) || !v.work(len(overrides), path) { + return nil + } + count := len(defaults) + len(overrides) + items := make([]effectiveObject, len(defaults), count) + copy(items, defaults) + positions := make(map[any]int, count) + for i, item := range items { + positions[key(item)] = i + } + for _, item := range overrides { + id := key(item) + if i, ok := positions[id]; ok { + items[i] = item + } else { + positions[id] = len(items) + items = append(items, item) + } + } + return items +} + +func (v *validation) uniqueParameters(items []effectiveObject) { + seen := make(map[any]string, len(items)) + for _, item := range items { + id := parameterIdentity(item) + if previous, ok := seen[id]; ok { + v.semanticDuplicate(CodeParameter, item.path, "parameter name and location are not unique", previous) + } + seen[id] = item.path + } +} + +func (v *validation) uniqueActions(items []effectiveObject) { + seen := make(map[any]string, len(items)) + for _, item := range items { + id := actionIdentity(item) + if previous, ok := seen[id]; ok { + v.semanticDuplicate(CodeDuplicateID, item.path, "effective action name is not unique", previous) + } + seen[id] = item.path + } +} + +func (v *validation) semanticDuplicate(code Code, path, message, previous string) { + before := len(v.result.Diagnostics) + v.add(code, path, message) + if len(v.result.Diagnostics) > before { + v.result.Diagnostics[len(v.result.Diagnostics)-1].Related = []Location{v.location(previous)} + } +} + +func (v *validation) checkParameterContext(items []effectiveObject, workflow, action bool) { + query, querystring := false, false + for _, item := range items { + location, exists := item.value["in"] + if action && exists { + v.add(CodeParameter, item.path+"/in", "action parameters must not specify a location") + } + if !workflow && !action && !exists { + v.add(CodeParameter, item.path, "operation parameters must specify in") + } + query = query || text(location) == "query" + querystring = querystring || text(location) == "querystring" + if !workflow && !action && text(location) == "querystring" { + if _, ok := item.value["value"].(string); !ok && !selectorValue(object(item.value["value"])) { + v.add(CodeParameter, item.path+"/value", "querystring parameter value must be a string or dynamic selector") + } + } + } + if !workflow && query && querystring { + for _, item := range items { + if text(item.value["in"]) == "querystring" { + v.add(CodeParameter, item.path+"/in", "querystring and query parameters cannot coexist") + } + } + } +} + +func (v *validation) checkAction(item effectiveObject, w *semanticWorkflow) { + if !v.work(1, item.path) { + return + } + m := item.value + _, workflowTarget := m["workflowId"] + _, stepTarget := m["stepId"] + if workflowTarget && stepTarget { + v.add(CodeAction, item.path, "action workflowId and stepId are mutually exclusive") + } + if id, ok := m["workflowId"]; ok { + v.checkLocalWorkflow(text(id), item.path+"/workflowId") + } + if id, ok := m["stepId"]; ok && w != nil { + if _, exists := w.steps[text(id)]; !exists { + v.add(CodeReference, item.path+"/stepId", fmt.Sprintf("step %q does not exist in the consuming workflow", text(id))) + } + } + if _, ok := m["parameters"]; ok { + if _, exists := m["workflowId"]; !exists { + v.add(CodeAction, item.path+"/parameters", "action parameters require a workflowId target") + } + params := v.resolveList(m["parameters"], item.path+"/parameters", "parameters") + v.checkWorkflowInputParameters(params, text(m["workflowId"])) + v.uniqueParameters(params) + v.checkParameterContext(params, true, true) + } +} + +func (v *validation) localStepDependency(w *semanticWorkflow, ref, path string) (dependencyID, bool) { + if strings.HasPrefix(ref, "$sourceDescriptions.") { + return dependencyID{}, false + } + target := w + step := ref + if strings.HasPrefix(ref, "$workflows.") { + suffix := strings.TrimPrefix(ref, "$workflows.") + var matches int + for offset := 0; offset < len(suffix); { + position := strings.Index(suffix[offset:], ".steps.") + if position < 0 { + break + } + position += offset + candidate := v.local.workflows[suffix[:position]] + id := suffix[position+len(".steps."):] + if candidate != nil && candidate.steps[id] != nil { + target = candidate + step = id + matches++ + } + offset = position + len(".steps.") + } + if matches != 1 { + v.add(CodeDependency, path, "step dependency must resolve to exactly one workflow step") + return dependencyID{}, false + } + } else if strings.HasPrefix(ref, "$") { + v.add(CodeDependency, path, "step dependency must use a stepId or a workflow step reference") + return dependencyID{}, false + } + if _, ok := target.steps[step]; !ok { + v.add(CodeDependency, path, fmt.Sprintf("prerequisite step %q does not exist", step)) + return dependencyID{}, false + } + return dependencyID{document: v.documentID(), workflow: target.id, step: step}, true +} + +func checkDependencyGraph(v *validation) { + if v.graph == nil || v.err != nil { + return + } + if err := v.graph.check(v.ctx, func(path, message string) { v.add(CodeDependency, path, message) }); err != nil { + v.err = err + } +} + +// finalizeExpressionUses resolves only statically declared symbols. Request, +// response and message properties remain runtime data. Open input schemas can +// provide undeclared properties, so they do not prove a missing input. +func finalizeExpressionUses(v *validation) { + if v.local == nil { + return + } + for _, use := range v.expressionUses { + if !v.check() { + return + } + e := use.Expression + w := v.local.workflows[use.WorkflowID] + switch e.Type { + case expression.Steps: + if w == nil { + continue + } + id, tail := semanticReferenceParts(strings.TrimPrefix(e.Raw, "$steps."), func(id string) bool { return w.steps[id] != nil }) + s := w.steps[id] + if s == nil { + v.add(CodeReference, use.Path, "runtime expression references an unknown step") + continue + } + if strings.HasPrefix(tail, "outputs.") { + if !semanticOutputExists(object(s.value["outputs"]), strings.TrimPrefix(tail, "outputs."), v.version) { + v.add(CodeReference, use.Path, "runtime expression references an unknown step output") + continue + } + if v.version == "1.1" && use.StepID != "" && (use.StepID != s.id || semanticPreExecution(w, use)) { + v.graph.addEdge(dependencyID{document: v.documentID(), workflow: w.id, step: use.StepID}, dependencyID{document: v.documentID(), workflow: w.id, step: s.id}, use.Path) + if v.opts.advisory && semanticPreExecution(w, use) && s.position > w.steps[use.StepID].position && !w.hasStepDependencies { + v.emit(Diagnostic{Code: CodeForwardReference, Severity: SeverityWarning, Message: "a sequential step references a later step's output; declare prerequisite dependencies or place the producing step first", Location: v.location(use.Path)}) + } + } + } + case expression.Workflows: + id, tail := semanticReferenceParts(strings.TrimPrefix(e.Raw, "$workflows."), func(id string) bool { return v.local.workflows[id] != nil }) + target := v.local.workflows[id] + if target == nil { + v.add(CodeReference, use.Path, "runtime expression references an unknown workflow") + continue + } + if strings.HasPrefix(tail, "outputs.") { + if !semanticOutputExists(object(target.value["outputs"]), strings.TrimPrefix(tail, "outputs."), v.version) { + v.add(CodeReference, use.Path, "runtime expression references an unknown workflow output") + continue + } + if v.version == "1.1" && use.StepID != "" { + v.graph.addEdge(dependencyID{document: v.documentID(), workflow: use.WorkflowID, step: use.StepID}, dependencyID{document: v.documentID(), workflow: target.id}, use.Path) + } + } else if strings.HasPrefix(tail, "inputs.") && semanticInputAbsent(object(target.value["inputs"]), strings.TrimPrefix(tail, "inputs.")) { + v.add(CodeReference, use.Path, "runtime expression references an input excluded by the workflow schema") + } + case expression.Outputs: + if w != nil && !semanticOutputExists(object(w.value["outputs"]), strings.TrimPrefix(e.Raw, "$outputs."), v.version) { + v.add(CodeReference, use.Path, "runtime expression references an unknown workflow output") + } + case expression.Inputs: + if w != nil && semanticInputAbsent(object(w.value["inputs"]), e.Name) { + v.add(CodeReference, use.Path, "runtime expression references an input excluded by the workflow schema") + } + case expression.ComponentParameters, expression.ComponentSuccessActions, expression.ComponentFailureActions: + kind := "parameters" + if e.Type == expression.ComponentSuccessActions { + kind = "successActions" + } + if e.Type == expression.ComponentFailureActions { + kind = "failureActions" + } + key := strings.TrimPrefix(e.Raw, "$components."+kind+".") + if _, ok := v.local.components[kind][key]; !ok { + v.add(CodeReference, use.Path, "runtime expression references an unknown component") + } + case expression.SourceDescriptions: + // Source-qualified members are checked by the source pass. Bare + // source references still have to name a declared source. + id, _ := semanticReferenceParts(strings.TrimPrefix(e.Raw, "$sourceDescriptions."), func(id string) bool { _, ok := v.local.sources[id]; return ok }) + found := id != "" + if !found { + v.add(CodeReference, use.Path, "runtime expression references an unknown source description") + } + } + } +} + +func semanticReferenceParts(raw string, exists func(string) bool) (string, string) { + for candidate := raw; candidate != ""; { + if exists(candidate) { + return candidate, strings.TrimPrefix(strings.TrimPrefix(raw, candidate), ".") + } + dot := strings.LastIndexByte(candidate, '.') + if dot < 0 { + break + } + candidate = candidate[:dot] + } + return "", "" +} + +func semanticPreExecution(w *semanticWorkflow, use expressionUse) bool { + s := w.steps[use.StepID] + if s == nil { + return false + } + for _, prefix := range []string{s.path + "/parameters/", s.path + "/requestBody/", s.path + "/correlationId", w.path + "/parameters/"} { + if strings.HasPrefix(use.Path, prefix) { + return true + } + } + return false +} + +func semanticOutputExists(outputs map[string]any, name, version string) bool { + name, _, _ = strings.Cut(name, "#") + if _, ok := outputs[name]; ok { + return true + } + if version == "1.0" { + id, _ := semanticReferenceParts(name, func(key string) bool { _, ok := outputs[key]; return ok }) + return id != "" + } + return false +} + +func semanticInputAbsent(schema map[string]any, name string) bool { + if schema == nil { + return false + } + for _, key := range []string{"$ref", "$dynamicRef", "allOf", "anyOf", "oneOf", "if", "then", "else", "patternProperties", "unevaluatedProperties"} { + if _, ok := schema[key]; ok { + return false + } + } + closed, ok := schema["additionalProperties"].(bool) + if !ok || closed { + return false + } + name, _, _ = strings.Cut(name, "#") + if _, ok := object(schema["properties"])[name]; ok { + return false + } + return true +} + +func (v *validation) checkWorkflowInputParameters(parameters []effectiveObject, targetID string) { + if target := v.local.workflows[targetID]; target != nil { + for _, parameter := range parameters { + if !v.work(1, parameter.path) { + return + } + if semanticInputAbsent(object(target.value["inputs"]), text(parameter.value["name"])) { + v.add(CodeParameter, parameter.path+"/name", "parameter is excluded by the target workflow input schema") + } + } + } +} diff --git a/arazzo/semantics_test.go b/arazzo/semantics_test.go new file mode 100644 index 0000000..0b38371 --- /dev/null +++ b/arazzo/semantics_test.go @@ -0,0 +1,351 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/pb33f/go-yaml" +) + +func semanticUnit(t *testing.T, body string, version string) *validation { + t.Helper() + var root map[string]any + if err := json.Unmarshal([]byte(body), &root); err != nil { + t.Fatal(err) + } + v := &validation{ctx: context.Background(), doc: Document{URI: "memory:local"}, opts: options{limits: DefaultLimits()}, root: root, nodes: map[string]nodeLocation{}, result: &Result{}, version: version} + checkSemantics(v) + checkExpressions(v) + finalizeExpressionUses(v) + checkDependencyGraph(v) + if v.err != nil { + t.Fatal(v.err) + } + return v +} + +// semanticRun supplies only the required document envelope, then runs Validate. +// Individual fixtures must contain valid workflow, step, component and action shapes. +func semanticRun(t *testing.T, body, version string) *Result { + t.Helper() + var root map[string]any + if err := json.Unmarshal([]byte(body), &root); err != nil { + t.Fatal(err) + } + root["arazzo"] = version + ".0" + root["info"] = map[string]any{"title": "Semantic fixture", "version": "1"} + if _, ok := root["sourceDescriptions"]; !ok { + root["sourceDescriptions"] = []any{map[string]any{"name": "api", "url": "api.yaml", "type": "openapi"}} + } + // Source URLs differ so duplicate source names test semantic identity rather + // than the schema's uniqueItems rule. Authored fields are preserved. + for i, raw := range array(root["sourceDescriptions"]) { + source := object(raw) + if _, ok := source["type"]; !ok { + source["type"] = "openapi" + } + if _, ok := source["url"]; !ok { + source["url"] = fmt.Sprintf("source-%d.yaml", i) + } + } + if _, ok := root["workflows"]; !ok { + root["workflows"] = []any{map[string]any{"workflowId": "run", "steps": []any{map[string]any{"stepId": "first", "operationId": "read"}}}} + } + data, err := json.Marshal(root) + if err != nil { + t.Fatal(err) + } + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + t.Fatal(err) + } + result, err := Validate(context.Background(), Document{Root: &node, URI: "memory:local"}) + if err != nil { + t.Fatal(err) + } + for _, diagnostic := range result.Diagnostics { + if diagnostic.Code == CodeStructure { + t.Fatalf("semantic fixture failed structural validation: %+v", result.Diagnostics) + } + } + return result +} + +func TestSemanticPublicWorkflowInputParameters(t *testing.T) { + base := foundationYAML("1.1.0") + " - workflowId: target\n inputs: {type: object, properties: {id: {type: string}}, additionalProperties: false}\n steps: [{stepId: read, operationId: read}]\n" + for _, action := range []bool{false, true} { + for _, name := range []string{"id", "bogus"} { + data := base + path := "/workflows/0/steps/0/parameters/0/name" + if action { + data = strings.Replace(data, "operationId: read}", "operationId: read, onSuccess: [{name: call, type: goto, workflowId: target, parameters: [{name: "+name+", value: true}]}]}", 1) + path = "/workflows/0/steps/0/onSuccess/0/parameters/0/name" + } else { + data = strings.Replace(data, "operationId: read}", "workflowId: target, parameters: [{name: "+name+", value: true}]}", 1) + } + r, err := ValidateBytes(context.Background(), []byte(data), "inputs.yaml") + if err != nil { + t.Fatal(err) + } + if name == "id" { + if !r.Valid() { + t.Fatalf("declared input rejected: %+v", r.Diagnostics) + } + } else { + foundationFinding(t, r, CodeParameter, path) + } + } + } +} + +func semanticHas(v *validation, code Code, path string) bool { + return hasDiagnostic(v.result, code, path) +} + +func hasDiagnostic(result *Result, code Code, path string) bool { + for _, d := range result.Diagnostics { + if d.Code == code && d.Pointer == path { + return true + } + } + return false +} + +func TestSemanticInheritedParameterContext(t *testing.T) { + v := semanticRun(t, `{"workflows":[{"workflowId":"run","parameters":[{"name":"X-ID","in":"header","value":false}],"steps":[{"stepId":"first","operationId":"fetch"}]}]}`, "1.0") + if len(v.Diagnostics) != 0 { + t.Fatalf("valid inherited header: %+v", v.Diagnostics) + } + v = semanticRun(t, `{"workflows":[{"workflowId":"run","parameters":[{"name":"id","value":0}],"steps":[{"stepId":"first","operationId":"fetch"}]}]}`, "1.0") + if !hasDiagnostic(v, CodeParameter, "/workflows/0/parameters/0") { + t.Fatalf("missing effective location: %+v", v.Diagnostics) + } +} + +func TestSemanticUnitReusableOverridesAndDuplicateIdentity(t *testing.T) { + for _, raw := range []string{"false", "0", "null"} { + t.Run(raw, func(t *testing.T) { + v := semanticUnit(t, `{"components":{"parameters":{"item":{"name":"id","in":"query","value":"original"}}},"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","parameters":[{"reference":"$components.parameters.item","value":`+raw+`}]}]}]}`, "1.0") + items := v.resolveList(object(array(object(array(v.root["workflows"])[0])["steps"])[0])["parameters"], "/parameters", "parameters") + var want any + if err := json.Unmarshal([]byte(raw), &want); err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].value["value"] != want { + t.Fatalf("override lost: %+v", items) + } + if v.local.components["parameters"]["item"].(map[string]any)["value"] != "original" { + t.Fatal("component mutated") + } + }) + } + v := semanticUnit(t, `{"components":{"parameters":{"a":{"name":"id","in":"query","value":1},"b":{"name":"id","in":"query","value":2}}},"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","parameters":[{"reference":"$components.parameters.a"},{"reference":"$components.parameters.b"}]}]}]}`, "1.0") + if !semanticHas(v, CodeParameter, "/workflows/0/steps/0/parameters/1") { + t.Fatalf("resolved identity duplicate missed: %+v", v.result.Diagnostics) + } +} + +func TestSemanticActionsUseConsumingWorkflow(t *testing.T) { + v := semanticRun(t, `{"components":{"successActions":{"move":{"name":"next","type":"goto","stepId":"last"}}},"workflows":[{"workflowId":"one","steps":[{"stepId":"last","operationId":"fetch","onSuccess":[{"reference":"$components.successActions.move"}]}]},{"workflowId":"two","steps":[{"stepId":"first","operationId":"fetch","onSuccess":[{"reference":"$components.successActions.move"}]}]}]}`, "1.0") + if hasDiagnostic(v, CodeReference, "/workflows/0/steps/0/onSuccess/0/stepId") { + t.Fatal("valid component target rejected") + } + if !hasDiagnostic(v, CodeReference, "/workflows/1/steps/0/onSuccess/0/stepId") { + t.Fatalf("consuming workflow target missed: %+v", v.Diagnostics) + } + v = semanticRun(t, `{"components":{"successActions":{"a":{"name":"done","type":"end"},"b":{"name":"done","type":"end"}}},"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","onSuccess":[{"reference":"$components.successActions.a"},{"reference":"$components.successActions.b"}]}]}]}`, "1.0") + if !hasDiagnostic(v, CodeDuplicateID, "/workflows/0/steps/0/onSuccess/1") { + t.Fatalf("effective action duplicate missed: %+v", v.Diagnostics) + } +} + +func TestSemanticDependencyFieldGrammarAndCycles(t *testing.T) { + v := semanticRun(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch"}]},{"workflowId":"other","steps":[{"stepId":"next","operationId":"fetch","dependsOn":["$workflows.run.steps.first"]}]}]}`, "1.1") + if len(v.Diagnostics) != 0 { + t.Fatalf("legal field-specific dependency rejected: %+v", v.Diagnostics) + } + v = semanticRun(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","dependsOn":["second"]},{"stepId":"second","operationId":"fetch","dependsOn":["first"]}]}]}`, "1.1") + if !hasDiagnostic(v, CodeDependency, "/workflows/0/steps/1/dependsOn/0") { + t.Fatalf("step cycle missed: %+v", v.Diagnostics) + } + v = semanticRun(t, `{"workflows":[{"workflowId":"run","dependsOn":["other"],"steps":[{"stepId":"first","operationId":"fetch"}]},{"workflowId":"other","dependsOn":["run"],"steps":[{"stepId":"first","operationId":"fetch"}]}]}`, "1.0") + if !hasDiagnostic(v, CodeDependency, "/workflows/1/dependsOn/0") { + t.Fatalf("workflow cycle missed: %+v", v.Diagnostics) + } +} + +func TestSemanticImplicitPrerequisitesAndDottedOutputs(t *testing.T) { + v := semanticRun(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","parameters":[{"name":"id","in":"query","value":"$steps.second.outputs.id"}],"outputs":{"id":"$statusCode"}},{"stepId":"second","operationId":"fetch","parameters":[{"name":"id","in":"query","value":"$steps.first.outputs.id"}],"outputs":{"id":"$statusCode"}}]}]}`, "1.1") + if !hasDiagnostic(v, CodeDependency, "/workflows/0/steps/1/parameters/0/value") { + t.Fatalf("implicit output cycle missed: %+v", v.Diagnostics) + } + v = semanticRun(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","outputs":{"id.part":"$statusCode"}},{"stepId":"second","operationId":"fetch","parameters":[{"name":"id","in":"query","value":"$steps.first.outputs.id.part"}]}]}]}`, "1.1") + if len(v.Diagnostics) != 0 { + t.Fatalf("dotted output name rejected: %+v", v.Diagnostics) + } +} + +func TestSemanticCompletedStepOutputInAction(t *testing.T) { + v := semanticRun(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","outputs":{"id":"$statusCode"},"onSuccess":[{"name":"done","type":"end","criteria":[{"condition":"$steps.first.outputs.id == 200"}]}]}]}]}`, "1.1") + if len(v.Diagnostics) != 0 { + t.Fatalf("completed current output falsely became a prerequisite: %+v", v.Diagnostics) + } +} + +func TestSemanticPublicDiagnosticLocations(t *testing.T) { + for _, version := range []string{"1.0.0", "1.1.0"} { + doc := []byte("arazzo: " + version + "\ninfo: {title: Example, version: '1'}\nsourceDescriptions:\n - {name: api, url: api.yaml, type: openapi}\nworkflows:\n - workflowId: run\n steps:\n - stepId: read\n operationId: read\n outputs:\n id: $steps.missing.outputs.id\n") + r, err := ValidateBytes(context.Background(), doc, "memory:document") + if err != nil { + t.Fatal(err) + } + found := false + for _, d := range r.Diagnostics { + if d.Code == CodeReference && d.Pointer == "/workflows/0/steps/0/outputs/id" { + found = true + if d.URI != "memory:document" || d.Line != 11 || d.Column != 15 { + t.Fatalf("source location lost: %+v", d) + } + } + } + if !found { + t.Fatalf("missing scoped expression diagnostic: %+v", r.Diagnostics) + } + } +} + +func TestSemanticPublicIdentityInheritanceAndActions(t *testing.T) { + for _, version := range []string{"1.0.0", "1.1.0"} { + base := "arazzo: " + version + "\ninfo: {title: Example, version: '1'}\nsourceDescriptions:\n - {name: api, url: api.yaml, type: openapi}\n" + t.Run(version, func(t *testing.T) { + for _, value := range []string{"false", "0", "null"} { + doc := base + "components:\n parameters:\n token: {name: X-ID, in: header, value: original}\n successActions:\n done: {name: done, type: end}\nworkflows:\n - workflowId: run\n parameters:\n - reference: $components.parameters.token\n value: " + value + "\n successActions:\n - reference: $components.successActions.done\n steps:\n - {stepId: first, operationId: read}\n" + r, err := ValidateBytes(context.Background(), []byte(doc), "memory:document") + if err != nil { + t.Fatal(err) + } + if !r.Valid() { + t.Fatalf("valid inheritance/value %s rejected: %+v", value, r.Diagnostics) + } + } + doc := base + "workflows:\n - workflowId: run\n steps:\n - {stepId: first, operationId: read}\n - {stepId: first, operationId: other}\n" + r, err := ValidateBytes(context.Background(), []byte(doc), "memory:document") + if err != nil { + t.Fatal(err) + } + d := foundationFinding(t, r, CodeDuplicateID, "/workflows/0/steps/1/stepId") + if d.Line != 9 || d.Column != 18 { + t.Fatalf("duplicate step location: %+v", d) + } + if len(d.Related) != 1 || d.Related[0].Pointer != "/workflows/0/steps/0/stepId" || d.Related[0].Line != 8 || d.Related[0].Column != 18 { + t.Fatalf("first duplicate declaration location: %+v", d.Related) + } + doc = base + "components:\n successActions:\n jump: {name: jump, type: goto, stepId: absent}\nworkflows:\n - workflowId: run\n steps:\n - stepId: first\n operationId: read\n onSuccess:\n - reference: $components.successActions.jump\n" + r, err = ValidateBytes(context.Background(), []byte(doc), "memory:document") + if err != nil { + t.Fatal(err) + } + d = foundationFinding(t, r, CodeReference, "/workflows/0/steps/0/onSuccess/0/stepId") + if d.Line != 14 || d.Column != 13 { + t.Fatalf("reusable target consuming location: %+v", d) + } + }) + } +} + +func TestSemanticPublicImplicitCycleAndOverride(t *testing.T) { + base := "arazzo: 1.1.0\ninfo: {title: Example, version: '1'}\nsourceDescriptions:\n - {name: api, url: api.yaml, type: openapi}\n" + doc := base + "workflows:\n - workflowId: run\n steps:\n - stepId: first\n operationId: read\n outputs: {id: '$statusCode'}\n parameters:\n - {name: id, in: query, value: '$steps.second.outputs.id'}\n - stepId: second\n operationId: read\n outputs: {id: '$statusCode'}\n parameters:\n - {name: id, in: query, value: '$steps.first.outputs.id'}\n" + r, err := ValidateBytes(context.Background(), []byte(doc), "memory:document") + if err != nil { + t.Fatal(err) + } + d := foundationFinding(t, r, CodeDependency, "/workflows/0/steps/1/parameters/0/value") + if d.Line != 17 || d.Column != 42 { + t.Fatalf("implicit cycle location: %+v", d) + } + // The step-level value replaces the inherited prerequisite completely. + doc = base + "workflows:\n - workflowId: run\n parameters:\n - {name: id, in: query, value: '$steps.second.outputs.id'}\n steps:\n - stepId: first\n operationId: read\n outputs: {id: '$statusCode'}\n parameters:\n - {name: id, in: query, value: false}\n - stepId: second\n operationId: read\n outputs: {id: '$statusCode'}\n parameters:\n - {name: id, in: query, value: false}\n" + r, err = ValidateBytes(context.Background(), []byte(doc), "memory:document") + if err != nil { + t.Fatal(err) + } + if !r.Valid() { + t.Fatalf("overridden prerequisite remained effective: %+v", r.Diagnostics) + } + // Existing control-flow loops remain valid: they are not prerequisites. + doc = base + "workflows:\n - workflowId: run\n steps:\n - stepId: first\n operationId: read\n onSuccess:\n - {name: again, type: goto, stepId: first}\n" + r, err = ValidateBytes(context.Background(), []byte(doc), "memory:document") + if err != nil { + t.Fatal(err) + } + if !r.Valid() { + t.Fatalf("goto loop rejected: %+v", r.Diagnostics) + } +} + +func TestSemanticUnitReusableCollectionAndCycles(t *testing.T) { + v := semanticUnit(t, `{"components":{"parameters":{"a":{"reference":"$components.parameters.b"},"b":{"reference":"$components.parameters.a"}}},"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","parameters":[{"reference":"$components.parameters.a"}]}]}]}`, "1.0") + if !semanticHas(v, CodeReference, "/workflows/0/steps/0/parameters/0/reference") { + t.Fatalf("reusable cycle missed: %+v", v.result.Diagnostics) + } + v = semanticUnit(t, `{"components":{"parameters":{"a":{"name":"id","value":0}}},"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","onSuccess":[{"reference":"$components.parameters.a"}]}]}]}`, "1.0") + if !semanticHas(v, CodeReference, "/workflows/0/steps/0/onSuccess/0/reference") { + t.Fatalf("wrong collection missed: %+v", v.result.Diagnostics) + } +} + +func TestSemanticInheritedWorkLimit(t *testing.T) { + for _, version := range []string{"1.0.0", "1.1.0"} { + for _, field := range []string{"successActions", "parameters"} { + t.Run(version+"/"+field, func(t *testing.T) { + defaults := make([]any, 12) + for i := range defaults { + if field == "successActions" { + defaults[i] = map[string]any{"name": fmt.Sprintf("end%d", i), "type": "end"} + } else { + defaults[i] = map[string]any{"name": fmt.Sprintf("item%d", i), "in": "query", "value": "fixed"} + } + } + steps := make([]any, 24) + for i := range steps { + steps[i] = map[string]any{"stepId": fmt.Sprintf("step%d", i), "operationId": "read"} + } + doc := map[string]any{"arazzo": version, "info": map[string]any{"title": "Bounded inheritance", "version": "1"}, "sourceDescriptions": []any{map[string]any{"name": "api", "url": "api.yaml", "type": "openapi"}}, "workflows": []any{map[string]any{"workflowId": "run", field: defaults, "steps": steps}}} + data, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + baseline, err := ValidateBytes(context.Background(), data, "memory:inherited") + if err != nil || !baseline.Valid() { + t.Fatalf("fixture must pass with normal limits: result=%+v err=%v", baseline, err) + } + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + t.Fatal(err) + } + _, _, _, stats, err := normalizeWithStats(context.Background(), Document{Root: &node, URI: "memory:inherited"}, DefaultLimits()) + if err != nil { + t.Fatal(err) + } + limits := DefaultLimits() + limits.MaxNodes = stats.nodes + 64 + result, err := ValidateBytes(context.Background(), data, "memory:inherited", WithLimits(limits)) + var limit *Error + if !errors.As(err, &limit) || limit.Kind != ErrorLimit || !strings.Contains(limit.Error(), "derived validation work") { + t.Fatalf("expected derived expansion limit above %d authored nodes: result=%+v err=%v", stats.nodes, result, err) + } + if result == nil || result.Complete { + t.Fatalf("bounded run must return incomplete coverage: %+v", result) + } + }) + } + } +} diff --git a/arazzo/source_contracts_test.go b/arazzo/source_contracts_test.go new file mode 100644 index 0000000..cbe92f0 --- /dev/null +++ b/arazzo/source_contracts_test.go @@ -0,0 +1,555 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/pb33f/go-yaml" + "github.com/pb33f/libopenapi" + upstream "github.com/pb33f/libopenapi/arazzo" + highArazzo "github.com/pb33f/libopenapi/datamodel/high/arazzo" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/orderedmap" +) + +// These unit fixtures start at the source boundary, with no primary document +// resource charges. Each test sets only the budget that its behavior requires. +func sourceContractSession() *sourceSession { + v := &validation{ctx: context.Background(), opts: options{limits: DefaultLimits()}, result: &Result{}, nodes: map[string]nodeLocation{}} + return &sourceSession{v: v, sourceState: &sourceState{byURI: map[string]*linkedSource{}, requests: map[string]*linkedSource{}}, sourceScope: sourceScope{base: "https://example.test/main.yaml", byName: map[string]map[string]any{}, descriptionPaths: map[string]string{}}} +} + +func sourceContractError(t *testing.T, s *sourceSession, kind ErrorKind) { + t.Helper() + var e *Error + if !errors.As(s.v.err, &e) || e.Kind != kind { + t.Fatalf("wanted %s, got %v", kind, s.v.err) + } +} + +func sourceContractYAML(t *testing.T, data string) *yaml.Node { + t.Helper() + var n yaml.Node + if err := yaml.Unmarshal([]byte(data), &n); err != nil { + t.Fatal(err) + } + return &n +} + +func sourceContractLinked(s *sourceSession, kind string, root map[string]any) *linkedSource { + return &linkedSource{owner: s, kind: kind, root: root, retrieval: "https://example.test/api.yaml", operations: map[string][]*linkedTarget{}, pointers: map[string]*linkedTarget{}, workflows: map[string]map[string]any{}} +} + +func TestSourceContractBuildFailures(t *testing.T) { + for _, tc := range []struct { + name string + source *upstream.ResolvedSource + nodes, bytes, sources int + kind ErrorKind + }{ + {name: "nil resolver result"}, + {name: "malformed YAML", source: &upstream.ResolvedSource{SourceBytes: []byte("openapi: [")}, kind: ErrorInput}, + {name: "multiple documents", source: &upstream.ResolvedSource{SourceBytes: []byte("openapi: 3.1.0\n---\nopenapi: 3.1.0\n")}, kind: ErrorInput}, + {name: "non JSON mapping key", source: &upstream.ResolvedSource{SourceBytes: []byte("openapi: 3.1.0\n? [a, b]\n: value\n")}, kind: ErrorInput}, + {name: "raw byte budget", source: &upstream.ResolvedSource{SourceBytes: []byte("openapi: 3.1.0")}, bytes: 1, kind: ErrorLimit}, + {name: "source count", source: &upstream.ResolvedSource{}, sources: 1, kind: ErrorLimit}, + {name: "no remaining parsed bytes", source: &upstream.ResolvedSource{SourceBytes: []byte("{}")}, bytes: 2, kind: ErrorLimit}, + {name: "no remaining parsed nodes", source: &upstream.ResolvedSource{RootNode: sourceContractYAML(t, "{}")}, nodes: 1, kind: ErrorLimit}, + } { + t.Run(tc.name, func(t *testing.T) { + s := sourceContractSession() + if tc.bytes > 0 { + s.v.opts.limits.MaxBytes = tc.bytes + } + if tc.nodes > 0 { + s.v.opts.limits.MaxNodes = tc.nodes + s.nodes = tc.nodes + } + if tc.sources > 0 { + s.v.opts.limits.MaxSources = tc.sources + s.count = tc.sources + } + got := s.build(tc.source) + if got != nil { + t.Fatalf("failed load returned source: %+v", got) + } + if tc.kind != "" { + sourceContractError(t, s, tc.kind) + } else if s.v.err != nil { + t.Fatal(s.v.err) + } + }) + } + s := sourceContractSession() + placeholder := s.build(&upstream.ResolvedSource{URL: "api.yaml"}) + if placeholder == nil || placeholder.identity != "https://example.test/api.yaml" || placeholder.kind != "" || len(s.v.result.Checks) != 1 || s.v.result.Checks[0].Name != "source-kind" || s.v.result.Checks[0].Status != CheckIncomplete { + t.Fatalf("placeholder classification: %+v %+v", placeholder, s.v.result) + } + s = sourceContractSession() + if s.build(&upstream.ResolvedSource{RetrievalURI: "https://example.test/api.yaml", Identity: "https://example.test/wrong.yaml", SourceBytes: []byte("arazzo: 1.1.0\n$self: canonical.yaml\n")}) != nil { + t.Fatal("conflicting identity accepted") + } + sourceContractError(t, s, ErrorConfiguration) + s = sourceContractSession() + s.base = "" + source := s.build(&upstream.ResolvedSource{Adapter: operationPresence{}}) + if source == nil || source.identity != "" || source.base != "" || !s.register(source) || len(s.byURI) != 0 { + t.Fatalf("anonymous typed source: %+v %v", source, s.v.err) + } +} + +func TestSourceContractParsedModels(t *testing.T) { + t.Run("OpenAPI low root", func(t *testing.T) { + doc, err := libopenapi.NewDocument([]byte(sourceOpenAPI)) + if err != nil { + t.Fatal(err) + } + model, errs := doc.BuildV3Model() + if errs != nil { + t.Fatal(errs) + } + s := sourceContractSession() + got := s.build(&upstream.ResolvedSource{URL: "api.yaml", OpenAPIDocument: &model.Model}) + if s.v.err != nil || got == nil || got.root == nil || len(got.operations["read"]) != 1 { + t.Fatalf("parsed OpenAPI projection: %+v %v", got, s.v.err) + } + }) + t.Run("Arazzo low root", func(t *testing.T) { + doc, err := libopenapi.NewArazzoDocument([]byte(foundationYAML("1.1.0"))) + if err != nil { + t.Fatal(err) + } + s := sourceContractSession() + got := s.build(&upstream.ResolvedSource{URL: "api.yaml", ArazzoDocument: doc}) + if s.v.err != nil || got == nil || got.root == nil || got.workflows["run"] == nil || got.partialExpressions { + t.Fatalf("parsed Arazzo projection: %+v %v", got, s.v.err) + } + }) +} + +func TestSourceContractRootlessArazzoInputs(t *testing.T) { + for _, tc := range []struct { + name, data string + nodes, depth int + kind ErrorKind + }{ + {name: "schema", data: "type: object\nproperties: {id: {type: string}}\n"}, + {name: "tagged schema", data: "!unsupported {type: object}", kind: ErrorInput}, + {name: "depth", data: "properties: {id: {type: string}}", depth: 1, kind: ErrorLimit}, + {name: "exact budget exhausted before inputs", data: "{}", nodes: 7, kind: ErrorLimit}, + {name: "workflow budget", data: "{}", nodes: 5, kind: ErrorLimit}, + } { + t.Run(tc.name, func(t *testing.T) { + s := sourceContractSession() + if tc.nodes > 0 { + s.v.opts.limits.MaxNodes = tc.nodes + } + if tc.depth > 0 { + s.v.opts.limits.MaxDepth = tc.depth + } + inputs := sourceContractYAML(t, tc.data) + before, _ := yaml.Marshal(inputs) + model := &highArazzo.Arazzo{Arazzo: "1.1.0", Workflows: []*highArazzo.Workflow{nil, {WorkflowId: "remote", DependsOn: []string{"prior"}, Inputs: inputs, Steps: []*highArazzo.Step{nil, {StepId: "first", DependsOn: []string{"second"}}}}}} + got := s.build(&upstream.ResolvedSource{URL: "api.yaml", ArazzoDocument: model}) + if tc.kind != "" { + if got != nil { + t.Fatal("invalid source accepted") + } + sourceContractError(t, s, tc.kind) + return + } + if s.v.err != nil || got == nil || !got.partialExpressions { + t.Fatalf("rootless model: %+v %v", got, s.v.err) + } + wf := got.workflows["remote"] + if !reflect.DeepEqual(wf["dependsOn"], []any{"prior"}) || object(wf["inputs"])["type"] != "object" || !reflect.DeepEqual(got.steps["remote"]["first"]["dependsOn"], []any{"second"}) { + t.Fatalf("lost input or dependency metadata: %+v", wf) + } + after, _ := yaml.Marshal(inputs) + if string(before) != string(after) || len(model.Workflows) != 2 || model.Workflows[0] != nil { + t.Fatal("caller model changed") + } + }) + } + // Output projection failure must stop before the workflow enters the index. + s := sourceContractSession() + s.v.opts.limits.MaxNodes = 8 + outputs := orderedmap.New[string, *highArazzo.OutputValue]() + outputs.Set("id", highArazzo.NewExpressionOutputValue("$response.body#/id")) + if s.build(&upstream.ResolvedSource{ArazzoDocument: &highArazzo.Arazzo{Workflows: []*highArazzo.Workflow{{WorkflowId: "remote", Outputs: outputs}}}}) != nil { + t.Fatal("partially projected workflow returned") + } + sourceContractError(t, s, ErrorLimit) +} + +func TestSourceContractLookupFailures(t *testing.T) { + for _, tc := range []struct { + name string + response *upstream.ResolvedSource + err error + kind ErrorKind + duplicate bool + }{ + {name: "unavailable", err: upstream.ErrUnresolvedSourceDesc}, + {name: "nil response"}, + {name: "placeholder", response: &upstream.ResolvedSource{URL: "api.yaml"}}, + {name: "invalid source", response: &upstream.ResolvedSource{URL: "api.yaml", SourceBytes: []byte("[")}, kind: ErrorInput}, + {name: "conflicting registration", response: &upstream.ResolvedSource{URL: "api.yaml", Identity: "https://example.test/canonical.yaml", Adapter: operationPresence{}}, duplicate: true, kind: ErrorConfiguration}, + } { + t.Run(tc.name, func(t *testing.T) { + s := sourceContractSession() + s.byName["api"] = map[string]any{"url": "api.yaml", "type": "openapi"} + if tc.duplicate { + s.byURI["https://example.test/canonical.yaml"] = &linkedSource{} + } + calls := 0 + s.v.opts.resolver = sourceResolverFunc(func(context.Context, upstream.SourceRequest) (*upstream.ResolvedSource, error) { + calls++ + return tc.response, tc.err + }) + if s.lookup("api", "/workflows/0/steps/0/operationId") != nil { + t.Fatal("failed source returned a target") + } + if tc.kind != "" { + sourceContractError(t, s, tc.kind) + } else { + if s.v.err != nil || len(s.v.result.Checks) == 0 { + t.Fatalf("missing incomplete coverage: %+v %v", s.v.result, s.v.err) + } + // Unavailable and nil resolver results are cached to prevent repeat I/O. + if tc.name != "placeholder" { + s.lookup("api", "/second") + if calls != 1 { + t.Fatalf("resolver called %d times", calls) + } + } + } + }) + } + s := sourceContractSession() + if s.lookup("missing", "/target") != nil || len(s.v.result.Diagnostics) != 1 || s.v.result.Diagnostics[0].Code != CodeReference { + t.Fatal("unknown description lacks finding") + } +} + +func TestSourceContractURIsAndNames(t *testing.T) { + for _, tc := range []struct{ ref, base, want string }{ + {"https://EXAMPLE.test/API", "", "https://example.test/API"}, + {"%invalid", "https://example.test/", "%invalid"}, + {"child.yaml", "%invalid", "child.yaml"}, + } { + if got := resolveLinkedURI(tc.ref, tc.base); got != tc.want { + t.Fatalf("URI %q: %q", tc.ref, got) + } + } + s := sourceContractSession() + for _, tc := range []struct { + value, name, target string + ok bool + }{ + {"read", "", "", false}, {"$sourceDescriptions.api", "", "", false}, {"$sourceDescriptions..read", "", "", false}, {"$sourceDescriptions.api.", "", "", false}, {"$sourceDescriptions.api.read", "api", "read", true}, + } { + n, id, ok := s.qualified(tc.value) + if n != tc.name || id != tc.target || ok != tc.ok { + t.Fatalf("qualification %q: %q %q %v", tc.value, n, id, ok) + } + } + if s.descriptionPath("missing") != "/sourceDescriptions" { + t.Fatal("missing default description path") + } + s.scopePath = "https://example.test/other.yaml#/sourceDescriptions/0" + if s.descriptionPath("missing") != s.scopePath { + t.Fatal("missing scoped description path") + } + source := sourceContractLinked(s, "openapi", nil) + source.retrieval = "" + source.identity = "https://example.test/canonical.yaml" + key := s.sourcePath(source, "unrooted") + if s.v.foreignLocations[key].URI != source.identity || s.v.foreignLocations[key].Pointer != "unrooted" { + t.Fatal("fallback source location lost") + } +} + +func TestSourceContractLocalReferences(t *testing.T) { + target := map[string]any{"operationId": "read"} + root := map[string]any{"entries": []any{target}, "scalar": "stop", "loop": map[string]any{"$ref": "#/loop"}, "chain": map[string]any{"$ref": "#/entries/0"}} + for _, tc := range []struct { + ref string + found bool + }{ + {"#/entries/0", true}, {"#/chain", true}, {"#/entries/-1", false}, {"#/entries/1", false}, {"#/entries/bogus", false}, {"#/scalar/child", false}, {"#/bad~2token", false}, {"#/loop", false}, {"other.yaml#/paths", false}, + } { + t.Run(tc.ref, func(t *testing.T) { + s := sourceContractSession() + source := sourceContractLinked(s, "openapi", root) + first := source.deref(map[string]any{"$ref": tc.ref}) + second := source.deref(map[string]any{"$ref": tc.ref}) + if (first != nil) != tc.found || !reflect.DeepEqual(first, second) || (tc.found && first["operationId"] != "read") { + t.Fatalf("reference resolution: %+v %+v", first, second) + } + if strings.HasPrefix(tc.ref, "other") && !source.unresolvedRefs { + t.Fatal("external reference was treated as complete") + } + }) + } + s := sourceContractSession() + s.v.opts.limits.MaxNodes = 1 + source := sourceContractLinked(s, "openapi", root) + if source.deref(map[string]any{"$ref": "#/chain"}) != nil { + t.Fatal("reference chain escaped work cap") + } + sourceContractError(t, s, ErrorLimit) +} + +func TestSourceContractRawOperationIndex(t *testing.T) { + s := sourceContractSession() + callback := map[string]any{"x-ignore": map[string]any{"get": map[string]any{"operationId": "ignore"}}, "{$request.query.url}": map[string]any{"post": map[string]any{"operationId": "notify"}}} + root := map[string]any{ + "components": map[string]any{"pathItems": map[string]any{"shared": map[string]any{"get": map[string]any{"operationId": "componentOnly"}}}, "callbacks": map[string]any{"notify": callback}}, + "paths": map[string]any{"/pets": map[string]any{"additionalOperations": map[string]any{"CUSTOM/~": map[string]any{"operationId": "custom"}}, "get": map[string]any{"operationId": "read", "parameters": []any{map[string]any{"$ref": "other.yaml#/parameters/id"}}, "callbacks": map[string]any{"notify": map[string]any{"$ref": "#/components/callbacks/notify"}}}}}, + } + source := sourceContractLinked(s, "openapi", root) + source.indexRaw() + if s.v.err != nil || len(source.operations["read"]) != 1 || len(source.operations["custom"]) != 1 || len(source.operations["notify"]) != 1 || len(source.operations["componentOnly"]) != 0 || len(source.operations["ignore"]) != 0 { + t.Fatalf("operation identities: %+v %v", source.operations, s.v.err) + } + if source.pointers["/paths/~1pets/additionalOperations/CUSTOM~1~0"] == nil || source.pointers["/components/pathItems/shared/get"] == nil || source.operations["read"][0].parametersComplete || !source.unresolvedRefs { + t.Fatalf("pointer/parameter coverage: %+v", source) + } + // A callback cycle ends without recursively expanding the same callback. + recursive := map[string]any{"{$request.query.url}": map[string]any{"post": map[string]any{"operationId": "notify", "callbacks": map[string]any{"again": map[string]any{"$ref": "#/components/callbacks/notify"}}}}} + root["components"].(map[string]any)["callbacks"].(map[string]any)["notify"] = recursive + source = sourceContractLinked(sourceContractSession(), "openapi", root) + source.indexRaw() + if !source.unresolvedRefs || len(source.operations["notify"]) > 2 { + t.Fatal("recursive callback did not stop") + } + // A pathItem reference recurring through a callback must also stop. + recursive["{$request.query.url}"] = map[string]any{"$ref": "#/paths/~1pets"} + source = sourceContractLinked(sourceContractSession(), "openapi", root) + source.indexRaw() + if !source.unresolvedRefs { + t.Fatal("recursive path item did not stop") + } + // AsyncAPI 2 operations live on channel publish/subscribe objects. + source = sourceContractLinked(sourceContractSession(), "asyncapi", map[string]any{"channels": map[string]any{"pets": map[string]any{"publish": map[string]any{"operationId": "publishPet"}, "subscribe": map[string]any{"operationId": "readPet"}}}}) + source.indexRaw() + if len(source.operations["publishPet"]) != 1 || len(source.operations["readPet"]) != 1 { + t.Fatal("AsyncAPI 2 channel operations omitted") + } +} + +func TestSourceContractRootlessOpenAPIMetadata(t *testing.T) { + s := sourceContractSession() + source := sourceContractLinked(s, "openapi", nil) + source.indexOpenAPI(&v3.Document{}, s) + paths := orderedmap.New[string, *v3.PathItem]() + paths.Set("/nil", nil) + additional := orderedmap.New[string, *v3.Operation]() + additional.Set("CUSTOM/~", &v3.Operation{OperationId: "custom"}) + paths.Set("/pets", &v3.PathItem{AdditionalOperations: additional, Get: &v3.Operation{OperationId: "read", Parameters: []*v3.Parameter{nil, {Reference: "#/components/parameters/id"}}}}) + source.indexOpenAPI(&v3.Document{Paths: &v3.Paths{PathItems: paths}}, s) + if s.v.err != nil || source.pointers["/paths/~1pets/additionalOperations/CUSTOM~1~0"] == nil || len(source.operations["custom"]) != 1 || source.operations["read"][0].parametersComplete { + t.Fatalf("rootless metadata: %+v %v", source, s.v.err) + } + for _, tc := range []struct { + name string + model *v3.Document + nodes, bytes int + }{ + {name: "path", model: &v3.Document{Paths: &v3.Paths{PathItems: paths}}, nodes: 1}, + {name: "additional method bytes", model: &v3.Document{Paths: &v3.Paths{PathItems: paths}}, bytes: 12}, + {name: "operation", model: &v3.Document{Paths: &v3.Paths{PathItems: paths}}, nodes: 6}, + {name: "parameter", model: &v3.Document{Paths: &v3.Paths{PathItems: paths}}, nodes: 11}, + } { + t.Run(tc.name, func(t *testing.T) { + s := sourceContractSession() + if tc.nodes > 0 { + s.v.opts.limits.MaxNodes = tc.nodes + } + if tc.bytes > 0 { + s.v.opts.limits.MaxBytes = tc.bytes + } + source := sourceContractLinked(s, "openapi", nil) + source.indexOpenAPI(tc.model, s) + sourceContractError(t, s, ErrorLimit) + }) + } +} + +func TestSourceContractOutputProjection(t *testing.T) { + outputs := orderedmap.New[string, *highArazzo.OutputValue]() + outputs.Set("scalar", highArazzo.NewExpressionOutputValue("$response.body#/id")) + outputs.Set("selector", highArazzo.NewSelectorOutputValue(&highArazzo.Selector{Context: "$response.body", Selector: "$.id", Type: "jsonpath"})) + outputs.Set("objectType", highArazzo.NewSelectorOutputValue(&highArazzo.Selector{Context: "$response.body", Selector: "$.id", ExpressionType: &highArazzo.ExpressionType{Type: "jsonpath", Version: "draft-goessner-dispatch-jsonpath-00"}})) + outputs.Set("nil", nil) + s := sourceContractSession() + got := linkedOutputs(outputs, s) + if s.v.err != nil || got["scalar"] != "$response.body#/id" || object(got["selector"])["type"] != "jsonpath" || object(object(got["objectType"])["type"])["version"] != "draft-goessner-dispatch-jsonpath-00" { + t.Fatalf("output projection: %+v %v", got, s.v.err) + } + if value, exists := got["nil"]; !exists || value != nil { + t.Fatal("nil output lost") + } + for _, tc := range []struct { + name string + output *highArazzo.OutputValue + nodes int + }{ + {"map entry", highArazzo.NewExpressionOutputValue("$response.body"), 1}, + {"scalar", highArazzo.NewExpressionOutputValue("$response.body"), 2}, + {"selector", highArazzo.NewSelectorOutputValue(&highArazzo.Selector{Context: "$response.body", Selector: "$.id", Type: "jsonpath"}), 8}, + {"object type", highArazzo.NewSelectorOutputValue(&highArazzo.Selector{Context: "$response.body", Selector: "$.id", ExpressionType: &highArazzo.ExpressionType{Type: "jsonpath", Version: "1"}}), 13}, + } { + t.Run(tc.name, func(t *testing.T) { + s := sourceContractSession() + s.v.opts.limits.MaxNodes = tc.nodes + values := orderedmap.New[string, *highArazzo.OutputValue]() + values.Set("id", tc.output) + if linkedOutputs(values, s) != nil { + t.Fatal("over-budget outputs returned") + } + sourceContractError(t, s, ErrorLimit) + }) + } + if outputs.Len() != 4 { + t.Fatal("caller outputs changed") + } +} + +// A deterministic context models cancellation between source indexing phases. +// It avoids timing-sensitive goroutines while retaining normal ctx.Err behavior. +type sourceContractCancelContext struct { + context.Context + checks, after int + cancel context.CancelFunc +} + +func newSourceCancelContext(t *testing.T, after int) *sourceContractCancelContext { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + return &sourceContractCancelContext{Context: ctx, after: after, cancel: cancel} +} + +func (c *sourceContractCancelContext) Err() error { + c.checks++ + if c.checks >= c.after { + c.cancel() + } + return c.Context.Err() +} + +func TestSourceContractIndexStops(t *testing.T) { + t.Run("workflow work cap", func(t *testing.T) { + s := sourceContractSession() + s.v.opts.limits.MaxNodes = 1 + source := sourceContractLinked(s, "arazzo", map[string]any{"workflows": []any{map[string]any{"workflowId": "run", "steps": []any{map[string]any{"stepId": "read"}}}}}) + source.indexSteps() + sourceContractError(t, s, ErrorLimit) + if len(source.steps) != 0 { + t.Fatal("over-budget workflow indexed") + } + }) + t.Run("step cancellation", func(t *testing.T) { + s := sourceContractSession() + s.v.ctx = newSourceCancelContext(t, 2) + source := sourceContractLinked(s, "arazzo", map[string]any{"workflows": []any{map[string]any{"workflowId": "run", "steps": []any{map[string]any{"stepId": "read"}}}}}) + source.indexSteps() + if !errors.Is(s.v.err, context.Canceled) || len(source.steps) != 0 { + t.Fatal("canceled steps indexed") + } + }) + t.Run("operation absent", func(t *testing.T) { + source := sourceContractLinked(sourceContractSession(), "openapi", nil) + if source.addOperation(nil, "/get", nil) != nil { + t.Fatal("nil operation indexed") + } + }) + t.Run("operation node budget", func(t *testing.T) { + s := sourceContractSession() + s.v.opts.limits.MaxNodes = 1 + s.nodes = 1 + source := sourceContractLinked(s, "openapi", nil) + if source.addOperation(map[string]any{"operationId": "read"}, "/get", nil) != nil { + t.Fatal("over-budget operation indexed") + } + sourceContractError(t, s, ErrorLimit) + }) + t.Run("parameter work budget", func(t *testing.T) { + s := sourceContractSession() + s.v.opts.limits.MaxNodes = 1 + source := sourceContractLinked(s, "openapi", nil) + operation := map[string]any{"operationId": "read", "parameters": []any{map[string]any{"name": "id", "in": "query"}, map[string]any{"name": "other", "in": "query"}}} + if source.addOperation(operation, "/get", nil) != nil { + t.Fatal("over-budget parameters indexed") + } + sourceContractError(t, s, ErrorLimit) + if len(source.pointers) != 0 { + t.Fatal("partially checked target registered") + } + }) + t.Run("path budget", func(t *testing.T) { + s := sourceContractSession() + s.v.opts.limits.MaxNodes = 1 + s.nodes = 1 + source := sourceContractLinked(s, "openapi", nil) + source.indexPathItem(map[string]any{"get": map[string]any{"operationId": "read"}}, "/paths/~1pets", true, map[string]bool{}) + sourceContractError(t, s, ErrorLimit) + if len(source.pointers) != 0 { + t.Fatal("over-budget path registered") + } + }) + t.Run("rootless input cancellation", func(t *testing.T) { + s := sourceContractSession() + s.v.ctx = newSourceCancelContext(t, 4) + model := &highArazzo.Arazzo{Workflows: []*highArazzo.Workflow{{WorkflowId: "remote", Inputs: &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "schema"}}}} + if s.build(&upstream.ResolvedSource{ArazzoDocument: model}) != nil || !errors.Is(s.v.err, context.Canceled) { + t.Fatalf("canceled input accepted: %v", s.v.err) + } + }) +} + +func TestSourceContractStageStopsAndComponentActions(t *testing.T) { + for _, candidate := range []bool{false, true} { + t.Run(map[bool]string{false: "workflow", true: "candidate"}[candidate], func(t *testing.T) { + s := sourceContractSession() + s.v.opts.limits.MaxNodes = 1 + s.v.root = map[string]any{"arazzo": "1.1.0", "workflows": []any{map[string]any{"workflowId": "run", "steps": []any{map[string]any{"stepId": "read"}}}}} + if candidate { + s.v.opts.sources = []upstream.CandidateDocument{{Adapter: operationPresence{}}} + } + checkSources(s.v) + sourceContractError(t, s, ErrorLimit) + if s.v.sources.count != 0 { + t.Fatal("candidate loaded after primary index failed") + } + }) + } + s := sourceContractSession() + s.v.doc.URI = "https://example.test/main.yaml" + s.v.root = map[string]any{"arazzo": "1.1.0", "sourceDescriptions": []any{map[string]any{"name": "flows", "url": "flows.yaml", "type": "arazzo"}}, "components": map[string]any{"successActions": map[string]any{"next": map[string]any{"workflowId": "$sourceDescriptions.flows.run"}}, "failureActions": map[string]any{"recover": map[string]any{"workflowId": "$sourceDescriptions.flows.run"}}}} + s.v.opts.sources = []upstream.CandidateDocument{{RetrievalURI: "https://example.test/flows.yaml", Adapter: workflowPresence{}}} + checkSources(s.v) + if s.v.err != nil { + t.Fatal(s.v.err) + } + pointers := map[string]bool{} + for _, check := range s.v.result.Checks { + if check.Name == "workflow-target" { + pointers[check.Pointer] = true + } + } + if !pointers["/components/successActions/next/workflowId"] || !pointers["/components/failureActions/recover/workflowId"] { + t.Fatalf("component workflow targets omitted: %+v", s.v.result.Checks) + } + if name, target, ok := sourceQualified("read"); name != "" || target != "" || ok { + t.Fatal("unqualified name accepted") + } +} diff --git a/arazzo/sources.go b/arazzo/sources.go new file mode 100644 index 0000000..c764613 --- /dev/null +++ b/arazzo/sources.go @@ -0,0 +1,559 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/url" + "strings" + + "github.com/pb33f/go-yaml" + upstream "github.com/pb33f/libopenapi/arazzo" +) + +// sourceSession holds only request-local source metadata. SourceDescription names +// belong to their containing document; canonical identities belong to documents. +type sourceSession struct { + v *validation + *sourceState + sourceScope +} + +// sourceState owns the shared caches and resource totals for one validation. +type sourceState struct { + byURI map[string]*linkedSource + requests map[string]*linkedSource + count, bytes, nodes int + graphVisited map[*linkedSource]bool + targets map[dependencyID]*linkedTarget +} + +// sourceScope resolves names and locations within one containing document. +type sourceScope struct { + base string + byName map[string]map[string]any + scopePath string + scopeSource *linkedSource + descriptionPaths map[string]string +} + +type linkedSource struct { + owner *sourceSession + nodes map[string]nodeLocation + unresolvedRefs bool + partialExpressions bool + refMemo map[string]map[string]any + kind, identity, retrieval, base string + root map[string]any + adapter upstream.SourceDocumentAdapter + operations map[string][]*linkedTarget + pointers map[string]*linkedTarget + workflows map[string]map[string]any + steps map[string]map[string]map[string]any +} + +type linkedTarget struct { + source *linkedSource + kind string + node map[string]any + parameters map[linkedParameterKey]bool + parametersComplete bool +} +type linkedParameterKey struct{ name, in string } + +func checkSources(v *validation) { + s := &sourceSession{v: v, sourceState: &sourceState{byURI: make(map[string]*linkedSource), requests: make(map[string]*linkedSource), graphVisited: make(map[*linkedSource]bool)}, sourceScope: sourceScope{byName: make(map[string]map[string]any)}} + v.sources = s + s.descriptionPaths = make(map[string]string) + s.targets = make(map[dependencyID]*linkedTarget) + s.bytes = v.byteCount + s.nodes = v.nodeCount + s.base = resolveLinkedURI(v.doc.URI, v.opts.baseURI) + if self := text(v.root["$self"]); self != "" { + s.base = resolveLinkedURI(self, s.base) + } + for i, value := range array(v.root["sourceDescriptions"]) { + description := object(value) + name := text(description["name"]) + if _, exists := s.byName[name]; !exists { + s.byName[name] = description + s.descriptionPaths[name] = fmt.Sprintf("/sourceDescriptions/%d", i) + } + } + // Fully index every candidate before looking up any reference. A later supplied + // candidate can establish the $self identity of an earlier reference. + primary := &linkedSource{owner: s, kind: "arazzo", identity: v.documentID(), retrieval: v.doc.URI, base: s.base, root: v.root, workflows: make(map[string]map[string]any)} + primary.indexRaw() + if s.base != "" { + s.byURI[s.base] = primary + } + if v.doc.URI != "" { + s.byURI[v.doc.URI] = primary + } + s.graphVisited[primary] = true + for _, candidate := range v.opts.sources { + if !v.check() { + return + } + source := &upstream.ResolvedSource{Type: candidate.Type, Identity: candidate.ResolvedIdentity, RetrievalURI: candidate.RetrievalURI, SourceBytes: candidate.SourceBytes, RootNode: candidate.RootNode, OpenAPIDocument: candidate.OpenAPIDocument, ArazzoDocument: candidate.ArazzoDocument, Adapter: candidate.Adapter} + item := s.build(source) + if v.err != nil { + return + } + if item != nil && !s.register(item) { + return + } + } + for i, value := range array(v.root["sourceDescriptions"]) { + description := object(value) + uri := resolveLinkedURI(text(description["url"]), s.base) + if source := s.byURI[uri]; source != nil { + s.checkDescription(source, description, fmt.Sprintf("/sourceDescriptions/%d", i)) + } + } + for wi, value := range array(v.root["workflows"]) { + if !v.check() { + return + } + workflow := object(value) + path := fmt.Sprintf("/workflows/%d", wi) + for di, dep := range array(workflow["dependsOn"]) { + if strings.HasPrefix(text(dep), "$sourceDescriptions.") { + depPath := fmt.Sprintf("%s/dependsOn/%d", path, di) + target := s.workflow(text(dep), depPath) + if target != nil { + _, id, _ := s.qualified(text(dep)) + to := dependencyID{document: target.source.identity, workflow: id} + from := dependencyID{document: v.documentID(), workflow: text(workflow["workflowId"]), prerequisites: true} + v.graph.addEdge(from, to, depPath) + s.graphSource(target.source, depPath, 1) + } + } + } + s.actions(workflow["successActions"], path+"/successActions") + s.actions(workflow["failureActions"], path+"/failureActions") + for si, value := range array(workflow["steps"]) { + step := object(value) + stepPath := fmt.Sprintf("%s/steps/%d", path, si) + target := s.step(step, stepPath) + s.targets[dependencyID{document: v.documentID(), workflow: text(workflow["workflowId"]), step: text(step["stepId"])}] = target + if target != nil { + s.parameters(workflow["parameters"], path+"/parameters", step["parameters"], stepPath+"/parameters", target) + } + s.actions(step["onSuccess"], stepPath+"/onSuccess") + s.actions(step["onFailure"], stepPath+"/onFailure") + for di, dep := range array(step["dependsOn"]) { + if strings.HasPrefix(text(dep), "$sourceDescriptions.") { + depPath := fmt.Sprintf("%s/dependsOn/%d", stepPath, di) + if target, ok := s.externalStep(text(dep), depPath); ok { + v.graph.addEdge(dependencyID{document: v.documentID(), workflow: text(workflow["workflowId"]), step: text(step["stepId"])}, target, depPath) + } + } + } + } + } + s.requestExpressions(v.expressionUses) + for _, collection := range []string{"successActions", "failureActions"} { + definitions := object(object(v.root["components"])[collection]) + for _, name := range sortedKeys(definitions) { + value := definitions[name] + action := object(value) + path := joinPtr("/components/"+collection, name) + if strings.HasPrefix(text(action["workflowId"]), "$sourceDescriptions.") { + s.workflow(text(action["workflowId"]), path+"/workflowId") + } + } + } +} + +func resolveLinkedURI(ref, base string) string { + uri, err := url.Parse(ref) + if err != nil { + return ref + } + if base != "" { + b, e := url.Parse(base) + if e == nil { + uri = b.ResolveReference(uri) + } + } + if uri.Scheme != "" { + uri.Scheme = strings.ToLower(uri.Scheme) + uri.Host = strings.ToLower(uri.Host) + } + return uri.String() +} + +func (s *sourceSession) fail(kind ErrorKind, message string) { + if s.v.err == nil { + s.v.err = &Error{Kind: kind, Cause: errors.New(message)} + } +} + +func (s *sourceSession) build(source *upstream.ResolvedSource) *linkedSource { + if source == nil { + return nil + } + s.count++ + if s.count > s.v.opts.limits.MaxSources { + s.fail(ErrorLimit, "total source document limit exceeded") + return nil + } + s.bytes += len(source.SourceBytes) + if s.bytes > s.v.opts.limits.MaxBytes { + s.fail(ErrorLimit, "total source byte limit exceeded") + return nil + } + item := &linkedSource{owner: s, adapter: source.Adapter, operations: make(map[string][]*linkedTarget), pointers: make(map[string]*linkedTarget), workflows: make(map[string]map[string]any)} + retrieval := source.RetrievalURI + if retrieval == "" { + retrieval = source.URL + } + item.retrieval = resolveLinkedURI(retrieval, s.base) + root := source.RootNode + if root == nil && source.ArazzoDocument != nil && source.ArazzoDocument.GoLow() != nil { + root = source.ArazzoDocument.GoLow().RootNode + } + if root == nil && source.OpenAPIDocument != nil && source.OpenAPIDocument.GoLow() != nil && source.OpenAPIDocument.GoLow().Index != nil { + root = source.OpenAPIDocument.GoLow().Index.GetRootNode() + } + if root == nil && len(source.SourceBytes) > 0 { + var parsed yaml.Node + decoder := yaml.NewDecoder(bytes.NewReader(source.SourceBytes)) + if err := decoder.Decode(&parsed); err != nil { + s.fail(ErrorInput, fmt.Sprintf("parse source %q: %v", item.retrieval, err)) + return nil + } + var extra yaml.Node + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + s.fail(ErrorInput, "source contains multiple YAML documents") + return nil + } + root = &parsed + } + if root != nil { + limits := s.v.opts.limits + limits.MaxBytes -= s.bytes + limits.MaxNodes -= s.nodes + if limits.MaxBytes <= 0 || limits.MaxNodes <= 0 { + s.fail(ErrorLimit, "total source document resources exceeded") + return nil + } + value, nodes, diagnostics, stats, err := normalizeWithStats(s.v.ctx, Document{Root: root, URI: item.retrieval}, limits) + if err != nil { + s.v.err = err + return nil + } + if len(diagnostics) > 0 { + s.fail(ErrorInput, fmt.Sprintf("source %q is not a JSON-compatible document", item.retrieval)) + return nil + } + s.nodes += stats.nodes + s.bytes += stats.bytes + item.root = object(value) + item.nodes = nodes + switch { + case item.root["openapi"] != nil: + item.kind = "openapi" + case item.root["arazzo"] != nil: + item.kind = "arazzo" + case item.root["asyncapi"] != nil: + item.kind = "asyncapi" + } + } + if item.kind == "" { + switch { + case source.OpenAPIDocument != nil: + item.kind = "openapi" + case source.ArazzoDocument != nil: + item.kind = "arazzo" + case source.Adapter != nil: + item.kind = strings.ToLower(source.Adapter.SourceType()) + } + } + if item.kind == "" { + s.v.incomplete("source-kind", "", item.retrieval, "resolver supplied no parsed document or typed adapter") + item.identity = item.retrieval + return item + } + item.identity = resolveLinkedURI(source.Identity, item.retrieval) + if item.identity == "" { + item.identity = item.retrieval + } + self := text(item.root["$self"]) + if self == "" && source.ArazzoDocument != nil { + self = source.ArazzoDocument.Self + } + if item.kind == "arazzo" && self != "" { + canonical := resolveLinkedURI(self, item.retrieval) + if source.Identity != "" && item.identity != canonical { + s.fail(ErrorConfiguration, "supplied source identity conflicts with its $self URI") + return nil + } + item.identity = canonical + } + item.base = item.identity + if item.base == "" { + item.base = item.retrieval + } + if item.root != nil { + item.indexRaw() + } else if source.OpenAPIDocument != nil { + item.indexOpenAPI(source.OpenAPIDocument, s) + } else if source.ArazzoDocument != nil { + item.partialExpressions = true + item.root = map[string]any{"arazzo": source.ArazzoDocument.Arazzo, "$self": source.ArazzoDocument.Self} + descriptions := []any{} + for _, description := range source.ArazzoDocument.SourceDescriptions { + if description != nil { + if !s.charge(7, len(description.Name)+len(description.URL)+len(description.Type)) { + return nil + } + descriptions = append(descriptions, map[string]any{"name": description.Name, "url": description.URL, "type": description.Type}) + } + } + item.root["sourceDescriptions"] = descriptions + workflows := []any{} + for _, workflow := range source.ArazzoDocument.Workflows { + if workflow == nil { + continue + } + if !s.charge(6, len(workflow.WorkflowId)) { + return nil + } + wf := map[string]any{"workflowId": workflow.WorkflowId, "dependsOn": s.linkedStrings(workflow.DependsOn), "outputs": linkedOutputs(workflow.Outputs, s)} + if workflow.Inputs != nil { + limits := s.v.opts.limits + limits.MaxBytes -= s.bytes + limits.MaxNodes -= s.nodes + if limits.MaxBytes <= 0 || limits.MaxNodes <= 0 { + s.fail(ErrorLimit, "total source document resources exceeded") + return nil + } + value, _, diags, stats, err := normalizeWithStats(s.v.ctx, Document{Root: workflow.Inputs, URI: item.retrieval}, limits) + if err != nil { + s.v.err = err + return nil + } + if len(diags) > 0 { + s.fail(ErrorInput, "supplied workflow input schema is not JSON-compatible") + return nil + } + if !s.charge(stats.nodes, stats.bytes) { + return nil + } + wf["inputs"] = value + } + steps := []any{} + for _, step := range workflow.Steps { + if step != nil { + if !s.charge(6, len(step.StepId)) { + return nil + } + steps = append(steps, map[string]any{"stepId": step.StepId, "dependsOn": s.linkedStrings(step.DependsOn), "outputs": linkedOutputs(step.Outputs, s)}) + } + } + wf["steps"] = steps + if s.v.err != nil { + return nil + } + workflows = append(workflows, wf) + item.workflows[workflow.WorkflowId] = wf + } + item.root["workflows"] = workflows + item.indexSteps() + } + + return item +} + +func (s *sourceSession) register(item *linkedSource) bool { + for _, uri := range []string{item.identity, item.retrieval} { + if uri == "" { + continue + } + if other := s.byURI[uri]; other != nil && other != item { + s.fail(ErrorConfiguration, fmt.Sprintf("duplicate source document identity %q", uri)) + return false + } + } + for _, uri := range []string{item.identity, item.retrieval} { + if uri != "" { + s.byURI[uri] = item + } + } + return true +} + +func (s *sourceSession) checkDescription(source *linkedSource, description map[string]any, path string) bool { + expected := text(description["type"]) + if source.kind != "" { + s.complete("source-kind", s.descriptionFieldPath(path, "type"), source.identity) + } + if expected != "" && source.kind != "" && expected != source.kind { + s.v.add(CodeSourceType, s.descriptionFieldPath(path, "type"), fmt.Sprintf("source declares %s but supplied document is %s", expected, source.kind)) + return false + } + uri := resolveLinkedURI(text(description["url"]), s.base) + if source.kind == "arazzo" && source.identity != "" && source.identity != source.retrieval && uri != source.identity { + s.v.add(CodeReference, s.descriptionFieldPath(path, "url"), "Arazzo source reference must use the target $self identity") + return false + } + return true +} + +func (s *sourceSession) descriptionPath(name string) string { + if path := s.descriptionPaths[name]; path != "" { + return path + } + if s.scopePath != "" { + return s.scopePath + } + return "/sourceDescriptions" +} + +// lookup is also used by dependency validation. It loads a complete document at +// most once per URI and never expands its imports while a resolver is loading it. +func (s *sourceSession) lookup(name, path string) *linkedSource { + description := s.byName[name] + if description == nil { + s.v.add(CodeReference, path, fmt.Sprintf("unknown source description %q", name)) + return nil + } + uri := resolveLinkedURI(text(description["url"]), s.base) + if item, seen := s.requests[uri]; seen { + if item == nil { + s.v.incomplete("linked-target", path, name, "source document is unavailable") + } else if !s.checkDescription(item, description, s.descriptionPath(name)) { + return nil + } + return item + } + item := s.byURI[uri] + if item == nil && s.v.opts.resolver != nil && s.v.check() { + if s.count >= s.v.opts.limits.MaxSources { + s.fail(ErrorLimit, "total source document limit exceeded") + return nil + } + resolved, err := s.v.opts.resolver.Resolve(s.v.ctx, upstream.SourceRequest{Name: name, URL: uri, Type: text(description["type"]), BaseURI: ""}) + if err != nil { + if errors.Is(err, upstream.ErrUnresolvedSourceDesc) { + s.requests[uri] = nil + s.v.incomplete("linked-target", path, name, "source document is unavailable") + return nil + } + s.v.err = &Error{Kind: ErrorOperational, Location: s.v.location(path), Cause: err} + return nil + } + item = s.build(resolved) + if s.v.err != nil { + return nil + } + if item != nil && !s.register(item) { + return nil + } + } + s.requests[uri] = item + if item == nil { + s.v.incomplete("linked-target", path, name, "source document was not supplied") + return nil + } + if !s.checkDescription(item, description, s.descriptionPath(name)) { + return nil + } + if item.kind == "" { + s.v.incomplete("linked-target", path, name, "resolver returned an incomplete source placeholder") + return nil + } + return item +} + +func sourceQualified(value string) (name, target string, ok bool) { + if !strings.HasPrefix(value, "$sourceDescriptions.") { + return "", "", false + } + rest := strings.TrimPrefix(value, "$sourceDescriptions.") + index := strings.IndexByte(rest, '.') + if index < 1 || index == len(rest)-1 { + return "", "", false + } + return rest[:index], rest[index+1:], true +} + +func (s *sourceSession) qualified(value string) (name, target string, ok bool) { + if !strings.HasPrefix(value, "$sourceDescriptions.") { + return "", "", false + } + rest := strings.TrimPrefix(value, "$sourceDescriptions.") + for candidate := range s.byName { + prefix := candidate + "." + if len(candidate) > len(name) && strings.HasPrefix(rest, prefix) && len(rest) > len(prefix) { + name = candidate + target = strings.TrimPrefix(rest, prefix) + ok = true + } + } + if ok { + return + } + return sourceQualified(value) +} + +func (s *sourceSession) sourcePath(source *linkedSource, pointer string) string { + uri := source.retrieval + if uri == "" { + uri = source.identity + } + key := uri + "#" + pointer + location := Location{URI: uri, Pointer: pointer} + for ancestor := pointer; ; { + if node := source.nodes[ancestor].value; node != nil { + location.Line, location.Column = node.Line, node.Column + break + } + if ancestor == "" { + break + } + if index := strings.LastIndex(ancestor, "/"); index >= 0 { + ancestor = ancestor[:index] + } else { + ancestor = "" + } + } + if s.v.foreignLocations == nil { + s.v.foreignLocations = make(map[string]Location) + } + s.v.foreignLocations[key] = location + return key +} + +func (s *sourceSession) descriptionFieldPath(path, field string) string { + if s.scopeSource != nil { + if location, ok := s.v.foreignLocations[path]; ok { + return s.sourcePath(s.scopeSource, joinPtr(location.Pointer, field)) + } + } + return joinPtr(path, field) +} + +func (s *sourceSession) complete(name, path, source string) { + location := s.v.location(path) + s.v.recordCheck(Check{Name: name, Status: CheckComplete, URI: location.URI, Pointer: location.Pointer, Source: source}) +} + +func (s *sourceSession) charge(nodes, bytes int) bool { + if !s.v.check() { + return false + } + s.nodes += nodes + s.bytes += bytes + if s.nodes > s.v.opts.limits.MaxNodes || s.bytes > s.v.opts.limits.MaxBytes { + s.fail(ErrorLimit, "total source document resources exceeded") + return false + } + return true +} diff --git a/arazzo/sources_graph.go b/arazzo/sources_graph.go new file mode 100644 index 0000000..977723c --- /dev/null +++ b/arazzo/sources_graph.go @@ -0,0 +1,219 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "fmt" + "strings" +) + +func (s *sourceSession) externalStep(value, path string) (dependencyID, bool) { + name, rest, ok := s.qualified(value) + if !ok { + return dependencyID{}, false + } + wf, step, ok := strings.Cut(rest, ".steps.") + if !ok || wf == "" || step == "" { + s.v.add(CodeReference, path, "invalid external step dependency reference") + return dependencyID{}, false + } + target := s.workflow("$sourceDescriptions."+name+"."+wf, path) + if target == nil { + return dependencyID{}, false + } + if target.node == nil { + s.v.incomplete("step-dependency", path, name, "adapter does not expose workflow steps") + return dependencyID{}, false + } + if target.source.steps[wf][step] != nil { + s.graphSource(target.source, path, 1) + return dependencyID{document: target.source.identity, workflow: wf, step: step}, true + } + s.v.add(CodeReference, path, fmt.Sprintf("step %q does not exist in workflow %q", step, wf)) + return dependencyID{}, false +} + +// graphSource adds prerequisites only. Merely importing another document is not +// an execution dependency and does not load that document's unused imports. +func (s *sourceSession) graphSource(source *linkedSource, path string, depth int) { + if s.graphVisited[source] || source.kind != "arazzo" { + return + } + s.graphVisited[source] = true + if depth > s.v.opts.limits.MaxDepth { + s.fail(ErrorLimit, "source dependency depth limit exceeded") + return + } + if source.root == nil { + s.v.incomplete("external-prerequisites", path, source.identity, "adapter does not expose workflow dependency metadata") + return + } + child := &sourceSession{v: s.v, sourceState: s.sourceState, sourceScope: sourceScope{ + base: source.base, scopePath: path, scopeSource: source, + descriptionPaths: make(map[string]string), byName: make(map[string]map[string]any), + }} + for i, value := range array(source.root["sourceDescriptions"]) { + description := object(value) + child.byName[text(description["name"])] = description + child.descriptionPaths[text(description["name"])] = s.sourcePath(source, fmt.Sprintf("/sourceDescriptions/%d", i)) + } + for wi, value := range array(source.root["workflows"]) { + if !s.v.check() { + return + } + workflow := object(value) + wf := text(workflow["workflowId"]) + wid := dependencyID{document: source.identity, workflow: wf} + s.v.graph.addNode(wid) + steps := make(map[string]bool) + for _, entry := range array(workflow["steps"]) { + id := text(object(entry)["stepId"]) + steps[id] = true + s.v.graph.addEdge(wid, dependencyID{document: source.identity, workflow: wf, step: id}, "") + if len(array(workflow["dependsOn"])) > 0 { + s.v.graph.addEdge(dependencyID{document: source.identity, workflow: wf, step: id}, dependencyID{document: source.identity, workflow: wf, prerequisites: true}, "") + } + } + for di, raw := range array(workflow["dependsOn"]) { + path := s.sourcePath(source, fmt.Sprintf("/workflows/%d/dependsOn/%d", wi, di)) + ref := text(raw) + to := dependencyID{document: source.identity, workflow: ref} + if strings.HasPrefix(ref, "$sourceDescriptions.") { + target := child.workflow(ref, path) + if target == nil { + continue + } + _, id, _ := child.qualified(ref) + to = dependencyID{document: target.source.identity, workflow: id} + child.graphSource(target.source, path, depth+1) + } else if source.workflows[ref] == nil { + s.v.add(CodeReference, path, fmt.Sprintf("external document %q references missing workflow %q", source.identity, ref)) + continue + } + s.v.graph.addEdge(dependencyID{document: source.identity, workflow: wf, prerequisites: true}, to, path) + } + for si, entry := range array(workflow["steps"]) { + step := object(entry) + from := dependencyID{document: source.identity, workflow: wf, step: text(step["stepId"])} + for di, raw := range array(step["dependsOn"]) { + path := s.sourcePath(source, fmt.Sprintf("/workflows/%d/steps/%d/dependsOn/%d", wi, si, di)) + ref := text(raw) + to := dependencyID{document: source.identity, workflow: wf, step: ref} + if strings.HasPrefix(ref, "$sourceDescriptions.") { + name, rest, ok := child.qualified(ref) + other, otherStep, valid := strings.Cut(rest, ".steps.") + if !ok || !valid { + s.v.add(CodeReference, path, "invalid external step dependency reference") + continue + } + target := child.workflow("$sourceDescriptions."+name+"."+other, path) + if target == nil { + continue + } + found := target.source.steps[other][otherStep] != nil + if !found { + if target.node == nil { + s.v.incomplete("external-prerequisites", path, target.source.identity, "workflow step metadata unavailable") + } else { + s.v.add(CodeReference, path, "external step prerequisite does not exist") + } + continue + } + to = dependencyID{document: target.source.identity, workflow: other, step: otherStep} + child.graphSource(target.source, path, depth+1) + } else if strings.HasPrefix(ref, "$workflows.") { + rest := strings.TrimPrefix(ref, "$workflows.") + other, otherStep, ok := strings.Cut(rest, ".steps.") + if !ok { + s.v.add(CodeReference, path, "invalid cross-workflow step dependency") + continue + } + found := source.steps[other][otherStep] != nil + if !found { + s.v.add(CodeReference, path, "external cross-workflow step prerequisite does not exist") + continue + } + to = dependencyID{document: source.identity, workflow: other, step: otherStep} + } else if !steps[ref] { + s.v.add(CodeReference, path, fmt.Sprintf("external step prerequisite %q does not exist", ref)) + continue + } + s.v.graph.addEdge(from, to, path) + } + } + } + child.graphExpressions(source) + if source.partialExpressions { + s.v.incomplete("external-prerequisites", path, source.identity, "rootless Arazzo model lacks complete expression-bearing fields; supply original nodes or bytes") + } else { + s.complete("external-prerequisites", path, source.identity) + } +} + +// graphExpressions reuses the expression traversal and symbol checks on supplied +// documents. It creates local indexes but shares the prerequisite graph and +// bounded source cache; it does not execute workflows or rebuild source models. +func (s *sourceSession) graphExpressions(source *linkedSource) { + version := strings.Split(text(source.root["arazzo"]), ".") + if len(version) < 2 { + return + } + feature := version[0] + "." + version[1] + if feature != "1.0" && feature != "1.1" { + s.v.incomplete("external-expression-grammar", "", source.identity, "source has an unsupported expression feature version") + return + } + local := &semanticIndex{workflows: make(map[string]*semanticWorkflow), components: make(map[string]map[string]any), sources: make(map[string]string)} + for name, description := range s.byName { + local.sources[name] = text(description["url"]) + } + for _, collection := range []string{"parameters", "successActions", "failureActions"} { + local.components[collection] = object(object(source.root["components"])[collection]) + } + for wi, raw := range array(source.root["workflows"]) { + value := object(raw) + workflow := &semanticWorkflow{id: text(value["workflowId"]), value: value, path: s.sourcePath(source, fmt.Sprintf("/workflows/%d", wi)), steps: make(map[string]*semanticStep)} + for si, raw := range array(value["steps"]) { + value := object(raw) + step := &semanticStep{id: text(value["stepId"]), value: value, path: s.sourcePath(source, fmt.Sprintf("/workflows/%d/steps/%d", wi, si)), position: si} + workflow.steps[step.id] = step + workflow.order = append(workflow.order, step) + if len(array(value["dependsOn"])) > 0 { + workflow.hasStepDependencies = true + } + } + local.workflows[workflow.id] = workflow + local.order = append(local.order, workflow) + } + locations := make(map[string]Location, len(source.nodes)) + for pointer := range source.nodes { + key := s.sourcePath(source, pointer) + locations[pointer] = s.v.foreignLocations[key] + } + uri := source.retrieval + if uri == "" { + uri = source.identity + } + nested := &validation{identity: source.identity, budget: s.v.budget, ctx: s.v.ctx, doc: Document{URI: uri}, opts: s.v.opts, result: s.v.result, root: source.root, nodes: source.nodes, version: feature, local: local, graph: s.v.graph, foreignLocations: locations} + child := &sourceSession{v: nested, sourceState: s.sourceState, sourceScope: s.sourceScope} + nested.sources = child + checkExpressions(nested) + if nested.check() { + checkInputs(nested) + } + for i := range nested.expressionUses { + nested.expressionUses[i].Path = s.sourcePath(source, nested.expressionUses[i].Path) + } + nested.foreignLocations = s.v.foreignLocations + if nested.err == nil { + child.requestExpressions(nested.expressionUses) + finalizeExpressionUses(nested) + if nested.check() { + finalizeLinkedExpressionUses(nested) + } + } + if nested.err != nil { + s.v.err = nested.err + } +} diff --git a/arazzo/sources_metadata.go b/arazzo/sources_metadata.go new file mode 100644 index 0000000..7fd8371 --- /dev/null +++ b/arazzo/sources_metadata.go @@ -0,0 +1,370 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "fmt" + "sort" + "strconv" + "strings" + + highArazzo "github.com/pb33f/libopenapi/datamodel/high/arazzo" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/orderedmap" +) + +func (source *linkedSource) indexRaw() { + switch source.kind { + case "arazzo": + for _, value := range array(source.root["workflows"]) { + workflow := object(value) + if id := text(workflow["workflowId"]); id != "" { + source.workflows[id] = workflow + } + } + source.indexSteps() + case "openapi": + for _, collection := range []string{"paths", "webhooks"} { + values := object(source.root[collection]) + for _, key := range sortedKeys(values) { + value := values[key] + source.indexPathItem(object(value), joinPtr("/"+collection, key), true, make(map[string]bool)) + } + } + items := object(object(source.root["components"])["pathItems"]) + for _, key := range sortedKeys(items) { + value := items[key] + source.indexPathItem(object(value), joinPtr("/components/pathItems", key), false, make(map[string]bool)) + } + callbacks := object(object(source.root["components"])["callbacks"]) + for _, key := range sortedKeys(callbacks) { + value := callbacks[key] + callback := source.deref(object(value)) + for _, expression := range sortedKeys(callback) { + item := callback[expression] + if !strings.HasPrefix(expression, "x-") { + source.indexPathItem(object(item), joinPtr(joinPtr("/components/callbacks", key), expression), false, make(map[string]bool)) + } + } + } + + case "asyncapi": + channels := object(source.root["channels"]) + for _, name := range sortedKeys(channels) { + value := channels[name] + channel := source.deref(object(value)) + source.pointers[joinPtr("/channels", name)] = &linkedTarget{source: source, kind: "channel", node: channel} + for _, action := range []string{"publish", "subscribe"} { + operation := source.deref(object(channel[action])) + if operation != nil { + source.addOperation(operation, joinPtr(joinPtr("/channels", name), action), nil) + } + } + } + operations := object(source.root["operations"]) + for _, name := range sortedKeys(operations) { + value := operations[name] + operation := source.deref(object(value)) + target := source.addOperation(operation, joinPtr("/operations", name), nil) + if text(operation["operationId"]) == "" && target != nil { + source.operations[name] = append(source.operations[name], target) + } + } + } +} + +// indexSteps gives every source-qualified dependency and expression the same +// scoped lookup. Repeated references never rescan a workflow's step array. +func (source *linkedSource) indexSteps() { + source.steps = make(map[string]map[string]map[string]any) + for wi, raw := range array(source.root["workflows"]) { + workflow := object(raw) + items := array(workflow["steps"]) + path := source.owner.sourcePath(source, fmt.Sprintf("/workflows/%d/steps", wi)) + if !source.owner.v.work(len(items)+1, path) { + return + } + steps := make(map[string]map[string]any, len(items)) + for _, raw := range items { + if !source.owner.v.check() { + return + } + step := object(raw) + steps[text(step["stepId"])] = step + } + source.steps[text(workflow["workflowId"])] = steps + } +} + +// deref memoizes complete local-reference chains within this validation call. +// Every uncached hop consumes bounded work; external refs never trigger I/O. +func (source *linkedSource) deref(node map[string]any) map[string]any { + if source.refMemo == nil { + source.refMemo = make(map[string]map[string]any) + } + visited := map[string]bool{} + chain := []string{} + finish := func(result map[string]any) map[string]any { + for _, ref := range chain { + source.refMemo[ref] = result + } + return result + } + for node != nil { + ref := text(node["$ref"]) + if ref == "" { + return finish(node) + } + if source.owner != nil && !source.owner.v.work(1, source.owner.sourcePath(source, linkedRefLocation(ref))) { + return nil + } + if cached, ok := source.refMemo[ref]; ok { + return finish(cached) + } + if visited[ref] { + return finish(nil) + } + visited[ref] = true + chain = append(chain, ref) + if !strings.HasPrefix(ref, "#") { + source.unresolvedRefs = true + return finish(nil) + } + tokens, ok := linkedPointerTokens(strings.TrimPrefix(ref, "#")) + if !ok { + return finish(nil) + } + var value any = source.root + for _, token := range tokens { + switch item := value.(type) { + case map[string]any: + value = item[token] + case []any: + index, err := strconv.Atoi(token) + if err != nil || index < 0 || index >= len(item) { + return finish(nil) + } + value = item[index] + default: + return finish(nil) + } + } + node = object(value) + } + return finish(nil) +} + +func (source *linkedSource) addOperation(operation map[string]any, pointer string, inherited any) *linkedTarget { + return source.addOperationMetadata(operation, pointer, inherited, true) +} + +func (source *linkedSource) addOperationMetadata(operation map[string]any, pointer string, inherited any, registerID bool) *linkedTarget { + if operation == nil { + return nil + } + if source.owner != nil && !source.owner.charge(1, len(pointer)) { + return nil + } + target := &linkedTarget{source: source, kind: source.kind, node: operation, parameters: make(map[linkedParameterKey]bool), parametersComplete: true} + for _, list := range []any{inherited, operation["parameters"]} { + for _, value := range array(list) { + if source.owner != nil && !source.owner.v.work(1, source.owner.sourcePath(source, pointer)) { + return nil + } + parameter := source.deref(object(value)) + if parameter == nil { + target.parametersComplete = false + continue + } + target.parameters[linkedParameterIdentity(text(parameter["name"]), text(parameter["in"]))] = true + } + } + source.pointers[pointer] = target + if id := text(operation["operationId"]); registerID && id != "" { + source.operations[id] = append(source.operations[id], target) + } + return target +} + +func (source *linkedSource) indexOpenAPI(document *v3.Document, s *sourceSession) { + if document.Paths == nil || document.Paths.PathItems == nil { + return + } + for path, item := range document.Paths.PathItems.FromOldest() { + if !s.charge(2, len(path)) { + return + } + if item == nil { + continue + } + operations := map[string]*v3.Operation{"get": item.Get, "put": item.Put, "post": item.Post, "delete": item.Delete, "options": item.Options, "head": item.Head, "patch": item.Patch, "trace": item.Trace, "query": item.Query} + if item.AdditionalOperations != nil { + if !s.charge(item.AdditionalOperations.Len(), 0) { + return + } + for method, operation := range item.AdditionalOperations.FromOldest() { + if !s.charge(1, len("additionalOperations/")+3*len(method)) { + return + } + operations["additionalOperations/"+strings.ReplaceAll(strings.ReplaceAll(method, "~", "~0"), "/", "~1")] = operation + } + } + operationNames := make([]string, 0, len(operations)) + for method := range operations { + operationNames = append(operationNames, method) + } + sort.Strings(operationNames) + for _, method := range operationNames { + operation := operations[method] + if operation == nil { + continue + } + if !s.charge(2, len(method)+len(operation.OperationId)) { + return + } + target := &linkedTarget{source: source, kind: "openapi", parameters: make(map[linkedParameterKey]bool), parametersComplete: true} + for _, list := range [][]*v3.Parameter{item.Parameters, operation.Parameters} { + for _, parameter := range list { + if parameter == nil { + continue + } + if !s.charge(2, len(parameter.Name)+len(parameter.In)) { + return + } + if parameter.Reference != "" && parameter.Name == "" { + target.parametersComplete = false + continue + } + target.parameters[linkedParameterIdentity(parameter.Name, parameter.In)] = true + } + } + source.pointers[joinPtr("/paths", path)+"/"+method] = target + if operation.OperationId != "" { + source.operations[operation.OperationId] = append(source.operations[operation.OperationId], target) + } + } + } +} + +func (s *sourceSession) linkedStrings(values []string) []any { + totalBytes := 0 + for _, value := range values { + totalBytes += len(value) + } + if !s.charge(len(values)+1, totalBytes) { + return nil + } + result := make([]any, len(values)) + for i, value := range values { + result[i] = value + } + return result +} + +func linkedOutputs(values *orderedmap.Map[string, *highArazzo.OutputValue], s *sourceSession) map[string]any { + result := make(map[string]any) + if values != nil { + for name, value := range values.FromOldest() { + if !s.charge(2, len(name)) { + return nil + } + if value != nil { + if expression, ok := value.GetExpression(); ok { + if !s.charge(1, len(expression)) { + return nil + } + result[name] = expression + } else if selector, ok := value.GetSelector(); ok && selector != nil { + if !s.charge(7, len(selector.Context)+len(selector.Selector)+len(selector.Type)) { + return nil + } + node := map[string]any{"context": selector.Context, "selector": selector.Selector, "type": selector.Type} + if selector.ExpressionType != nil { + if !s.charge(5, len(selector.ExpressionType.Type)+len(selector.ExpressionType.Version)) { + return nil + } + node["type"] = map[string]any{"type": selector.ExpressionType.Type, "version": selector.ExpressionType.Version} + } + result[name] = node + } + } else { + result[name] = nil + } + } + } + return result +} + +// indexPathItem includes callback operations, while component definitions supply +// pointer targets without inventing additional callable operation identities. +func (source *linkedSource) indexPathItem(raw map[string]any, path string, registerID bool, active map[string]bool) { + if source.owner != nil && !source.owner.charge(1, len(path)) { + return + } + ref := text(raw["$ref"]) + if ref != "" { + if active[ref] { + source.unresolvedRefs = true + return + } + active[ref] = true + defer delete(active, ref) + } + item := source.deref(raw) + if item == nil { + return + } + operations := map[string]map[string]any{} + for _, method := range sortedKeys(item) { + value := item[method] + if httpMethod(method) { + operations[method] = source.deref(object(value)) + } + } + additional := object(item["additionalOperations"]) + for _, method := range sortedKeys(additional) { + value := additional[method] + operations[joinPtr("additionalOperations", method)] = source.deref(object(value)) + } + operationNames := make([]string, 0, len(operations)) + for method := range operations { + operationNames = append(operationNames, method) + } + sort.Strings(operationNames) + for _, method := range operationNames { + operation := operations[method] + opPath := path + "/" + method + source.addOperationMetadata(operation, opPath, item["parameters"], registerID) + callbacks := object(operation["callbacks"]) + for _, name := range sortedKeys(callbacks) { + value := callbacks[name] + rawCallback := object(value) + ref := text(rawCallback["$ref"]) + if ref != "" && active[ref] { + source.unresolvedRefs = true + continue + } + if ref != "" { + active[ref] = true + } + callback := source.deref(rawCallback) + for _, expression := range sortedKeys(callback) { + value := callback[expression] + if !strings.HasPrefix(expression, "x-") { + source.indexPathItem(object(value), joinPtr(joinPtr(opPath+"/callbacks", name), expression), registerID, active) + } + } + if ref != "" { + delete(active, ref) + } + } + } +} + +func linkedRefLocation(ref string) string { + if strings.HasPrefix(ref, "#/") { + return strings.TrimPrefix(ref, "#") + } + return "" +} diff --git a/arazzo/sources_regression_test.go b/arazzo/sources_regression_test.go new file mode 100644 index 0000000..c912cd9 --- /dev/null +++ b/arazzo/sources_regression_test.go @@ -0,0 +1,171 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/pb33f/go-yaml" + upstream "github.com/pb33f/libopenapi/arazzo" + highArazzo "github.com/pb33f/libopenapi/datamodel/high/arazzo" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/orderedmap" +) + +func TestSourcesRootlessArazzoPrerequisiteCoverage(t *testing.T) { + output := func() *orderedmap.Map[string, *highArazzo.OutputValue] { + result := orderedmap.New[string, *highArazzo.OutputValue]() + result.Set("id", highArazzo.NewExpressionOutputValue("$response.body#/id")) + return result + } + value := func(expression string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: expression} + } + model := &highArazzo.Arazzo{Arazzo: "1.1.0", Workflows: []*highArazzo.Workflow{{WorkflowId: "remote", Steps: []*highArazzo.Step{ + {StepId: "first", OperationId: "read", Outputs: output(), Parameters: []*highArazzo.Parameter{{Name: "id", In: "query", Value: value("$steps.second.outputs.id")}}}, + {StepId: "second", OperationId: "read", Outputs: output(), Parameters: []*highArazzo.Parameter{{Name: "id", In: "query", Value: value("$steps.first.outputs.id")}}}, + }}}} + input := strings.Replace(sourceTestDocument(" workflowId: $sourceDescriptions.api.remote\n"), "type: openapi", "type: arazzo", 1) + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", ArazzoDocument: model})) + if err != nil || !result.Valid() || result.Complete { + t.Fatalf("partial model falsely completed prerequisites: %+v %v", result, err) + } + for _, check := range result.Checks { + if check.Name == "external-prerequisites" && check.Status == CheckIncomplete && check.URI == "https://example.test/main.yaml" && check.Pointer == "/workflows/0/steps/0/workflowId" && check.Source == "https://example.test/api.yaml" { + return + } + } + t.Fatalf("partial source coverage lacked authored coordinates: %+v", result.Checks) +} + +func TestSourcesCrossDocumentImplicitCycle(t *testing.T) { + main := `arazzo: 1.1.0 +info: {title: Main, version: '1'} +sourceDescriptions: [{name: flows, url: flows.yaml, type: arazzo}] +workflows: +- workflowId: main + steps: + - stepId: first + workflowId: $sourceDescriptions.flows.remote + parameters: [{name: id, value: $sourceDescriptions.flows.remote.outputs.id}] + outputs: {id: $response.body#/id} + outputs: {id: $steps.first.outputs.id} +` + external := `arazzo: 1.1.0 +info: {title: Other, version: '1'} +sourceDescriptions: [{name: main, url: main.yaml, type: arazzo}] +workflows: +- workflowId: remote + steps: + - stepId: first + workflowId: $sourceDescriptions.main.main + parameters: [{name: id, value: $sourceDescriptions.main.main.outputs.id}] + outputs: {id: $response.body#/id} + outputs: {id: $steps.first.outputs.id} +` + for _, cycle := range []bool{false, true} { + source := external + if !cycle { + source = strings.Replace(source, "$sourceDescriptions.main.main.outputs.id", "literal", 1) + } + result, err := ValidateBytes(context.Background(), []byte(main), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/flows.yaml", SourceBytes: []byte(source)})) + if err != nil || !result.Complete { + t.Fatalf("cross-document check did not finish: %+v %v", result, err) + } + if !cycle { + if !result.Valid() { + t.Fatalf("import cycle became a prerequisite cycle: %+v", result.Diagnostics) + } + continue + } + diagnostic := foundationFinding(t, result, CodeDependency, "/workflows/0/steps/0/parameters/0/value") + if diagnostic.URI != "https://example.test/flows.yaml" || diagnostic.Line != 9 || diagnostic.Column != 36 { + t.Fatalf("external cycle attribution lost: %+v", diagnostic) + } + } +} + +func TestSourcesDottedStepDependencies(t *testing.T) { + input := `arazzo: 1.1.0 +info: {title: Main, version: '1'} +sourceDescriptions: [{name: other.name, url: other.yaml, type: arazzo}] +workflows: +- workflowId: main + steps: + - {stepId: first, workflowId: main, dependsOn: ['$sourceDescriptions.other.name.target.steps.first']} +` + external := strings.Replace(foundationYAML("1.1.0"), "workflowId: run", "workflowId: target", 1) + external = strings.Replace(external, "stepId: read", "stepId: first", 1) + for _, step := range []string{"first", "missing"} { + data := strings.Replace(input, "target.steps.first", "target.steps."+step, 1) + result, err := ValidateBytes(context.Background(), []byte(data), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/other.yaml", SourceBytes: []byte(external)})) + if err != nil || !result.Complete { + t.Fatalf("dotted source dependency did not finish: %+v %v", result, err) + } + if step == "first" { + if !result.Valid() { + t.Fatalf("known dotted source rejected: %+v", result.Diagnostics) + } + } else { + foundationFinding(t, result, CodeReference, "/workflows/0/steps/0/dependsOn/0") + } + } +} + +func TestSourcesRootlessAdditionalOperationCaps(t *testing.T) { + for _, populated := range []bool{false, true} { + additional := orderedmap.New[string, *v3.Operation]() + for i := range 1000 { + var operation *v3.Operation + if populated { + operation = &v3.Operation{OperationId: fmt.Sprintf("read%d", i)} + } + additional.Set(fmt.Sprintf("METHOD/%d", i), operation) + } + paths := orderedmap.New[string, *v3.PathItem]() + paths.Set("/items", &v3.PathItem{Get: &v3.Operation{OperationId: "read"}, AdditionalOperations: additional}) + model := &v3.Document{Version: "3.2.0", Paths: &v3.Paths{PathItems: paths}} + limits := DefaultLimits() + limits.MaxNodes, limits.MaxBytes = 100, 1000 + result, err := ValidateBytes(context.Background(), []byte(foundationYAML("1.1.0")), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", OpenAPIDocument: model}), WithLimits(limits)) + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorLimit || result.Complete { + t.Fatalf("rootless additional operations escaped caps: %+v %v", result, err) + } + if additional.Len() != 1000 { + t.Fatal("caller operation map changed") + } + } +} + +func TestSourcesCriterionHeaderTokenIdentity(t *testing.T) { + for _, version := range []string{"1.0.1", "1.1.0"} { + for _, name := range []string{"X&&Id", "X||true", "X!"} { + for _, declared := range []bool{false, true} { + api := strings.Replace(sourceOpenAPI, "X-ID", name, 1) + used := name + if !declared { + used += "missing" + } + input := sourceTestDocument(" operationId: read\n successCriteria:\n - condition: \"$request.header." + used + "=='x'\"\n") + input = strings.Replace(input, "1.1.0", version, 1) + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(sourceCandidate(api))) + if err != nil || !result.Complete { + t.Fatalf("header token check did not finish: %+v %v", result, err) + } + if declared { + if !result.Valid() { + t.Fatalf("declared full RFC token was split: %+v", result.Diagnostics) + } + } else { + foundationFinding(t, result, CodeReference, "/workflows/0/steps/0/successCriteria/0/condition") + } + } + } + } +} diff --git a/arazzo/sources_targets.go b/arazzo/sources_targets.go new file mode 100644 index 0000000..4c0956c --- /dev/null +++ b/arazzo/sources_targets.go @@ -0,0 +1,515 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "fmt" + "net/url" + "sort" + "strings" + "unicode/utf8" + + upstream "github.com/pb33f/libopenapi/arazzo" + "github.com/pb33f/libopenapi/arazzo/expression" +) + +func (s *sourceSession) workflow(value, path string) *linkedTarget { + name, id, ok := s.qualified(value) + if !ok { + return nil + } + source := s.lookup(name, path) + if source == nil { + return nil + } + if source.kind != "arazzo" { + s.v.add(CodeSourceType, path, "workflow reference requires an Arazzo source") + return nil + } + workflow := source.workflows[id] + if workflow == nil && source.root == nil && source.adapter != nil { + adapter, ok := source.adapter.(upstream.ArazzoSourceAdapter) + if !ok { + s.v.incomplete("workflow-target", path, name, "adapter does not expose workflow lookup") + return nil + } + if adapter.HasWorkflow(id) { + s.complete("workflow-target", path, source.identity) + s.v.incomplete("workflow-metadata", path, name, "adapter exposes workflow presence only") + return &linkedTarget{source: source, kind: "workflow"} + } + } + if workflow == nil { + s.v.add(CodeReference, path, fmt.Sprintf("workflow %q does not exist in source %q", id, name)) + return nil + } + s.complete("workflow-target", path, source.identity) + return &linkedTarget{source: source, kind: "workflow", node: workflow} +} + +func (s *sourceSession) step(step map[string]any, path string) *linkedTarget { + if value := text(step["workflowId"]); strings.HasPrefix(value, "$sourceDescriptions.") { + target := s.workflow(value, path+"/workflowId") + if target != nil { + s.graphSource(target.source, path+"/workflowId", 1) + s.async(step, path, target) + } + return target + } + if value := text(step["operationId"]); value != "" { + return s.operation(value, path+"/operationId", step, path) + } + for _, field := range []string{"operationPath", "channelPath"} { + if value := text(step[field]); value != "" { + return s.pointer(value, path+"/"+field, field, step, path) + } + } + return nil +} + +func (s *sourceSession) operation(value, path string, step map[string]any, stepPath string) *linkedTarget { + name, id, qualified := s.qualified(value) + if !qualified { + names := []string{} + for n, d := range s.byName { + if text(d["type"]) != "arazzo" { + names = append(names, n) + } + } + sort.Strings(names) + if len(names) > 1 { + s.v.add(CodeReference, path, "operationId must identify its source when multiple API sources are declared") + return nil + } + if len(names) == 0 { + s.v.add(CodeReference, path, "operationId requires an API source description") + return nil + } + name = names[0] + id = value + } + source := s.lookup(name, path) + if source == nil { + return nil + } + if source.kind != "openapi" && source.kind != "asyncapi" { + s.v.add(CodeSourceType, path, "operation reference requires an OpenAPI or AsyncAPI source") + return nil + } + targets := source.operations[id] + if len(targets) > 1 { + s.v.add(CodeReference, path, fmt.Sprintf("operationId %q is not unique in source %q", id, name)) + return nil + } + if len(targets) == 1 { + s.complete("operation-target", path, source.identity) + s.async(step, stepPath, targets[0]) + return targets[0] + } + if source.root == nil && source.adapter != nil { + present := false + capable := false + switch adapter := source.adapter.(type) { + case upstream.OpenAPISourceAdapter: + capable = true + present = adapter.HasOperationID(id) + case upstream.AsyncAPISourceAdapter: + capable = true + present = adapter.HasOperation(id) + } + if !capable { + s.v.incomplete("operation-presence", path, name, "adapter does not expose operation lookup") + return nil + } + if present { + s.complete("operation-target", path, source.identity) + target := &linkedTarget{source: source, kind: source.kind} + s.v.incomplete("operation-metadata", path, name, "adapter exposes operation presence only") + s.async(step, stepPath, target) + return target + } + } + if source.unresolvedRefs { + s.v.incomplete("operation-presence", path, name, "source contains unavailable external references") + return nil + } + s.v.add(CodeReference, path, fmt.Sprintf("operation %q does not exist in source %q", id, name)) + return nil +} + +func (s *sourceSession) pointer(value, path, field string, step map[string]any, stepPath string) *linkedTarget { + prefix, fragment, ok := strings.Cut(value, "#") + if !ok { + s.v.add(CodeReference, path, "target requires a JSON Pointer fragment") + return nil + } + if !strings.HasPrefix(prefix, "{") || !strings.HasSuffix(prefix, "}") { + s.v.add(CodeReference, path, "target must use a source description runtime expression") + return nil + } + name, member, ok := s.qualified(prefix[1 : len(prefix)-1]) + if !ok || member != "url" { + s.v.add(CodeReference, path, "target must reference a source description url") + return nil + } + decoded, err := url.PathUnescape(fragment) + if err != nil { + s.v.add(CodeReference, path, "invalid URI fragment encoding") + return nil + } + tokens, ok := linkedPointerTokens(decoded) + if !ok { + s.v.add(CodeReference, path, "invalid JSON Pointer fragment") + return nil + } + source := s.lookup(name, path) + if source == nil { + return nil + } + expected := "operation" + if field == "channelPath" { + expected = "channel" + } + if field == "channelPath" && source.kind != "asyncapi" || field == "operationPath" && source.kind != "openapi" && source.kind != "asyncapi" { + s.v.add(CodeSourceType, path, "target source has the wrong document type") + return nil + } + canonical := "" + for _, token := range tokens { + canonical = joinPtr(canonical, token) + } + if target := source.pointers[canonical]; target != nil && ((expected == "operation" && target.kind != "channel") || expected == "channel" && target.kind == "channel") { + s.complete("pointer-target", path, source.identity) + s.async(step, stepPath, target) + return target + } + if source.root == nil && source.adapter != nil { + // Validate the complete target shape before asking a presence-only adapter. + shape := field == "operationPath" && source.kind == "openapi" && linkedOperationPointerShape(tokens) || source.kind == "asyncapi" && len(tokens) == 2 && (field == "channelPath" && tokens[0] == "channels" || field == "operationPath" && tokens[0] == "operations") + present := false + capable := false + if shape { + switch adapter := source.adapter.(type) { + case upstream.OpenAPISourceAdapter: + capable = true + present = adapter.HasOperationPath("#" + canonical) + case upstream.AsyncAPISourceAdapter: + capable = true + if expected == "channel" { + present = adapter.HasChannel(tokens[1]) + } else { + present = adapter.HasOperation(tokens[1]) + } + } + } + if shape && !capable { + s.v.incomplete("target-presence", path, name, "adapter does not expose target lookup") + return nil + } + if present { + s.complete("pointer-target", path, source.identity) + target := &linkedTarget{source: source, kind: source.kind} + s.v.incomplete("target-metadata", path, name, "adapter exposes target presence only") + s.async(step, stepPath, target) + return target + } + } + if source.unresolvedRefs { + s.v.incomplete("target-presence", path, name, "source contains unavailable external references") + return nil + } + s.v.add(CodeReference, path, "JSON Pointer does not identify the required target object") + return nil +} + +func linkedPointerTokens(pointer string) ([]string, bool) { + if !utf8.ValidString(pointer) { + return nil, false + } + if pointer == "" { + return nil, true + } + if !strings.HasPrefix(pointer, "/") { + return nil, false + } + parts := strings.Split(pointer[1:], "/") + for i, part := range parts { + var b strings.Builder + for j := 0; j < len(part); j++ { + if part[j] != '~' { + b.WriteByte(part[j]) + continue + } + j++ + if j == len(part) || part[j] != '0' && part[j] != '1' { + return nil, false + } + if part[j] == '0' { + b.WriteByte('~') + } else { + b.WriteByte('/') + } + } + parts[i] = b.String() + } + return parts, true +} + +func httpMethod(method string) bool { + switch method { + case "get", "put", "post", "delete", "options", "head", "patch", "trace", "query": + return true + } + return false +} + +func linkedParameterIdentity(name, in string) linkedParameterKey { + if in == "header" { + name = strings.ToLower(name) + } + return linkedParameterKey{name, in} +} + +func (s *sourceSession) parameters(inherited any, inheritedPath string, own any, ownPath string, target *linkedTarget) { + if target.kind == "workflow" { + if target.node == nil { + s.v.incomplete("workflow-inputs", ownPath, target.source.identity, "target workflow input metadata is unavailable") + return + } + defaults := s.v.resolveList(inherited, inheritedPath, "parameters") + ownItems := s.v.resolveList(own, ownPath, "parameters") + for _, parameter := range s.v.mergeEffective(defaults, ownItems, parameterIdentity, ownPath) { + if !s.v.work(1, parameter.path) { + return + } + s.complete("workflow-inputs", parameter.path, target.source.identity) + if semanticInputAbsent(object(target.node["inputs"]), text(parameter.value["name"])) { + s.v.add(CodeParameter, parameter.path+"/name", "parameter names an input excluded by the target workflow schema") + } + } + return + } + if target.source.kind != "openapi" { + if len(array(inherited))+len(array(own)) > 0 { + s.v.incomplete("asyncapi-parameters", ownPath, target.source.identity, "AsyncAPI parameter and message value compatibility is not available") + } + return + } + effective := map[linkedParameterKey]struct { + node map[string]any + path string + }{} + for li, list := range []any{inherited, own} { + base := inheritedPath + if li == 1 { + base = ownPath + } + for i, value := range array(list) { + parameter := object(value) + if ref := text(parameter["reference"]); ref != "" { + if strings.HasPrefix(ref, "$components.parameters.") { + parameter = object(object(object(s.v.root["components"])["parameters"])[strings.TrimPrefix(ref, "$components.parameters.")]) + } else { + continue + } + } + effective[linkedParameterIdentity(text(parameter["name"]), text(parameter["in"]))] = struct { + node map[string]any + path string + }{parameter, fmt.Sprintf("%s/%d", base, i)} + } + } + keys := make([]linkedParameterKey, 0, len(effective)) + for key := range effective { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].in == keys[j].in { + return keys[i].name < keys[j].name + } + return keys[i].in < keys[j].in + }) + for _, key := range keys { + parameter := effective[key] + if !s.v.work(1, parameter.path) { + return + } + if key.name == "" || key.in == "" { + continue + } + if target.parameters == nil || !target.parametersComplete { + s.v.incomplete("operation-parameters", parameter.path, target.source.identity, "operation parameter metadata is incomplete") + continue + } + s.complete("operation-parameters", parameter.path, target.source.identity) + if !target.parameters[key] { + s.v.add(CodeParameter, parameter.path+"/name", fmt.Sprintf("parameter %q in %s is not declared by the target operation", key.name, key.in)) + } + } +} + +func (s *sourceSession) actions(value any, path string) { + for i, value := range array(value) { + action := object(value) + actionPath := fmt.Sprintf("%s/%d", path, i) + if ref := text(action["reference"]); ref != "" { + for _, collection := range []string{"successActions", "failureActions"} { + prefix := "$components." + collection + "." + if strings.HasPrefix(ref, prefix) { + action = object(object(object(s.v.root["components"])[collection])[strings.TrimPrefix(ref, prefix)]) + break + } + } + } + if ref := text(action["workflowId"]); strings.HasPrefix(ref, "$sourceDescriptions.") { + target := s.workflow(ref, actionPath+"/workflowId") + if target != nil { + s.parameters(nil, "", action["parameters"], actionPath+"/parameters", target) + } + } + } +} + +func (s *sourceSession) async(step map[string]any, path string, target *linkedTarget) { + if target.source.kind != "asyncapi" { + for _, field := range []string{"action", "correlationId"} { + if _, present := step[field]; present { + s.v.add(CodeSourceType, path+"/"+field, field+" applies only to AsyncAPI steps") + } + } + return + } + action := text(step["action"]) + if action == "" { + action = text(target.node["action"]) + } + if text(step["correlationId"]) != "" && action != "" && action != "receive" { + s.v.add(CodeSourceType, path+"/correlationId", "correlationId applies only to receive steps") + } + if target.node == nil { + s.v.incomplete("asyncapi-metadata", path, target.source.identity, "adapter does not expose action, channel or message metadata") + return + } + if expected := text(target.node["action"]); expected != "" && action != "" && expected != action { + s.v.add(CodeReference, path+"/action", "step action differs from the referenced AsyncAPI operation") + } + channel := target.node + if target.kind != "channel" { + if value := target.source.deref(object(target.node["channel"])); value != nil { + channel = value + } + } + if action == "receive" && len(array(step["successCriteria"])) == 0 { + messages := object(channel["messages"]) + if len(messages) > 1 { + s.v.add(CodeReference, path, "receive step without successCriteria requires a single successful message type") + } else { + s.v.incomplete("asyncapi-success", path, target.source.identity, "message success semantics cannot be proved from static metadata") + } + } + if text(step["correlationId"]) != "" { + s.v.incomplete("asyncapi-correlation", path+"/correlationId", target.source.identity, "correlation value compatibility requires message data") + } +} + +func linkedOperationPointerShape(tokens []string) bool { + if len(tokens) < 3 { + return false + } + index := 2 + switch tokens[0] { + case "paths", "webhooks": + case "components": + if len(tokens) < 4 { + return false + } + switch tokens[1] { + case "pathItems": + index = 3 + case "callbacks": + index = 4 + default: + return false + } + default: + return false + } + for index < len(tokens) { + if tokens[index] == "additionalOperations" { + index++ + if index >= len(tokens) { + return false + } + } else if !httpMethod(tokens[index]) { + return false + } + index++ + if index == len(tokens) { + return true + } + if tokens[index] != "callbacks" || index+3 >= len(tokens) { + return false + } + index += 3 + } + return false +} + +// requestExpressions checks the declared HTTP parameter boundary. Dynamic body +// members do not require static properties, but named request parameters do. +func (s *sourceSession) requestExpressions(uses []expressionUse) { + for _, use := range uses { + location := "" + switch use.Expression.Type { + case expression.RequestHeader: + location = "header" + case expression.RequestQuery: + location = "query" + case expression.RequestPath: + location = "path" + default: + continue + } + workflow := s.v.local.workflows[use.WorkflowID] + if workflow == nil { + continue + } + steps := []string{use.StepID} + if use.StepID == "" { + // Inherited workflow parameters/actions are checked at every consuming step. + if strings.Contains(use.Path, "/parameters/") || strings.Contains(use.Path, "/successActions/") || strings.Contains(use.Path, "/failureActions/") { + steps = nil + for _, step := range workflow.order { + steps = append(steps, step.id) + } + } else { + s.v.incomplete("request-parameter-context", use.Path, "", "request expression has no static parent operation") + continue + } + } + for _, stepID := range steps { + step := workflow.steps[stepID] + if step == nil { + continue + } + key := dependencyID{document: s.v.documentID(), workflow: workflow.id, step: stepID} + target, known := s.targets[key] + if !known { + target = s.step(step.value, step.path) + s.targets[key] = target + } + if target == nil { + continue + } + if target.source.kind != "openapi" || target.parameters == nil || !target.parametersComplete { + s.v.incomplete("request-parameter-context", use.Path, target.source.identity, "source does not expose complete HTTP operation parameter metadata") + continue + } + s.complete("request-parameter-context", use.Path, target.source.identity) + if !target.parameters[linkedParameterIdentity(use.Expression.Property, location)] { + s.v.add(CodeReference, use.Path, fmt.Sprintf("request %s parameter %q is not declared by the parent operation", location, use.Expression.Property)) + } + } + } +} diff --git a/arazzo/sources_test.go b/arazzo/sources_test.go new file mode 100644 index 0000000..26fc60a --- /dev/null +++ b/arazzo/sources_test.go @@ -0,0 +1,697 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "strconv" + "strings" + "testing" + + "github.com/pb33f/go-yaml" + upstream "github.com/pb33f/libopenapi/arazzo" + highArazzo "github.com/pb33f/libopenapi/datamodel/high/arazzo" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/libopenapi/orderedmap" +) + +type sourceResolverFunc func(context.Context, upstream.SourceRequest) (*upstream.ResolvedSource, error) + +func (f sourceResolverFunc) Resolve(ctx context.Context, request upstream.SourceRequest) (*upstream.ResolvedSource, error) { + return f(ctx, request) +} + +type operationPresence struct{} + +func (operationPresence) SourceType() string { return "openapi" } +func (operationPresence) HasOperationID(value string) bool { return value == "read" } +func (operationPresence) HasOperationPath(string) bool { return true } + +type workflowPresence struct{} + +func (workflowPresence) SourceType() string { return "arazzo" } +func (workflowPresence) HasWorkflow(value string) bool { return value == "run" } + +const sourceOpenAPI = `openapi: 3.1.0 +info: {title: API, version: '1'} +paths: + /pets/~demo: + parameters: + - {name: X-ID, in: header, schema: {type: string}} + get: + operationId: read + responses: {'200': {description: OK}} +` + +func sourceTestDocument(step string) string { + return "arazzo: 1.1.0\ninfo: {title: Flow, version: '1'}\nsourceDescriptions:\n - {name: api, url: 'api.yaml', type: openapi}\nworkflows:\n - workflowId: run\n steps:\n - stepId: first\n" + step +} + +func sourceCandidate(data string) upstream.CandidateDocument { + return upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", SourceBytes: []byte(data)} +} + +func sourceDiagnostic(t *testing.T, result *Result, code Code, pointer string) { + t.Helper() + for _, d := range result.Diagnostics { + if d.Code == code && d.Pointer == pointer { + if d.Line == 0 || d.Column == 0 { + t.Fatalf("missing source coordinates: %+v", d) + } + return + } + } + t.Fatalf("missing %s at %s: %+v", code, pointer, result.Diagnostics) +} + +func TestSourcesOfflineTargets(t *testing.T) { + cases := []struct { + name, step string + code Code + pointer string + }{ + {"qualified operation", " operationId: $sourceDescriptions.api.read\n", "", ""}, + {"bare operation", " operationId: read\n", "", ""}, + {"escaped pointer", " operationPath: '{$sourceDescriptions.api.url}#/paths/~1pets~1~0demo/get'\n", "", ""}, + {"percent encoded fragment", " operationPath: '{$sourceDescriptions.api.url}#%2Fpaths%2F~1pets~1~0demo%2Fget'\n", "", ""}, + {"absent operation", " operationId: $sourceDescriptions.api.missing\n", CodeReference, "/workflows/0/steps/0/operationId"}, + {"trailing pointer member", " operationPath: '{$sourceDescriptions.api.url}#/paths/~1pets~1~0demo/get/responses'\n", CodeReference, "/workflows/0/steps/0/operationPath"}, + {"uppercase method", " operationPath: '{$sourceDescriptions.api.url}#/paths/~1pets~1~0demo/GET'\n", CodeReference, "/workflows/0/steps/0/operationPath"}, + {"header case", " operationId: read\n parameters: [{name: x-id, in: header, value: test}]\n", "", ""}, + {"undeclared parameter", " operationId: read\n parameters: [{name: other, in: query, value: test}]\n", CodeParameter, "/workflows/0/steps/0/parameters/0/name"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + result, err := ValidateBytes(context.Background(), []byte(sourceTestDocument(tt.step)), "https://example.test/main.yaml", WithSources(sourceCandidate(sourceOpenAPI))) + if err != nil { + t.Fatal(err) + } + if tt.code != "" { + sourceDiagnostic(t, result, tt.code, tt.pointer) + } else if !result.Valid() || !result.Complete { + t.Fatalf("unexpected result: %+v", result) + } + }) + } +} + +func TestSourcesAbsenceTransportAndType(t *testing.T) { + input := []byte(sourceTestDocument(" operationId: read\n")) + result, err := ValidateBytes(context.Background(), input, "https://example.test/main.yaml") + if err != nil || !result.Valid() || result.Complete { + t.Fatalf("missing source must be incomplete: %+v %v", result, err) + } + failure := errors.New("transport failed") + result, err = ValidateBytes(context.Background(), input, "https://example.test/main.yaml", WithResolver(sourceResolverFunc(func(context.Context, upstream.SourceRequest) (*upstream.ResolvedSource, error) { return nil, failure }))) + if !errors.Is(err, failure) || result.Complete { + t.Fatalf("transport failure lost: %+v %v", result, err) + } + result, err = ValidateBytes(context.Background(), input, "https://example.test/main.yaml", WithSources(sourceCandidate("asyncapi: 3.0.0\nchannels: {}\noperations: {}\n"))) + if err != nil { + t.Fatal(err) + } + sourceDiagnostic(t, result, CodeSourceType, "/sourceDescriptions/0/type") +} + +func TestSourcesAdapterCoverageAndExactPointer(t *testing.T) { + candidate := upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", Adapter: operationPresence{}} + result, err := ValidateBytes(context.Background(), []byte(sourceTestDocument(" operationId: read\n")), "https://example.test/main.yaml", WithSources(candidate)) + if err != nil || !result.Valid() || result.Complete { + t.Fatalf("adapter must expose coverage limit: %+v %v", result, err) + } + result, err = ValidateBytes(context.Background(), []byte(sourceTestDocument(" operationPath: '{$sourceDescriptions.api.url}#/paths/~1pets/get/responses'\n")), "https://example.test/main.yaml", WithSources(candidate)) + if err != nil { + t.Fatal(err) + } + sourceDiagnostic(t, result, CodeReference, "/workflows/0/steps/0/operationPath") +} + +func TestSourcesResolverMemoizesURI(t *testing.T) { + input := strings.Replace(sourceTestDocument(" operationId: $sourceDescriptions.api.read\n - stepId: second\n operationId: $sourceDescriptions.alias.read\n"), "workflows:", " - {name: alias, url: './api.yaml', type: openapi}\nworkflows:", 1) + calls := 0 + resolver := sourceResolverFunc(func(ctx context.Context, request upstream.SourceRequest) (*upstream.ResolvedSource, error) { + calls++ + if request.URL != "https://example.test/api.yaml" || request.BaseURI != "" { + t.Fatalf("unexpected request: %+v", request) + } + return &upstream.ResolvedSource{RetrievalURI: request.URL, SourceBytes: []byte(sourceOpenAPI)}, nil + }) + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithResolver(resolver)) + if err != nil || !result.Valid() || !result.Complete || calls != 1 { + t.Fatalf("memoization failed: calls=%d result=%+v err=%v", calls, result, err) + } +} + +func TestSourcesSelfIdentityAndAllCandidates(t *testing.T) { + external := "arazzo: 1.1.0\n$self: canonical.yaml\ninfo: {title: External, version: '1'}\nsourceDescriptions: [{name: api, url: api.yaml, type: openapi}]\nworkflows: [{workflowId: remote, steps: [{stepId: first, operationId: read}]}]\n" + candidate := upstream.CandidateDocument{RetrievalURI: "https://example.test/retrieved.yaml", SourceBytes: []byte(external)} + input := strings.Replace(sourceTestDocument(" workflowId: $sourceDescriptions.api.remote\n"), "url: 'api.yaml', type: openapi", "url: 'canonical.yaml', type: arazzo", 1) + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(sourceCandidate(sourceOpenAPI), candidate)) + if err != nil || !result.Valid() { + t.Fatalf("canonical candidate should resolve: %+v %v", result, err) + } + input = strings.Replace(input, "canonical.yaml", "retrieved.yaml", 1) + result, err = ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(candidate)) + if err != nil { + t.Fatal(err) + } + sourceDiagnostic(t, result, CodeReference, "/sourceDescriptions/0/url") +} + +func TestSourcesDuplicateIdentityAndLimits(t *testing.T) { + input := []byte(sourceTestDocument(" operationId: read\n")) + _, err := ValidateBytes(context.Background(), input, "https://example.test/main.yaml", WithSources(sourceCandidate(sourceOpenAPI), sourceCandidate(sourceOpenAPI))) + var typed *Error + if !errors.As(err, &typed) || typed.Kind != ErrorConfiguration { + t.Fatalf("duplicate identity must fail configuration: %v", err) + } + limits := DefaultLimits() + limits.MaxSources = 1 + _, err = ValidateBytes(context.Background(), input, "https://example.test/main.yaml", WithSources(sourceCandidate(sourceOpenAPI), upstream.CandidateDocument{RetrievalURI: "https://example.test/other.yaml", SourceBytes: []byte(sourceOpenAPI)}), WithLimits(limits)) + if !errors.As(err, &typed) || typed.Kind != ErrorLimit { + t.Fatalf("total candidate limit not enforced: %v", err) + } +} + +func TestSourcesRootImmutable(t *testing.T) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(sourceOpenAPI), &root); err != nil { + t.Fatal(err) + } + before, _ := yaml.Marshal(&root) + result, err := ValidateBytes(context.Background(), []byte(sourceTestDocument(" operationId: read\n")), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", RootNode: &root})) + if err != nil || !result.Valid() { + t.Fatalf("root candidate failed: %+v %v", result, err) + } + after, _ := yaml.Marshal(&root) + if string(before) != string(after) { + t.Fatal("caller source root was modified") + } +} + +func TestSourcesImportCycleAndPrerequisiteCycle(t *testing.T) { + main := `arazzo: 1.1.0 +info: {title: Main, version: '1'} +sourceDescriptions: [{name: other, url: other.yaml, type: arazzo}] +workflows: + - workflowId: run + dependsOn: [$sourceDescriptions.other.remote] + steps: [{stepId: first, workflowId: $sourceDescriptions.other.remote}] +` + other := `arazzo: 1.1.0 +info: {title: Other, version: '1'} +sourceDescriptions: [{name: main, url: main.yaml, type: arazzo}] +workflows: + - workflowId: remote + steps: [{stepId: first, workflowId: $sourceDescriptions.main.run}] +` + candidate := upstream.CandidateDocument{RetrievalURI: "https://example.test/other.yaml", SourceBytes: []byte(other)} + result, err := ValidateBytes(context.Background(), []byte(main), "https://example.test/main.yaml", WithSources(candidate)) + if err != nil || !result.Valid() || !result.Complete { + t.Fatalf("imports are not prerequisite cycles: %+v %v", result, err) + } + candidate.SourceBytes = []byte(strings.Replace(other, " steps:", " dependsOn: [$sourceDescriptions.main.run]\n steps:", 1)) + result, err = ValidateBytes(context.Background(), []byte(main), "https://example.test/main.yaml", WithSources(candidate)) + if err != nil { + t.Fatal(err) + } + sourceDiagnostic(t, result, CodeDependency, "/workflows/0/dependsOn/0") +} + +func TestSourcesExternalStepMetadata(t *testing.T) { + input := strings.Replace(sourceTestDocument(" workflowId: $sourceDescriptions.api.remote\n dependsOn: [$sourceDescriptions.api.remote.steps.first]\n"), "type: openapi", "type: arazzo", 1) + external := `arazzo: 1.1.0 +info: {title: Other, version: '1'} +sourceDescriptions: [{name: main, url: main.yaml, type: arazzo}] +workflows: [{workflowId: remote, steps: [{stepId: first, workflowId: $sourceDescriptions.main.run}]}] +` + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(sourceCandidate(external))) + if err != nil || !result.Valid() { + t.Fatalf("valid external step failed: %+v %v", result, err) + } + input = strings.Replace(input, ".steps.first", ".steps.missing", 1) + result, err = ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(sourceCandidate(external))) + if err != nil { + t.Fatal(err) + } + sourceDiagnostic(t, result, CodeReference, "/workflows/0/steps/0/dependsOn/0") + input = strings.Replace(input, ".steps.missing", ".steps.first", 1) + result, err = ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", Adapter: workflowPresence{}})) + if err != nil { + t.Fatal(err) + } + // The adapter proves remote absent rather than providing step metadata. + sourceDiagnostic(t, result, CodeReference, "/workflows/0/steps/0/workflowId") +} + +func TestSourcesUnusedResolverAndMissingMemberCapability(t *testing.T) { + input := `arazzo: 1.1.0 +info: {title: Main, version: '1'} +sourceDescriptions: [{name: api, url: api.yaml, type: openapi}] +workflows: [{workflowId: first, steps: [{stepId: local, workflowId: second}]}, {workflowId: second, steps: [{stepId: local, workflowId: first}]}] +` + calls := 0 + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithResolver(sourceResolverFunc(func(context.Context, upstream.SourceRequest) (*upstream.ResolvedSource, error) { + calls++ + return nil, errors.New("must not retrieve") + }))) + if err != nil || !result.Valid() || !result.Complete || calls != 0 { + t.Fatalf("unused source was loaded: %+v %v calls=%d", result, err, calls) + } +} + +func TestSourcesUnavailableOpenAPIReferences(t *testing.T) { + external := `openapi: 3.1.0 +info: {title: API, version: '1'} +paths: {/pets: {$ref: other.yaml#/paths/~1pets}} +` + result, err := ValidateBytes(context.Background(), []byte(sourceTestDocument(" operationId: read\n")), "https://example.test/main.yaml", WithSources(sourceCandidate(external))) + if err != nil || !result.Valid() || result.Complete { + t.Fatalf("unavailable nested reference is coverage, not absence: %+v %v", result, err) + } +} + +func TestSourcesMixedOfflineComplete(t *testing.T) { + input := `arazzo: 1.1.0 +$self: https://example.test/main.yaml +info: {title: Mixed sources, version: '1'} +sourceDescriptions: + - {name: api, url: api.yaml, type: openapi} + - {name: events, url: events.yaml, type: asyncapi} + - {name: flows, url: flows.yaml, type: arazzo} +workflows: + - workflowId: run + steps: + - {stepId: read, operationId: $sourceDescriptions.api.read} + - {stepId: publish, operationId: $sourceDescriptions.events.publish, action: send} + - {stepId: remote, workflowId: $sourceDescriptions.flows.remote} +` + events := `asyncapi: 3.0.0 +info: {title: Events, version: '1'} +channels: + pets: {address: pets, messages: {created: {$ref: '#/components/messages/created'}}} +operations: + publish: {action: send, channel: {$ref: '#/channels/pets'}} +components: + messages: {created: {payload: {type: object, properties: {id: {type: string}}}}} +` + flows := `arazzo: 1.1.0 +info: {title: Flows, version: '1'} +sourceDescriptions: [{name: api, url: api.yaml, type: openapi}] +workflows: [{workflowId: remote, steps: [{stepId: first, operationId: read}]}] +` + result, err := ValidateBytes(context.Background(), []byte(input), "https://retrieval.test/flow.yaml", WithSources(sourceCandidate(sourceOpenAPI), upstream.CandidateDocument{RetrievalURI: "https://example.test/events.yaml", SourceBytes: []byte(events)}, upstream.CandidateDocument{RetrievalURI: "https://example.test/flows.yaml", SourceBytes: []byte(flows)})) + if err != nil || !result.Valid() || !result.Complete { + t.Fatalf("complete offline mixed-source validation failed: %+v %v", result, err) + } +} + +func TestSourcesDiamondCapsAndCancellation(t *testing.T) { + document := func(id string, deps []string) string { + descriptions := []string{} + refs := []string{} + for _, dep := range deps { + descriptions = append(descriptions, "{name: "+dep+", url: "+dep+".yaml, type: arazzo}") + refs = append(refs, "$sourceDescriptions."+dep+"."+dep) + } + if len(descriptions) == 0 { + descriptions = append(descriptions, "{name: api, url: api.yaml, type: openapi}") + } + dependency := "" + if len(refs) > 0 { + dependency = " dependsOn: [" + strings.Join(refs, ", ") + "]\n" + } + return "arazzo: 1.1.0\ninfo: {title: " + id + ", version: '1'}\nsourceDescriptions: [" + strings.Join(descriptions, ", ") + "]\nworkflows:\n - workflowId: " + id + "\n" + dependency + " steps: [{stepId: first, operationId: placeholder}]\n" + } + input := `arazzo: 1.1.0 +info: {title: Main, version: '1'} +sourceDescriptions: [{name: a, url: a.yaml, type: arazzo}] +workflows: + - workflowId: run + dependsOn: [$sourceDescriptions.a.a] + steps: [{stepId: first, workflowId: $sourceDescriptions.a.a}] +` + docs := map[string]string{"a.yaml": document("a", []string{"b", "c"}), "b.yaml": document("b", []string{"d"}), "c.yaml": document("c", []string{"d"}), "d.yaml": document("d", nil)} + loads := map[string]int{} + resolver := sourceResolverFunc(func(ctx context.Context, request upstream.SourceRequest) (*upstream.ResolvedSource, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + key := strings.TrimPrefix(request.URL, "https://example.test/") + loads[key]++ + return &upstream.ResolvedSource{RetrievalURI: request.URL, SourceBytes: []byte(docs[key])}, nil + }) + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithResolver(resolver)) + if err != nil || !result.Valid() || !result.Complete { + t.Fatalf("diamond failed: %+v %v", result, err) + } + for _, key := range []string{"a.yaml", "b.yaml", "c.yaml", "d.yaml"} { + if loads[key] != 1 { + t.Fatalf("%s loaded %d times", key, loads[key]) + } + } + limits := DefaultLimits() + limits.MaxSources = 3 + result, err = ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithResolver(resolver), WithLimits(limits)) + var typed *Error + if !errors.As(err, &typed) || typed.Kind != ErrorLimit || result.Complete { + t.Fatalf("fallback total source cap failed: %+v %v", result, err) + } + ctx, cancel := context.WithCancel(context.Background()) + result, err = ValidateBytes(ctx, []byte(input), "https://example.test/main.yaml", WithResolver(sourceResolverFunc(func(ctx context.Context, request upstream.SourceRequest) (*upstream.ResolvedSource, error) { + cancel() + return nil, ctx.Err() + }))) + if !errors.Is(err, context.Canceled) || result.Complete { + t.Fatalf("resolver cancellation lost: %+v %v", result, err) + } +} + +func TestSourcesConcurrentMergeRoots(t *testing.T) { + data := `openapi: 3.1.0 +info: {title: API, version: '1'} +operation: &operation + operationId: read + responses: {'200': {description: OK}} +paths: + /pets: + get: + <<: *operation +` + var root yaml.Node + if err := yaml.Unmarshal([]byte(data), &root); err != nil { + t.Fatal(err) + } + before, _ := yaml.Marshal(&root) + candidate := upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", RootNode: &root} + done := make(chan error, 8) + for i := 0; i < 8; i++ { + go func() { + result, err := ValidateBytes(context.Background(), []byte(sourceTestDocument(" operationId: read\n")), "https://example.test/main.yaml", WithSources(candidate)) + if err == nil && (!result.Valid() || !result.Complete) { + err = errors.New("concurrent source validation failed") + } + done <- err + }() + } + for i := 0; i < 8; i++ { + if err := <-done; err != nil { + t.Fatal(err) + } + } + after, _ := yaml.Marshal(&root) + if string(before) != string(after) { + t.Fatal("caller root changed during concurrent validation") + } +} + +func TestSourcesExternalDiagnosticLocation(t *testing.T) { + input := `arazzo: 1.1.0 +info: {title: Main, version: '1'} +sourceDescriptions: [{name: other, url: other.yaml, type: arazzo}] +workflows: + - workflowId: run + dependsOn: [$sourceDescriptions.other.remote] + steps: [{stepId: first, workflowId: $sourceDescriptions.other.remote}] +` + external := `arazzo: 1.1.0 +info: {title: Other, version: '1'} +sourceDescriptions: [{name: api, url: api.yaml, type: openapi}] +workflows: + - workflowId: remote + dependsOn: [missing] + steps: [{stepId: first, operationId: placeholder}] +` + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/other.yaml", SourceBytes: []byte(external)})) + if err != nil { + t.Fatal(err) + } + for _, diagnostic := range result.Diagnostics { + if diagnostic.Code == CodeReference && diagnostic.URI == "https://example.test/other.yaml" && diagnostic.Pointer == "/workflows/0/dependsOn/0" && diagnostic.Line == 6 && diagnostic.Column == 17 { + return + } + } + t.Fatalf("external source location lost: %+v", result.Diagnostics) +} + +func TestSourcesDottedSourceNames(t *testing.T) { + input := strings.Replace(sourceTestDocument(" operationId: $sourceDescriptions.customer.api.read\n"), "name: api", "name: customer.api", 1) + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(sourceCandidate(sourceOpenAPI))) + if err != nil || !result.Valid() || !result.Complete { + t.Fatalf("dotted source name was split as an internal separator: %+v %v", result, err) + } +} + +func TestSourcesDepthAndRootByteCaps(t *testing.T) { + input := `arazzo: 1.1.0 +info: {title: Main, version: '1'} +sourceDescriptions: [{name: chain, url: '1.yaml', type: arazzo}] +workflows: + - workflowId: run + dependsOn: [$sourceDescriptions.chain.chain] + steps: [{stepId: first, workflowId: $sourceDescriptions.chain.chain}] +` + resolver := sourceResolverFunc(func(ctx context.Context, request upstream.SourceRequest) (*upstream.ResolvedSource, error) { + number := strings.TrimSuffix(strings.TrimPrefix(request.URL, "https://example.test/"), ".yaml") + index, err := strconv.Atoi(number) + if err != nil { + return nil, err + } + dependency := "" + if index < 12 { + dependency = " dependsOn: [$sourceDescriptions.chain.chain]\n" + } + doc := "arazzo: 1.1.0\ninfo: {title: Chain, version: '1'}\nsourceDescriptions: [{name: chain, url: '" + strconv.Itoa(index+1) + ".yaml', type: arazzo}]\nworkflows:\n - workflowId: chain\n" + dependency + " steps: [{stepId: first, operationId: placeholder}]\n" + return &upstream.ResolvedSource{RetrievalURI: request.URL, SourceBytes: []byte(doc)}, nil + }) + limits := DefaultLimits() + limits.MaxDepth = 8 + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithResolver(resolver), WithLimits(limits)) + var typed *Error + if !errors.As(err, &typed) || typed.Kind != ErrorLimit || result.Complete { + t.Fatalf("source dependency depth cap failed: %+v %v", result, err) + } + var root yaml.Node + if err := yaml.Unmarshal([]byte(sourceOpenAPI+"x-data: "+strings.Repeat("a", 3000)+"\n"), &root); err != nil { + t.Fatal(err) + } + limits = DefaultLimits() + limits.MaxBytes = 2048 + result, err = ValidateBytes(context.Background(), []byte(sourceTestDocument(" operationId: read\n")), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", RootNode: &root}), WithLimits(limits)) + if !errors.As(err, &typed) || typed.Kind != ErrorLimit || result.Complete { + t.Fatalf("root-only source byte cap failed: %+v %v", result, err) + } +} + +func TestSourcesExternalImplicitPrerequisiteCycle(t *testing.T) { + input := strings.Replace(sourceTestDocument(" workflowId: $sourceDescriptions.api.remote\n"), "type: openapi", "type: arazzo", 1) + external := `arazzo: 1.1.0 +info: {title: Other, version: '1'} +sourceDescriptions: [{name: api, url: api.yaml, type: openapi}] +workflows: + - workflowId: remote + steps: + - stepId: first + operationId: read + parameters: [{name: id, in: query, value: $steps.second.outputs.id}] + outputs: {id: $response.body#/id} + - stepId: second + operationId: read + parameters: [{name: id, in: query, value: $steps.first.outputs.id}] + outputs: {id: $response.body#/id} +` + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(sourceCandidate(external))) + if err != nil { + t.Fatal(err) + } + for _, diagnostic := range result.Diagnostics { + if diagnostic.Code == CodeDependency && diagnostic.URI == "https://example.test/api.yaml" && strings.HasSuffix(diagnostic.Pointer, "/parameters/0/value") { + return + } + } + t.Fatalf("external implicit prerequisite cycle missed: %+v", result) +} + +func TestSourcesRequestExpressionParameterDeclarations(t *testing.T) { + for _, version := range []string{"1.0.1", "1.1.0"} { + for _, tt := range []struct { + expression string + missing bool + }{{"$request.header.x-id", false}, {"$request.header.bogus", true}, {"$request.query.bogus", true}, {"$request.path.bogus", true}} { + t.Run(version+tt.expression, func(t *testing.T) { + input := strings.Replace(sourceTestDocument(" operationId: read\n outputs: {value: "+tt.expression+"}\n"), "1.1.0", version, 1) + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(sourceCandidate(sourceOpenAPI))) + if err != nil { + t.Fatal(err) + } + if tt.missing { + sourceDiagnostic(t, result, CodeReference, "/workflows/0/steps/0/outputs/value") + } else if !result.Valid() || !result.Complete { + t.Fatalf("declared inherited header expression failed: %+v", result) + } + }) + } + } + input := sourceTestDocument(" operationId: read\n outputs: {value: $request.header.bogus}\n") + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", Adapter: operationPresence{}})) + if err != nil || !result.Valid() || result.Complete { + t.Fatalf("adapter cannot prove request parameter absence: %+v %v", result, err) + } +} + +func TestSourcesCachedAliasChecksDeclaredKind(t *testing.T) { + input := strings.Replace(sourceTestDocument(" operationId: $sourceDescriptions.api.read\n - stepId: second\n workflowId: $sourceDescriptions.alias.remote\n"), "workflows:", " - {name: alias, url: './api.yaml', type: arazzo}\nworkflows:", 1) + calls := 0 + resolver := sourceResolverFunc(func(ctx context.Context, request upstream.SourceRequest) (*upstream.ResolvedSource, error) { + calls++ + return &upstream.ResolvedSource{RetrievalURI: request.URL, SourceBytes: []byte(sourceOpenAPI)}, nil + }) + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithResolver(resolver)) + if err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("cached source alias loaded %d times", calls) + } + sourceDiagnostic(t, result, CodeSourceType, "/sourceDescriptions/1/type") +} + +func TestSourcesRootlessModelCollectionCaps(t *testing.T) { + for _, collection := range []string{"sourceDescriptions", "dependsOn", "outputs"} { + t.Run(collection, func(t *testing.T) { + model := &highArazzo.Arazzo{Arazzo: "1.1.0", Workflows: []*highArazzo.Workflow{{WorkflowId: "remote", Steps: []*highArazzo.Step{{StepId: "first", OperationId: "read"}}}}} + for i := 0; i < 2000; i++ { + switch collection { + case "sourceDescriptions": + model.SourceDescriptions = append(model.SourceDescriptions, &highArazzo.SourceDescription{Name: strconv.Itoa(i), URL: "api.yaml", Type: "openapi"}) + case "dependsOn": + model.Workflows[0].DependsOn = append(model.Workflows[0].DependsOn, "unused") + case "outputs": + if model.Workflows[0].Outputs == nil { + model.Workflows[0].Outputs = orderedmap.New[string, *highArazzo.OutputValue]() + } + model.Workflows[0].Outputs.Set(strconv.Itoa(i), highArazzo.NewExpressionOutputValue("$response.body")) + } + } + input := strings.Replace(sourceTestDocument(" workflowId: $sourceDescriptions.api.remote\n"), "type: openapi", "type: arazzo", 1) + limits := DefaultLimits() + limits.MaxNodes = 1000 + limits.MaxBytes = 8000 + result, err := ValidateBytes(context.Background(), []byte(input), "https://example.test/main.yaml", WithSources(upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", ArazzoDocument: model}), WithLimits(limits)) + var typed *Error + if !errors.As(err, &typed) || typed.Kind != ErrorLimit || result.Complete { + t.Fatalf("rootless %s collection escaped caps: %+v %v", collection, result, err) + } + }) + } +} + +func TestSourcesRootlessOpenAPIModelTargets(t *testing.T) { + operation := &v3.Operation{OperationId: "read"} + header := &v3.Parameter{Name: "X-ID", In: "header"} + pathItem := &v3.PathItem{Get: operation, Parameters: []*v3.Parameter{header}} + paths := orderedmap.New[string, *v3.PathItem]() + paths.Set("/pets/~demo", pathItem) + model := &v3.Document{Version: "3.1.0", Paths: &v3.Paths{PathItems: paths}} + if model.GoLow() != nil { + t.Fatal("fixture must exercise the rootless high-model fallback") + } + candidate := upstream.CandidateDocument{RetrievalURI: "https://example.test/api.yaml", OpenAPIDocument: model} + cases := []struct { + name, step string + code Code + pointer string + }{ + {"operation ID", " operationId: $sourceDescriptions.api.read\n", "", ""}, + {"escaped operation pointer", " operationPath: '{$sourceDescriptions.api.url}#/paths/~1pets~1~0demo/get'\n", "", ""}, + {"inherited header parameter", " operationId: read\n parameters: [{name: x-id, in: header, value: test}]\n", "", ""}, + {"inherited header expression", " operationId: read\n outputs: {header: $request.header.x-id}\n", "", ""}, + {"absent operation ID", " operationId: absent\n", CodeReference, "/workflows/0/steps/0/operationId"}, + {"pointer targets a response member", " operationPath: '{$sourceDescriptions.api.url}#/paths/~1pets~1~0demo/get/responses'\n", CodeReference, "/workflows/0/steps/0/operationPath"}, + {"absent header parameter", " operationId: read\n parameters: [{name: absent, in: header, value: test}]\n", CodeParameter, "/workflows/0/steps/0/parameters/0/name"}, + {"absent request header expression", " operationId: read\n outputs: {header: $request.header.absent}\n", CodeReference, "/workflows/0/steps/0/outputs/header"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + result, err := ValidateBytes(context.Background(), []byte(sourceTestDocument(tt.step)), "https://example.test/main.yaml", WithSources(candidate)) + if err != nil { + t.Fatal(err) + } + if tt.code != "" { + sourceDiagnostic(t, result, tt.code, tt.pointer) + } else if !result.Valid() || !result.Complete { + t.Fatalf("rootless high model failed: %+v", result) + } + if !result.Complete { + t.Fatalf("rootless high model unexpectedly lost coverage: %+v", result.Checks) + } + item, ok := model.Paths.PathItems.Get("/pets/~demo") + if !ok || item != pathItem || item.Get != operation || operation.OperationId != "read" || len(item.Parameters) != 1 || item.Parameters[0] != header || header.Name != "X-ID" || header.In != "header" || model.GoLow() != nil { + t.Fatal("validation changed the caller's high model") + } + }) + } +} + +func TestSourcesApplicationBaseAndRelativeSelf(t *testing.T) { + t.Run("relative retrieval and source locations", func(t *testing.T) { + input := strings.Replace(sourceTestDocument(" operationId: read\n"), "url: 'api.yaml'", "url: '../api.yaml'", 1) + candidate := upstream.CandidateDocument{RetrievalURI: "../api.yaml", SourceBytes: []byte(sourceOpenAPI)} + result, err := ValidateBytes(context.Background(), []byte(input), "flows/main.yaml", WithBaseURI("https://example.test/bundle/"), WithSources(candidate)) + if err != nil || !result.Valid() || !result.Complete { + t.Fatalf("application base did not resolve relative locations: %+v %v", result, err) + } + found := false + for _, check := range result.Checks { + if check.Name == "operation-target" && check.Source == "https://example.test/bundle/api.yaml" { + found = true + break + } + } + if !found { + t.Fatalf("resolved source identity missing from coverage: %+v", result.Checks) + } + }) + t.Run("relative self resolves against retrieval plus application base", func(t *testing.T) { + input := `arazzo: 1.1.0 +$self: ../identity/main.yaml +info: {title: Main, version: '1'} +sourceDescriptions: [{name: other, url: remote.yaml, type: arazzo}] +workflows: [{workflowId: run, steps: [{stepId: first, workflowId: $sourceDescriptions.other.remote}]}] +` + external := `arazzo: 1.1.0 +$self: ../identity/remote.yaml +info: {title: Remote, version: '1'} +sourceDescriptions: [{name: api, url: api.yaml, type: openapi}] +workflows: [{workflowId: remote, steps: [{stepId: first, operationId: read}]}] +` + candidate := upstream.CandidateDocument{RetrievalURI: "https://example.test/bundle/retrieved/other.yaml", SourceBytes: []byte(external)} + result, err := ValidateBytes(context.Background(), []byte(input), "retrieved/main.yaml", WithBaseURI("https://example.test/bundle/"), WithSources(candidate)) + if err != nil || !result.Valid() || !result.Complete { + t.Fatalf("relative $self identity did not resolve: %+v %v", result, err) + } + found := false + for _, check := range result.Checks { + if check.Name == "workflow-target" && check.Source == "https://example.test/bundle/identity/remote.yaml" { + found = true + break + } + } + if !found { + t.Fatalf("canonical $self identity missing from coverage: %+v", result.Checks) + } + input = strings.Replace(input, "url: remote.yaml", "url: ../retrieved/other.yaml", 1) + result, err = ValidateBytes(context.Background(), []byte(input), "retrieved/main.yaml", WithBaseURI("https://example.test/bundle/"), WithSources(candidate)) + if err != nil { + t.Fatal(err) + } + sourceDiagnostic(t, result, CodeReference, "/sourceDescriptions/0/url") + for _, diagnostic := range result.Diagnostics { + if diagnostic.Code == CodeReference && diagnostic.Pointer == "/sourceDescriptions/0/url" && diagnostic.URI != "retrieved/main.yaml" { + t.Fatalf("retrieval URI for authored diagnostic was replaced: %+v", diagnostic) + } + } + }) +} diff --git a/arazzo/target_contracts_test.go b/arazzo/target_contracts_test.go new file mode 100644 index 0000000..f9034b1 --- /dev/null +++ b/arazzo/target_contracts_test.go @@ -0,0 +1,505 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "strings" + "testing" + + upstream "github.com/pb33f/libopenapi/arazzo" + "github.com/pb33f/libopenapi/arazzo/expression" +) + +type targetContractOpaque string + +func (a targetContractOpaque) SourceType() string { return string(a) } + +type targetContractAsyncPresence struct{} + +func (targetContractAsyncPresence) SourceType() string { return "asyncapi" } +func (targetContractAsyncPresence) HasOperation(id string) bool { return id == "read" } +func (targetContractAsyncPresence) HasChannel(id string) bool { return id == "events" } + +// The harness enters the source boundary directly for malformed target strings +// that the document schema or expression parser rejects before source resolution. +func targetContractSession(t *testing.T, kind string, adapter upstream.SourceDocumentAdapter, data string) (*sourceSession, *linkedSource) { + t.Helper() + v := &validation{ctx: context.Background(), opts: options{limits: DefaultLimits()}, result: &Result{}, version: "1.1", doc: Document{URI: "https://example.test/main.yaml"}, root: map[string]any{}, budget: &workBudget{}} + v.graph = newDependencyGraph() + v.graph.run = v + v.local = &semanticIndex{workflows: map[string]*semanticWorkflow{}, components: map[string]map[string]any{}, sources: map[string]string{"api": "api.yaml"}} + s := &sourceSession{v: v, sourceState: &sourceState{byURI: map[string]*linkedSource{}, requests: map[string]*linkedSource{}, graphVisited: map[*linkedSource]bool{}, targets: map[dependencyID]*linkedTarget{}}, sourceScope: sourceScope{base: v.doc.URI, byName: map[string]map[string]any{"api": {"name": "api", "url": "api.yaml", "type": kind}}, descriptionPaths: map[string]string{"api": "/sourceDescriptions/0"}}} + v.sources = s + source := s.build(&upstream.ResolvedSource{Type: kind, RetrievalURI: "https://example.test/api.yaml", Adapter: adapter, SourceBytes: []byte(data)}) + if v.err != nil || source == nil || !s.register(source) { + t.Fatalf("source setup: %v", v.err) + } + return s, source +} + +func targetContractFinding(t *testing.T, r *Result, code Code, pointer, message string) { + t.Helper() + for _, d := range r.Diagnostics { + if d.Code == code && d.Pointer == pointer && strings.Contains(d.Message, message) { + return + } + } + t.Fatalf("missing %s at %s (%s): %+v", code, pointer, message, r.Diagnostics) +} + +func targetContractCheck(t *testing.T, r *Result, name, pointer string, status CheckStatus) { + t.Helper() + for _, c := range r.Checks { + if c.Name == name && c.Pointer == pointer && c.Status == status { + return + } + } + t.Fatalf("missing %s %s at %s: %+v", name, status, pointer, r.Checks) +} + +func TestTargetContractPointerBoundary(t *testing.T) { + for _, test := range []struct{ value, message string }{ + {"{$sourceDescriptions.api.url}", "JSON Pointer fragment"}, + {"api.yaml#/paths/~1pets/get", "runtime expression"}, + {"{$sourceDescriptions.api.name}#/paths/~1pets/get", "description url"}, + {"{$sourceDescriptions.api.url}#%GG", "URI fragment encoding"}, + {"{$sourceDescriptions.api.url}#paths/~1pets/get", "JSON Pointer fragment"}, + {"{$sourceDescriptions.api.url}#/paths/~2pets/get", "JSON Pointer fragment"}, + } { + t.Run(test.value, func(t *testing.T) { + s, _ := targetContractSession(t, "openapi", operationPresence{}, "") + if got := s.pointer(test.value, "/target", "operationPath", nil, "/step"); got != nil { + t.Fatal("invalid pointer resolved") + } + targetContractFinding(t, s.v.result, CodeReference, "/target", test.message) + }) + } + for _, test := range []struct { + kind, field, pointer string + adapter upstream.SourceDocumentAdapter + check string + target bool + }{ + {"openapi", "operationPath", "/paths/~1pets/get", operationPresence{}, "target-metadata", true}, + {"asyncapi", "operationPath", "/operations/read", targetContractAsyncPresence{}, "target-metadata", true}, + {"asyncapi", "channelPath", "/channels/events", targetContractAsyncPresence{}, "target-metadata", true}, + {"asyncapi", "channelPath", "/channels/events", targetContractOpaque("asyncapi"), "target-presence", false}, + {"openapi", "operationPath", "/paths/~1pets/get", targetContractOpaque("openapi"), "target-presence", false}, + } { + t.Run(test.kind+test.field+test.check, func(t *testing.T) { + s, _ := targetContractSession(t, test.kind, test.adapter, "") + got := s.pointer("{$sourceDescriptions.api.url}#"+test.pointer, "/target", test.field, nil, "/step") + if (got != nil) != test.target || !s.v.result.Valid() { + t.Fatalf("target=%+v diagnostics=%+v", got, s.v.result.Diagnostics) + } + targetContractCheck(t, s.v.result, test.check, "/target", CheckIncomplete) + }) + } + s, source := targetContractSession(t, "openapi", nil, sourceOpenAPI) + source.unresolvedRefs = true + if s.pointer("{$sourceDescriptions.api.url}#/paths/~1missing/get", "/target", "operationPath", nil, "/step") != nil { + t.Fatal("missing pointer resolved") + } + targetContractCheck(t, s.v.result, "target-presence", "/target", CheckIncomplete) + if s.pointer("{$sourceDescriptions.api.url}#/channels/events", "/channel", "channelPath", nil, "/step") != nil { + t.Fatal("HTTP channel resolved") + } + targetContractFinding(t, s.v.result, CodeSourceType, "/channel", "wrong document type") + if s.pointer("{$sourceDescriptions.missing.url}#/paths/~1pets/get", "/unknown", "operationPath", nil, "/step") != nil { + t.Fatal("unknown source resolved") + } + targetContractFinding(t, s.v.result, CodeReference, "/unknown", "unknown source") +} + +func TestTargetContractPointerShapes(t *testing.T) { + for _, test := range []struct { + pointer string + valid bool + }{ + {"/paths/~1pets/get", true}, + {"/webhooks/event/post", true}, + {"/components/pathItems/pets/get", true}, + {"/components/callbacks/notify/expression/post", true}, + {"/paths/~1pets/additionalOperations/CUSTOM", true}, + {"/paths/~1pets/get/callbacks/notify/expression/post", true}, + {"", false}, + {"/paths/x", false}, + {"/components/pathItems/x", false}, + {"/components/schemas/x/get", false}, + {"/unknown/x/get", false}, + {"/paths/x/additionalOperations", false}, + {"/paths/x/GET", false}, + {"/paths/x/get/callbacks/x/y", false}, + {"/paths/x/get/responses/x/y/z", false}, + {"/components/callbacks/x/y", false}, + } { + tokens, ok := linkedPointerTokens(test.pointer) + if !ok { + t.Fatalf("valid pointer syntax rejected: %s", test.pointer) + } + if got := linkedOperationPointerShape(tokens); got != test.valid { + t.Errorf("%s: got %v want %v", test.pointer, got, test.valid) + } + } + for _, pointer := range []string{"x", "/trailing~", "/bad~2"} { + if _, ok := linkedPointerTokens(pointer); ok { + t.Errorf("accepted invalid escape: %s", pointer) + } + } +} + +func TestTargetContractOperationAndWorkflowCapabilities(t *testing.T) { + for _, test := range []struct { + kind, value, check string + adapter upstream.SourceDocumentAdapter + code Code + }{ + {"arazzo", "$sourceDescriptions.api.run", "", workflowPresence{}, CodeSourceType}, + {"openapi", "read", "operation-presence", targetContractOpaque("openapi"), ""}, + {"asyncapi", "read", "operation-metadata", targetContractAsyncPresence{}, ""}, + } { + s, _ := targetContractSession(t, test.kind, test.adapter, "") + got := s.operation(test.value, "/op", nil, "/step") + if test.code != "" { + targetContractFinding(t, s.v.result, test.code, "/op", "requires") + continue + } + if test.check == "operation-metadata" && got == nil { + t.Fatal("known async operation did not resolve") + } + targetContractCheck(t, s.v.result, test.check, "/op", CheckIncomplete) + } + s, source := targetContractSession(t, "openapi", nil, sourceOpenAPI) + source.operations["read"] = append(source.operations["read"], source.operations["read"][0]) + if s.operation("read", "/duplicate", nil, "/step") != nil { + t.Fatal("duplicate operation resolved") + } + targetContractFinding(t, s.v.result, CodeReference, "/duplicate", "not unique") + s.byName["other"] = map[string]any{"type": "asyncapi"} + if s.operation("read", "/ambiguous", nil, "/step") != nil { + t.Fatal("ambiguous operation resolved") + } + targetContractFinding(t, s.v.result, CodeReference, "/ambiguous", "multiple API sources") + s.byName = map[string]map[string]any{"api": {"type": "arazzo"}} + if s.operation("read", "/none", nil, "/step") != nil { + t.Fatal("operation without API source resolved") + } + targetContractFinding(t, s.v.result, CodeReference, "/none", "requires an API source") + s, _ = targetContractSession(t, "openapi", operationPresence{}, "") + if s.workflow("$sourceDescriptions.api.run", "/wrong") != nil { + t.Fatal("HTTP workflow resolved") + } + targetContractFinding(t, s.v.result, CodeSourceType, "/wrong", "requires an Arazzo") + if s.workflow("run", "/bare") != nil { + t.Fatal("bare external workflow resolved") + } + s, _ = targetContractSession(t, "arazzo", targetContractOpaque("arazzo"), "") + if s.workflow("$sourceDescriptions.api.run", "/metadata") != nil { + t.Fatal("opaque workflow resolved") + } + targetContractCheck(t, s.v.result, "workflow-target", "/metadata", CheckIncomplete) +} + +func TestTargetContractAsyncMetadata(t *testing.T) { + data := "asyncapi: 3.0.0\nchannels: {events: {messages: {one: {}, two: {}}}}\noperations: {read: {action: receive, channel: {$ref: '#/channels/events'}}}\n" + for _, test := range []struct { + action, correlation string + criteria bool + code Code + pointer, check string + }{ + {"", "", false, CodeReference, "/step", ""}, + {"send", "id", true, CodeSourceType, "/step/correlationId", "asyncapi-correlation"}, + {"send", "", true, CodeReference, "/step/action", ""}, + {"receive", "id", true, "", "", "asyncapi-correlation"}, + } { + s, source := targetContractSession(t, "asyncapi", nil, data) + step := map[string]any{"action": test.action, "correlationId": test.correlation} + if test.criteria { + step["successCriteria"] = []any{map[string]any{"condition": "$message.payload#/id"}} + } + s.async(step, "/step", source.operations["read"][0]) + if test.code != "" { + targetContractFinding(t, s.v.result, test.code, test.pointer, "") + } else if !s.v.result.Valid() { + t.Fatalf("receive rejected: %+v", s.v.result.Diagnostics) + } + if test.check != "" { + targetContractCheck(t, s.v.result, test.check, "/step/correlationId", CheckIncomplete) + } + } + s, source := targetContractSession(t, "asyncapi", nil, strings.Replace(data, "one: {}, two: {}", "one: {}", 1)) + s.async(map[string]any{"action": "receive"}, "/step", source.pointers["/channels/events"]) + targetContractCheck(t, s.v.result, "asyncapi-success", "/step", CheckIncomplete) + s, source = targetContractSession(t, "openapi", nil, sourceOpenAPI) + s.async(map[string]any{"action": "send", "correlationId": "id"}, "/step", source.operations["read"][0]) + targetContractFinding(t, s.v.result, CodeSourceType, "/step/action", "only to AsyncAPI") + targetContractFinding(t, s.v.result, CodeSourceType, "/step/correlationId", "only to AsyncAPI") +} + +func TestTargetContractExternalPrerequisites(t *testing.T) { + main := strings.Replace(sourceTestDocument(" workflowId: $sourceDescriptions.api.remote\n"), "type: openapi", "type: arazzo", 1) + external := "arazzo: 1.1.0\ninfo: {title: Other, version: '1'}\nsourceDescriptions: [{name: known, url: main.yaml, type: arazzo}, {name: opaque, url: opaque.yaml, type: arazzo}]\nworkflows:\n- workflowId: remote\n steps:\n - stepId: first\n operationId: read\n dependsOn:\n - DEPENDENCY\n- workflowId: other\n steps: [{stepId: local, operationId: read}]\n" + for _, test := range []struct{ dependency, message, check string }{ + {"missing", "external step prerequisite", ""}, + {"$workflows.other.steps.local", "", ""}, + {"$workflows.other.steps.missing", "cross-workflow step prerequisite", ""}, + {"$workflows.other", "invalid cross-workflow", ""}, + {"$sourceDescriptions.known.run.steps.first", "", ""}, + {"$sourceDescriptions.known.run.steps.missing", "external step prerequisite does not exist", ""}, + {"$sourceDescriptions.known.missing.steps.first", "does not exist in source", ""}, + {"$sourceDescriptions.known.run", "invalid external step dependency", ""}, + {"$sourceDescriptions.opaque.run.steps.first", "", "external-prerequisites"}, + } { + t.Run(test.dependency, func(t *testing.T) { + result, err := ValidateBytes(context.Background(), []byte(main), "https://example.test/main.yaml", WithSources(sourceCandidate(strings.Replace(external, "DEPENDENCY", test.dependency, 1)), upstream.CandidateDocument{RetrievalURI: "https://example.test/opaque.yaml", Adapter: workflowPresence{}})) + if err != nil { + t.Fatal(err) + } + pointer := "/workflows/0/steps/0/dependsOn/0" + if test.message != "" { + targetContractFinding(t, result, CodeReference, pointer, test.message) + for _, d := range result.Diagnostics { + if d.Code == CodeReference && d.Pointer == pointer && (d.URI != "https://example.test/api.yaml" || d.Line != 10 || d.Column != 7) { + t.Fatalf("external location: %+v", d) + } + } + } else if !result.Valid() { + t.Fatalf("valid prerequisite rejected: %+v", result.Diagnostics) + } + if test.check != "" { + targetContractCheck(t, result, test.check, pointer, CheckIncomplete) + } + }) + } +} + +func TestTargetContractLinkedExpressionFallbacks(t *testing.T) { + s, _ := targetContractSession(t, "arazzo", workflowPresence{}, "") + s.v.expressionUses = []expressionUse{{Expression: expression.Expression{Type: expression.SourceDescriptions, Raw: "$sourceDescriptions.api.run"}, Path: "/presence"}} + finalizeLinkedExpressionUses(s.v) + targetContractCheck(t, s.v.result, "expression-symbol", "/presence", CheckIncomplete) + for _, test := range []struct { + raw, version, data string + adapter upstream.SourceDocumentAdapter + message, check string + }{ + {"$sourceDescriptions.api.read", "1.1", sourceOpenAPI, nil, "", ""}, + {"$sourceDescriptions.api.url", "1.1", sourceOpenAPI, nil, "", ""}, + {"$sourceDescriptions.api.missing", "1.1", "", operationPresence{}, "", "expression-symbol"}, + {"$sourceDescriptions.api.read.property", "1.0", sourceOpenAPI, nil, "", "expression-symbol"}, + {"$sourceDescriptions.api.missing", "1.1", sourceOpenAPI, nil, "unknown source member", ""}, + } { + s, _ := targetContractSession(t, "openapi", test.adapter, test.data) + s.v.version = test.version + s.v.expressionUses = []expressionUse{{Expression: expression.Expression{Type: expression.SourceDescriptions, Raw: test.raw}, Path: "/value"}} + finalizeLinkedExpressionUses(s.v) + if test.message != "" { + targetContractFinding(t, s.v.result, CodeReference, "/value", test.message) + } else if !s.v.result.Valid() { + t.Fatalf("unavailable or known member rejected: %+v", s.v.result.Diagnostics) + } + if test.check != "" { + targetContractCheck(t, s.v.result, test.check, "/value", CheckIncomplete) + } + } + for _, raw := range []string{"$sourceDescriptions.api.remote", "$sourceDescriptions.api.remote.steps.produce.outputs.missing", "$sourceDescriptions.api.missing.outputs.id"} { + s, _ := targetContractSession(t, "arazzo", nil, expressionLinkedSource) + s.v.expressionUses = []expressionUse{{Expression: expression.Expression{Type: expression.SourceDescriptions, Raw: raw}, Path: "/value"}} + finalizeLinkedExpressionUses(s.v) + if raw == "$sourceDescriptions.api.remote" { + if !s.v.result.Valid() { + t.Fatal(s.v.result.Diagnostics) + } + continue + } + targetContractFinding(t, s.v.result, CodeReference, "/value", "") + } + // A missing supplied source cannot establish that an authored member is absent. + s, _ = targetContractSession(t, "openapi", nil, sourceOpenAPI) + s.byURI = map[string]*linkedSource{} + s.v.expressionUses = []expressionUse{{Expression: expression.Expression{Type: expression.SourceDescriptions, Raw: "$sourceDescriptions.api.missing"}, Path: "/value"}} + finalizeLinkedExpressionUses(s.v) + targetContractCheck(t, s.v.result, "linked-target", "/value", CheckIncomplete) +} + +func TestTargetContractSourceCancellationAndMetadata(t *testing.T) { + s, source := targetContractSession(t, "arazzo", workflowPresence{}, "") + if _, ok := s.externalStep("local", "/dep"); ok { + t.Fatal("local dependency treated as external") + } + if _, ok := s.externalStep("$sourceDescriptions.api.run", "/bad"); ok { + t.Fatal("missing step suffix resolved") + } + targetContractFinding(t, s.v.result, CodeReference, "/bad", "invalid external step") + if _, ok := s.externalStep("$sourceDescriptions.api.run.steps.first", "/opaque"); ok { + t.Fatal("presence-only step resolved") + } + targetContractCheck(t, s.v.result, "step-dependency", "/opaque", CheckIncomplete) + s.graphSource(source, "/workflow", 1) + targetContractCheck(t, s.v.result, "external-prerequisites", "/workflow", CheckIncomplete) + for _, version := range []string{"1", "2.0.0"} { + s, source = targetContractSession(t, "arazzo", nil, strings.Replace(expressionLinkedSource, "1.1.0", version, 1)) + s.graphExpressions(source) + if version == "2.0.0" { + targetContractCheck(t, s.v.result, "external-expression-grammar", "", CheckIncomplete) + } else if len(s.v.result.Checks) != 0 { + t.Fatalf("invalid version produced expression checks: %+v", s.v.result.Checks) + } + } + s, source = targetContractSession(t, "arazzo", nil, expressionLinkedSource) + source.retrieval = "" + s.graphExpressions(source) + if s.v.err != nil || !s.v.result.Valid() { + t.Fatalf("canonical identity fallback failed: %+v %v", s.v.result, s.v.err) + } + s, source = targetContractSession(t, "arazzo", nil, expressionLinkedSource) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.v.ctx = ctx + s.graphSource(source, "/workflow", 1) + if !errors.Is(s.v.err, context.Canceled) { + t.Fatalf("graph cancellation lost: %v", s.v.err) + } + s, source = targetContractSession(t, "arazzo", nil, expressionLinkedSource) + s.v.opts.limits.MaxNodes = 1 + s.graphExpressions(source) + var limit *Error + if !errors.As(s.v.err, &limit) || limit.Kind != ErrorLimit { + t.Fatalf("nested work limit lost: %v", s.v.err) + } +} + +func TestTargetContractLinkedEarlyReturns(t *testing.T) { + v := &validation{} + finalizeLinkedExpressionUses(v) + for _, raw := range []string{"invalid", "$sourceDescriptions.unknown.member"} { + s, _ := targetContractSession(t, "openapi", nil, sourceOpenAPI) + s.v.expressionUses = []expressionUse{{Expression: expression.Expression{Type: expression.SourceDescriptions, Raw: raw}, Path: "/value"}} + finalizeLinkedExpressionUses(s.v) + if len(s.v.result.Checks)+len(s.v.result.Diagnostics) != 0 { + t.Fatalf("local pass owns bad source symbols: %+v", s.v.result) + } + } + s, _ := targetContractSession(t, "openapi", nil, sourceOpenAPI) + s.v.expressionUses = []expressionUse{{Expression: expression.Expression{Type: expression.SourceDescriptions, Raw: "$sourceDescriptions.api.read"}}} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.v.ctx = ctx + finalizeLinkedExpressionUses(s.v) + if !errors.Is(s.v.err, context.Canceled) { + t.Fatalf("linked finalization ignored cancellation: %v", s.v.err) + } +} + +func TestTargetContractParameterMetadata(t *testing.T) { + s, source := targetContractSession(t, "openapi", nil, sourceOpenAPI) + s.v.root["components"] = map[string]any{"parameters": map[string]any{"header": map[string]any{"name": "X-ID", "in": "header"}}} + defaults := []any{map[string]any{"name": "unused", "in": "header"}, map[string]any{"name": "X-ID", "in": "header"}} + own := []any{map[string]any{"reference": "$components.parameters.header"}, map[string]any{"reference": "$other.unknown"}, map[string]any{}, map[string]any{"name": "a", "in": "query"}, map[string]any{"name": "b", "in": "query"}} + s.parameters(defaults, "/defaults", own, "/own", source.operations["read"][0]) + targetContractCheck(t, s.v.result, "operation-parameters", "/own/0", CheckComplete) + targetContractFinding(t, s.v.result, CodeParameter, "/defaults/0/name", "not declared") + targetContractFinding(t, s.v.result, CodeParameter, "/own/3/name", "not declared") + targetContractFinding(t, s.v.result, CodeParameter, "/own/4/name", "not declared") + for _, d := range s.v.result.Diagnostics { + if d.Pointer == "/defaults/1/name" || d.Pointer == "/own/0/name" { + t.Fatalf("declared inherited header rejected: %+v", d) + } + } + s, source = targetContractSession(t, "openapi", operationPresence{}, "") + s.parameters(nil, "", []any{map[string]any{"name": "id", "in": "query"}}, "/own", &linkedTarget{source: source}) + targetContractCheck(t, s.v.result, "operation-parameters", "/own/0", CheckIncomplete) + for _, kind := range []string{"asyncapi", "arazzo"} { + s, source = targetContractSession(t, kind, targetContractOpaque(kind), "") + target := &linkedTarget{source: source, kind: kind} + check := "asyncapi-parameters" + if kind == "arazzo" { + target.kind = "workflow" + check = "workflow-inputs" + } + s.parameters(nil, "", []any{map[string]any{"name": "id", "value": "literal"}}, "/own", target) + targetContractCheck(t, s.v.result, check, "/own", CheckIncomplete) + } + for _, kind := range []string{"openapi", "arazzo"} { + s, source = targetContractSession(t, kind, targetContractOpaque(kind), "") + target := &linkedTarget{source: source, kind: kind, node: map[string]any{"inputs": map[string]any{"type": "object", "additionalProperties": false}}} + if kind == "arazzo" { + target.kind = "workflow" + } + s.v.budget.used = s.v.opts.limits.MaxNodes + if kind == "arazzo" { + s.v.budget.used -= 2 + } // Allow list resolution and merging; stop before checking the resulting parameter. + s.parameters(nil, "", []any{map[string]any{"name": "id", "in": "query", "value": "literal"}}, "/own", target) + var limit *Error + if !errors.As(s.v.err, &limit) || limit.Kind != ErrorLimit { + t.Fatalf("%s parameter work limit: %v", kind, s.v.err) + } + } +} + +func TestTargetContractComponentActionTargets(t *testing.T) { + for _, collection := range []string{"successActions", "failureActions"} { + s, source := targetContractSession(t, "arazzo", nil, expressionLinkedSource) + s.v.root["components"] = map[string]any{collection: map[string]any{"remote": map[string]any{"workflowId": "$sourceDescriptions.api.remote", "parameters": []any{map[string]any{"name": "missing", "value": "literal"}}}}} + s.actions([]any{map[string]any{"reference": "$components." + collection + ".remote"}}, "/actions") + targetContractFinding(t, s.v.result, CodeParameter, "/actions/0/parameters/0/name", "excluded") + if len(source.workflows) != 1 { + t.Fatal("action lookup changed caller metadata") + } + } + s, _ := targetContractSession(t, "arazzo", workflowPresence{}, "") + s.actions([]any{map[string]any{"workflowId": "$sourceDescriptions.api.absent"}}, "/actions") + targetContractFinding(t, s.v.result, CodeReference, "/actions/0/workflowId", "does not exist") +} + +func TestTargetContractRequestExpressionContexts(t *testing.T) { + for _, test := range []struct { + kind expression.ExpressionType + location, property string + }{ + {expression.RequestHeader, "header", "x-id"}, + {expression.RequestQuery, "query", "q"}, + {expression.RequestPath, "path", "id"}, + } { + s, source := targetContractSession(t, "openapi", nil, sourceOpenAPI) + target := source.operations["read"][0] + target.parameters[linkedParameterIdentity(test.property, test.location)] = true + step := &semanticStep{id: "first", value: map[string]any{"operationId": "read"}, path: "/steps/0"} + s.v.local.workflows["run"] = &semanticWorkflow{id: "run", steps: map[string]*semanticStep{"first": step}, order: []*semanticStep{step}} + uses := []expressionUse{ + {Expression: expression.Expression{Type: test.kind, Property: test.property}, WorkflowID: "run", Path: "/parameters/0/value"}, + {Expression: expression.Expression{Type: test.kind, Property: "missing"}, WorkflowID: "run", StepID: "first", Path: "/steps/0/parameters/0/value"}, + } + s.requestExpressions(uses) + targetContractCheck(t, s.v.result, "request-parameter-context", "/parameters/0/value", CheckComplete) + targetContractFinding(t, s.v.result, CodeReference, "/steps/0/parameters/0/value", "not declared") + if s.targets[dependencyID{document: s.v.documentID(), workflow: "run", step: "first"}] != target { + t.Fatal("operation metadata not cached") + } + } + s, source := targetContractSession(t, "openapi", operationPresence{}, "") + step := &semanticStep{id: "first", value: map[string]any{"operationId": "read"}, path: "/steps/0"} + s.v.local.workflows["run"] = &semanticWorkflow{id: "run", steps: map[string]*semanticStep{"first": step}, order: []*semanticStep{step}} + s.requestExpressions([]expressionUse{ + {Expression: expression.Expression{Type: expression.RequestQuery}, WorkflowID: "missing", Path: "/missing"}, + {Expression: expression.Expression{Type: expression.RequestQuery}, WorkflowID: "run", StepID: "missing", Path: "/missing-step"}, + {Expression: expression.Expression{Type: expression.RequestQuery}, WorkflowID: "run", Path: "/outputs/id"}, + {Expression: expression.Expression{Type: expression.RequestQuery}, WorkflowID: "run", StepID: "first", Path: "/value"}, + }) + targetContractCheck(t, s.v.result, "request-parameter-context", "/outputs/id", CheckIncomplete) + targetContractCheck(t, s.v.result, "request-parameter-context", "/value", CheckIncomplete) + if !s.v.result.Valid() || source.root != nil { + t.Fatal("incomplete request context became invalid") + } + s.targets[dependencyID{document: s.v.documentID(), workflow: "run", step: "first"}] = nil + before := len(s.v.result.Checks) + s.requestExpressions([]expressionUse{{Expression: expression.Expression{Type: expression.RequestQuery}, WorkflowID: "run", StepID: "first", Path: "/unresolved"}}) + if len(s.v.result.Checks) != before { + t.Fatal("failed target treated as declared operation") + } +} diff --git a/arazzo/testdata/mixed-source/api.yaml b/arazzo/testdata/mixed-source/api.yaml new file mode 100644 index 0000000..18bb68f --- /dev/null +++ b/arazzo/testdata/mixed-source/api.yaml @@ -0,0 +1,7 @@ +openapi: 3.1.0 +info: {title: Pets, version: '1'} +paths: + /pets: + get: + operationId: read + responses: {'200': {description: OK}} diff --git a/arazzo/testdata/mixed-source/events.yaml b/arazzo/testdata/mixed-source/events.yaml new file mode 100644 index 0000000..ae4c6df --- /dev/null +++ b/arazzo/testdata/mixed-source/events.yaml @@ -0,0 +1,8 @@ +asyncapi: 3.0.0 +info: {title: Events, version: '1'} +channels: + pets: {address: pets, messages: {created: {$ref: '#/components/messages/created'}}} +operations: + publish: {action: send, channel: {$ref: '#/channels/pets'}} +components: + messages: {created: {payload: {type: object, properties: {id: {type: string}}}}} diff --git a/arazzo/testdata/mixed-source/flows.yaml b/arazzo/testdata/mixed-source/flows.yaml new file mode 100644 index 0000000..2c67419 --- /dev/null +++ b/arazzo/testdata/mixed-source/flows.yaml @@ -0,0 +1,4 @@ +arazzo: 1.1.0 +info: {title: Flows, version: '1'} +sourceDescriptions: [{name: api, url: api.yaml, type: openapi}] +workflows: [{workflowId: remote, steps: [{stepId: first, operationId: read}]}] diff --git a/arazzo/testdata/mixed-source/workflow.yaml b/arazzo/testdata/mixed-source/workflow.yaml new file mode 100644 index 0000000..69ecb52 --- /dev/null +++ b/arazzo/testdata/mixed-source/workflow.yaml @@ -0,0 +1,13 @@ +arazzo: 1.1.0 +$self: https://example.test/main.yaml +info: {title: Mixed sources, version: '1'} +sourceDescriptions: + - {name: api, url: api.yaml, type: openapi} + - {name: events, url: events.yaml, type: asyncapi} + - {name: flows, url: flows.yaml, type: arazzo} +workflows: + - workflowId: run + steps: + - {stepId: read, operationId: $sourceDescriptions.api.read} + - {stepId: publish, operationId: $sourceDescriptions.events.publish, action: send} + - {stepId: remote, workflowId: $sourceDescriptions.flows.remote} diff --git a/arazzo/types.go b/arazzo/types.go new file mode 100644 index 0000000..2a2f57d --- /dev/null +++ b/arazzo/types.go @@ -0,0 +1,232 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +// Package arazzo validates Arazzo 1.0 and 1.1 descriptions without executing +// workflows. Validation performs no retrieval unless a caller supplies a resolver. +package arazzo + +import ( + "context" + "fmt" + + "github.com/pb33f/go-yaml" + source "github.com/pb33f/libopenapi/arazzo" +) + +// Document is an immutable source tree and its retrieval URI. Root can be a +// YAML document node or its object node. URI supplies the base for relative URLs. +type Document struct { + Root *yaml.Node + URI string +} + +// Code is a stable diagnostic identifier, suitable for rule suppression. +type Code string + +// Diagnostic codes distinguish structural, identity, reference and syntax checks. +const ( + CodeStructure Code = "arazzo-structure" + CodeDuplicateID Code = "arazzo-duplicate-id" + CodeReference Code = "arazzo-reference" + CodeParameter Code = "arazzo-parameter" + CodeExpression Code = "arazzo-expression" + CodeSelector Code = "arazzo-selector" + CodeDependency Code = "arazzo-dependency" + CodeInputSchema Code = "arazzo-input-schema" + CodeSourceType Code = "arazzo-source-type" + CodeAdvisory Code = "arazzo-advisory" +) + +// Severity indicates whether a diagnostic violates a requirement or gives advice. +type Severity string + +const ( + SeverityError Severity = "error" + SeverityWarning Severity = "warning" +) + +// Location contains detached source coordinates and an RFC 6901 instance pointer. +// Line and column are one-based, or zero when the caller's nodes have no position. +type Location struct { + URI string `json:"uri"` + Pointer string `json:"pointer"` + Line int `json:"line"` + Column int `json:"column"` +} + +// Diagnostic describes a document error without retaining source nodes or models. +type Diagnostic struct { + Code Code `json:"code"` + Severity Severity `json:"severity"` + Message string `json:"message"` + Location + SchemaPointer string `json:"schemaPointer,omitempty"` + Related []Location `json:"related,omitempty"` +} + +// CheckStatus records whether an applicable check ran. +type CheckStatus string + +const ( + CheckComplete CheckStatus = "complete" + CheckIncomplete CheckStatus = "incomplete" +) + +// Check identifies a completed or unavailable check at its affected source slot. +// Incomplete checks do not imply a malformed document. +type Check struct { + Name string `json:"name"` + Status CheckStatus `json:"status"` + URI string `json:"uri"` + Pointer string `json:"pointer,omitempty"` + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// Result contains findings and coverage for the detected major.minor feature set. +// Complete is false if any applicable check was skipped or an operation failed. +type Result struct { + Version string `json:"version"` + Diagnostics []Diagnostic `json:"diagnostics"` + Checks []Check `json:"checks"` + Complete bool `json:"complete"` +} + +// Valid reports whether validation found no conformance errors. Use Complete to +// distinguish fully checked documents from documents with unavailable checks. +func (r *Result) Valid() bool { + if r == nil { + return false + } + for _, d := range r.Diagnostics { + if d.Severity == SeverityError { + return false + } + } + return true +} + +// ErrorKind distinguishes input, feature, configuration and operational failures. +type ErrorKind string + +const ( + ErrorInput ErrorKind = "input" + ErrorUnsupported ErrorKind = "unsupported-version" + ErrorConfiguration ErrorKind = "configuration" + ErrorLimit ErrorKind = "resource-limit" + ErrorOperational ErrorKind = "operational" +) + +// Error is a typed tool error. Invalid document fields normally produce diagnostics. +type Error struct { + Kind ErrorKind + Location Location + Cause error +} + +// Error returns a printable description of the tool failure. +func (e *Error) Error() string { return fmt.Sprintf("arazzo %s: %v", e.Kind, e.Cause) } + +// Unwrap exposes the cause for errors.Is and errors.As. +func (e *Error) Unwrap() error { return e.Cause } + +// Limits bounds each validation call, including alias expansion and source loads. +// Every field must be positive when passed to WithLimits. +type Limits struct { + MaxBytes int + MaxNodes int + MaxDepth int + MaxSources int + MaxDiagnostics int + MaxPatternBytes int +} + +// DefaultLimits returns the default per-call limits. +func DefaultLimits() Limits { + return Limits{ + MaxBytes: 16 << 20, + MaxNodes: 200000, + MaxDepth: 128, + MaxSources: 50, + MaxDiagnostics: 1000, + MaxPatternBytes: 65536, + } +} + +// Option configures one validation call. Options do not modify shared state. +type ( + Option func(*options) error + options struct { + limits Limits + sources []source.CandidateDocument + resolver source.SourceDocumentResolver + baseURI string + advisory bool + } +) + +// WithLimits replaces all resource limits for this call. +func WithLimits(limits Limits) Option { + return func(o *options) error { + if limits.MaxBytes <= 0 || limits.MaxNodes <= 0 || limits.MaxDepth <= 0 || limits.MaxSources <= 0 || limits.MaxDiagnostics <= 0 || limits.MaxPatternBytes <= 0 { + return fmt.Errorf("all limits must be positive") + } + o.limits = limits + return nil + } +} + +// WithSources supplies application-owned candidate documents. They must remain +// immutable during validation. Their roots and models are never modified. +func WithSources(candidates ...source.CandidateDocument) Option { + return func(o *options) error { + o.sources = append(o.sources, candidates...) + return nil + } +} + +// WithResolver enables explicit caller-owned retrieval. The resolver must honor +// context cancellation and enforce its own network and file access policy. +func WithResolver(resolver source.SourceDocumentResolver) Option { + return func(o *options) error { + if resolver == nil { + return fmt.Errorf("resolver must not be nil") + } + o.resolver = resolver + return nil + } +} + +// WithBaseURI provides an application base when the document URI is relative. +func WithBaseURI(uri string) Option { return func(o *options) error { o.baseURI = uri; return nil } } + +// WithAdvisories enables optional authoring recommendations. +func WithAdvisories() Option { return func(o *options) error { o.advisory = true; return nil } } + +type ( + nodeLocation struct{ key, value *yaml.Node } + validation struct { + ctx context.Context + doc Document + opts options + result *Result + root map[string]any + nodes map[string]nodeLocation + version string + err error + local *semanticIndex + graph *dependencyGraph + sources *sourceSession + expressionUses []expressionUse + foreignLocations map[string]Location + nodeCount, byteCount int + budget *workBudget + identity string + } +) + +type workBudget struct { + used int + bytes int + err error +} diff --git a/arazzo/validate.go b/arazzo/validate.go new file mode 100644 index 0000000..1f4aa13 --- /dev/null +++ b/arazzo/validate.go @@ -0,0 +1,383 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "regexp" + "sort" + "strings" + + "github.com/pb33f/go-yaml" +) + +var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.([0-9]+)(-.+)?$`) + +func configure(ctx context.Context, opts []Option) (options, error) { + o := options{limits: DefaultLimits()} + if ctx == nil { + return o, &Error{Kind: ErrorConfiguration, Cause: fmt.Errorf("context must not be nil")} + } + for _, opt := range opts { + if opt == nil { + return o, &Error{Kind: ErrorConfiguration, Cause: fmt.Errorf("option must not be nil")} + } + if err := opt(&o); err != nil { + return o, &Error{Kind: ErrorConfiguration, Cause: err} + } + } + if len(o.sources) > o.limits.MaxSources { + return o, &Error{Kind: ErrorLimit, Cause: fmt.Errorf("source limit exceeded")} + } + if _, err := url.Parse(o.baseURI); err != nil { + return o, &Error{Kind: ErrorConfiguration, Cause: err} + } + return o, nil +} + +// ValidateBytes parses exactly one JSON or YAML document and validates it through +// the same path as Validate. Parse failures and multiple documents are input errors. +// Every failure returns a non-nil incomplete result. +func ValidateBytes(ctx context.Context, data []byte, uri string, opts ...Option) (*Result, error) { + o, err := configure(ctx, opts) + if err != nil { + return &Result{}, err + } + if err := ctx.Err(); err != nil { + return &Result{}, &Error{Kind: ErrorOperational, Location: Location{URI: uri}, Cause: err} + } + if len(data) > o.limits.MaxBytes { + return &Result{}, &Error{Kind: ErrorLimit, Cause: fmt.Errorf("input byte limit exceeded")} + } + var root, extra yaml.Node + decoder := yaml.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&root); err != nil { + return &Result{}, &Error{Kind: ErrorInput, Location: Location{URI: uri}, Cause: err} + } + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + err = fmt.Errorf("multiple YAML documents are not permitted") + } + return &Result{}, &Error{Kind: ErrorInput, Location: Location{URI: uri, Line: extra.Line, Column: extra.Column}, Cause: err} + } + // The YAML resolver falls back to string when a plain number overflows + // float64. Keep numeric syntax and precision on this parser-owned tree. + stack := []*yaml.Node{&root} + for len(stack) > 0 { + n := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if n.Kind == yaml.ScalarNode && n.Style == 0 && n.ShortTag() == "!!str" && len(n.Value) > 0 && (n.Value[0] == '-' || n.Value[0] >= '0' && n.Value[0] <= '9') && json.Valid([]byte(n.Value)) { + n.Tag = "!!int" + if strings.ContainsAny(n.Value, ".eE") { + n.Tag = "!!float" + } + } + stack = append(stack, n.Content...) + if len(stack) > o.limits.MaxNodes { + return &Result{}, &Error{Kind: ErrorLimit, Cause: fmt.Errorf("parsed node limit exceeded")} + } + } + return validate(ctx, Document{Root: &root, URI: uri}, o) +} + +// Validate checks an existing immutable node tree. Document errors are findings; +// unsupported versions, cancellation, limits and resolver failures are tool errors. +// A partial result accompanies operational failure and always has Complete=false. +func Validate(ctx context.Context, doc Document, opts ...Option) (*Result, error) { + o, err := configure(ctx, opts) + if err != nil { + return &Result{}, err + } + return validate(ctx, doc, o) +} + +func validate(ctx context.Context, doc Document, opts options) (result *Result, err error) { + result = &Result{} + v := &validation{ctx: ctx, doc: doc, opts: opts, result: result, budget: &workBudget{}} + defer func() { + if v.err != nil { + var toolError *Error + if !errors.As(v.err, &toolError) { + v.err = &Error{Kind: ErrorOperational, Location: v.location(""), Cause: v.err} + } + v.incomplete("operation", "", "", v.err.Error()) + err = v.err + } + v.finish() + }() + if !v.check() { + return result, v.err + } + if doc.Root == nil { + v.err = &Error{Kind: ErrorInput, Location: Location{URI: doc.URI}, Cause: fmt.Errorf("root must not be nil")} + return result, v.err + } + if _, err := url.Parse(doc.URI); err != nil { + v.err = &Error{Kind: ErrorConfiguration, Location: Location{URI: doc.URI}, Cause: err} + return result, v.err + } + value, nodes, diags, stats, normalizeErr := normalizeWithStats(ctx, doc, opts.limits) + v.nodes = nodes + v.nodeCount, v.byteCount = stats.nodes, stats.bytes + result.Diagnostics = diags + if normalizeErr != nil { + v.err = normalizeErr + return result, v.err + } + v.root = object(value) + if v.root == nil { + v.add(CodeStructure, "", "Arazzo document must be an object") + v.incomplete("semantics", "", "", "structural validation failed") + return result, nil + } + authored, exists := v.root["arazzo"] + v.version = "1.0" + if version, ok := authored.(string); ok { + parts := versionPattern.FindStringSubmatch(version) + if parts == nil { + v.err = &Error{Kind: ErrorInput, Location: v.location("/arazzo"), Cause: fmt.Errorf("malformed Arazzo version %q", version)} + return result, v.err + } + v.version = parts[1] + "." + parts[2] + result.Version = v.version + if v.version != "1.0" && v.version != "1.1" { + v.err = &Error{Kind: ErrorUnsupported, Location: v.location("/arazzo"), Cause: fmt.Errorf("unsupported Arazzo feature version %s", v.version)} + return result, v.err + } + } else if exists { + v.add(CodeStructure, "/arazzo", "arazzo must be a version string") + } + v.identity = resolveLinkedURI(v.doc.URI, v.opts.baseURI) + if v.version == "1.1" && text(v.root["$self"]) != "" { + v.identity = resolveLinkedURI(text(v.root["$self"]), v.identity) + } + if len(result.Diagnostics) == 0 { + checkInputLimits(v) + } + if len(result.Diagnostics) == 0 { + if v.check() { + checkStructure(v) + } + } + v.complete("structure", "") + if !v.check() { + return result, v.err + } + if !result.Valid() { + v.incomplete("semantics", "", "", "structural validation failed") + return result, nil + } + checkSemantics(v) + if v.check() { + checkExpressions(v) + } + if v.check() { + checkInputs(v) + } + if v.check() { + checkSources(v) + } + if v.check() { + finalizeExpressionUses(v) + } + if v.check() { + finalizeLinkedExpressionUses(v) + } + if v.check() { + checkDependencyGraph(v) + } + if v.check() { + v.complete("local-semantics", "") + } + if v.check() { + checkAdvice(v) + } + return result, v.err +} + +func (v *validation) location(path string) Location { + if loc, ok := v.foreignLocations[path]; ok { + return loc + } + loc := Location{URI: v.doc.URI, Pointer: path} + for p := path; ; { + if n := v.nodes[p].value; n != nil { + loc.Line, loc.Column = n.Line, n.Column + return loc + } + if p == "" { + return loc + } + p = p[:strings.LastIndex(p, "/")] + } +} + +func (v *validation) add(code Code, path, msg string) { + v.emit(Diagnostic{Code: code, Severity: SeverityError, Message: msg, Location: v.location(path)}) +} + +func (v *validation) emit(diagnostic Diagnostic) bool { + if !v.check() { + return false + } + if len(v.result.Diagnostics) >= v.opts.limits.MaxDiagnostics { + v.err = &Error{Kind: ErrorLimit, Location: diagnostic.Location, Cause: fmt.Errorf("diagnostic limit exceeded")} + return false + } + v.result.Diagnostics = append(v.result.Diagnostics, diagnostic) + return true +} + +func (v *validation) addKey(code Code, path, msg string) { + v.add(code, path, msg) + if v.err != nil { + return + } + if key := v.nodes[path].key; key != nil { + d := &v.result.Diagnostics[len(v.result.Diagnostics)-1] + d.Line, d.Column = key.Line, key.Column + } +} + +func (v *validation) check() bool { + if v.budget != nil && v.budget.err != nil { + v.err = v.budget.err + } + if v.err != nil { + return false + } + if err := v.ctx.Err(); err != nil { + v.err = err + return false + } + return true +} + +func (v *validation) documentID() string { + if v.identity != "" { + return v.identity + } + return v.doc.URI +} + +func (v *validation) work(count int, path string) bool { + if !v.check() { + return false + } + if v.budget == nil { + v.budget = &workBudget{} + } + if count > v.opts.limits.MaxNodes-v.budget.used { + v.err = &Error{Kind: ErrorLimit, Location: v.location(path), Cause: fmt.Errorf("derived validation work limit exceeded")} + v.budget.err = v.err + return false + } + v.budget.used += count + return true +} + +func (v *validation) workBytes(count int, path string) bool { + if !v.check() { + return false + } + if v.budget == nil { + v.budget = &workBudget{} + } + if count > v.opts.limits.MaxBytes-v.budget.bytes { + v.err = &Error{Kind: ErrorLimit, Location: v.location(path), Cause: fmt.Errorf("derived validation byte limit exceeded")} + v.budget.err = v.err + return false + } + v.budget.bytes += count + return true +} + +func (v *validation) incomplete(name, path, source, reason string) { + loc := v.location(path) + v.recordCheck(Check{Name: name, Status: CheckIncomplete, URI: loc.URI, Pointer: loc.Pointer, Source: source, Reason: reason}) +} + +func (v *validation) complete(name, path string) { + loc := v.location(path) + v.recordCheck(Check{Name: name, Status: CheckComplete, URI: loc.URI, Pointer: loc.Pointer}) +} + +func (v *validation) recordCheck(check Check) { + if check.Name != "operation" && !v.work(1, check.Pointer) { + return + } + v.result.Checks = append(v.result.Checks, check) +} + +func (v *validation) finish() { + sort.Slice(v.result.Diagnostics, func(i, j int) bool { + a, b := v.result.Diagnostics[i], v.result.Diagnostics[j] + if a.URI != b.URI { + return a.URI < b.URI + } + if a.Pointer != b.Pointer { + return a.Pointer < b.Pointer + } + if a.Code != b.Code { + return a.Code < b.Code + } + if a.Message != b.Message { + return a.Message < b.Message + } + if a.Line != b.Line { + return a.Line < b.Line + } + return a.Column < b.Column + }) + diags := v.result.Diagnostics[:0] + for _, d := range v.result.Diagnostics { + if len(diags) > 0 { + prev := diags[len(diags)-1] + if prev.URI == d.URI && prev.Pointer == d.Pointer && prev.Code == d.Code && prev.Message == d.Message { + continue + } + } + diags = append(diags, d) + } + v.result.Diagnostics = diags + sort.Slice(v.result.Checks, func(i, j int) bool { + a, b := v.result.Checks[i], v.result.Checks[j] + if a.URI != b.URI { + return a.URI < b.URI + } + if a.Pointer != b.Pointer { + return a.Pointer < b.Pointer + } + if a.Name != b.Name { + return a.Name < b.Name + } + if a.Status != b.Status { + return a.Status < b.Status + } + if a.Source != b.Source { + return a.Source < b.Source + } + return a.Reason < b.Reason + }) + checks := v.result.Checks[:0] + for _, c := range v.result.Checks { + if len(checks) > 0 && checks[len(checks)-1] == c { + continue + } + checks = append(checks, c) + } + v.result.Checks = checks + v.result.Complete = v.err == nil + for _, c := range checks { + if c.Status != CheckComplete { + v.result.Complete = false + } + } +} diff --git a/arazzo/validate_test.go b/arazzo/validate_test.go new file mode 100644 index 0000000..7948079 --- /dev/null +++ b/arazzo/validate_test.go @@ -0,0 +1,373 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/dlclark/regexp2" + "github.com/pb33f/go-yaml" +) + +func foundationYAML(version string) string { + return "arazzo: " + version + "\ninfo: {title: Example, version: '1'}\nsourceDescriptions:\n - {name: api, url: 'api.yaml', type: openapi}\nworkflows:\n - workflowId: run\n steps:\n - {stepId: read, operationId: read}\n" +} + +func foundationFinding(t *testing.T, r *Result, code Code, path string) Diagnostic { + t.Helper() + if r == nil { + t.Fatal("missing result") + } + for _, d := range r.Diagnostics { + if d.Code == code && d.Pointer == path { + return d + } + } + t.Fatalf("missing %s at %s: %+v", code, path, r.Diagnostics) + return Diagnostic{} +} + +func TestOfficialSchemaSnapshots(t *testing.T) { + for name, want := range map[string]string{ + "schemas/arazzo-1.0-2025-10-15.json": "b8715bd824fffcb2accf5077977d37c9e7a15be60d785e7a3a51cf600fd46ad4", + "schemas/arazzo-1.1-2026-04-15.json": "37be908409bdb2f7bffe61fa23685c7e84cbeebfafac475a1d01dbc50ff7ab9e", + } { + data, err := schemaFiles.ReadFile(name) + if err != nil { + t.Fatal(err) + } + if got := fmt.Sprintf("%x", sha256.Sum256(data)); got != want { + t.Fatalf("schema snapshot changed: %s", name) + } + } + if _, err := officialSchemas(); err != nil { + t.Fatal(err) + } +} + +func TestTrustedPatternEquivalence(t *testing.T) { + re, err := regexp2.Compile(`^(?!\$).+$`, regexp2.ECMAScript) + if err != nil { + t.Fatal(err) + } + alphabet := []string{"a", "$", "\n", "\r", "\u2028", "\u2029", "é", "😀"} + var check func(string, int) + check = func(s string, depth int) { + want, err := re.MatchString(s) + if err != nil { + t.Fatal(err) + } + if got := (nonDollarPattern{}).MatchString(s); got != want { + t.Errorf("%q: got %v want %v", s, got, want) + } + if depth > 0 { + for _, ch := range alphabet { + check(s+ch, depth-1) + } + } + } + check("", 3) +} + +func TestFoundationVersionsAndJSONParity(t *testing.T) { + for _, version := range []string{"1.0.0", "1.0.24", "1.1.0", "1.1.42", "1.1.0-preview"} { + t.Run(version, func(t *testing.T) { + data := []byte(foundationYAML(version)) + r, err := ValidateBytes(context.Background(), data, "https://example.test/main.yaml") + if err != nil || !r.Valid() { + t.Fatalf("%+v %v", r, err) + } + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + t.Fatal(err) + } + value, _, _, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) + if err != nil { + t.Fatal(err) + } + jsonData, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + jr, err := ValidateBytes(context.Background(), jsonData, "https://example.test/main.yaml") + if err != nil || !jr.Valid() || jr.Version != r.Version { + t.Fatalf("JSON parity: %+v %v", jr, err) + } + }) + } + for version, kind := range map[string]ErrorKind{"1.2.0": ErrorUnsupported, "2.0.0": ErrorUnsupported, "1.0": ErrorInput, "1.0.x": ErrorInput, "01.0.0": ErrorInput} { + r, err := ValidateBytes(context.Background(), []byte(foundationYAML("'"+version+"'")), "input.yaml") + var tool *Error + if !errors.As(err, &tool) || tool.Kind != kind || r.Complete { + t.Fatalf("version %s: %+v %v", version, r, err) + } + } + for _, value := range []string{"", "arazzo: false\n", "arazzo: null\n"} { + fixture := strings.Replace(foundationYAML("1.1.0"), "arazzo: 1.1.0\n", value, 1) + r, err := ValidateBytes(context.Background(), []byte(fixture), "version.yaml") + if err != nil || r.Valid() || r.Complete || r.Version != "" { + t.Fatalf("absent/nonstring version was reported as detected: %+v %v", r, err) + } + foundationFinding(t, r, CodeStructure, "/arazzo") + } +} + +func TestFoundationStructuralLocations(t *testing.T) { + tests := []struct { + name, from, to, path string + key bool + }{ + {"string type", "title: Example", "title: 13", "/info/title", false}, + {"null", "title: Example", "title: null", "/info/title", false}, + {"missing field", "title: Example, ", "", "/info/title", false}, + {"unknown", "title: Example", "title: Example, surprise: true", "/info/surprise", true}, + {"target union", "operationId: read", "operationId: read, workflowId: run", "/workflows/0/steps/0", false}, + {"source URI", "api.yaml", "http://[invalid", "/sourceDescriptions/0/url", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := strings.Replace(foundationYAML("1.1.0"), tt.from, tt.to, 1) + r, err := ValidateBytes(context.Background(), []byte(data), "test.yaml") + if err != nil { + t.Fatal(err) + } + d := foundationFinding(t, r, CodeStructure, tt.path) + if d.Line == 0 || d.Column == 0 || d.URI != "test.yaml" { + t.Fatalf("bad location: %+v", d) + } + var root yaml.Node + if err := yaml.Unmarshal([]byte(data), &root); err != nil { + t.Fatal(err) + } + _, nodes, _, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) + if err != nil { + t.Fatal(err) + } + if tt.key && d.Column != nodes[tt.path].key.Column { + t.Fatalf("expected key location: %+v", d) + } + }) + } +} + +func TestFoundationInputAndLimits(t *testing.T) { + for _, data := range []string{"", "[", foundationYAML("1.1.0") + "---\nnull\n"} { + _, err := ValidateBytes(context.Background(), []byte(data), "input.yaml") + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorInput { + t.Fatalf("expected input error: %v", err) + } + } + for _, data := range []string{"null", "[]", "true"} { + r, err := ValidateBytes(context.Background(), []byte(data), "") + if err != nil || r.Valid() { + t.Fatalf("%q: %+v %v", data, r, err) + } + } + limits := DefaultLimits() + limits.MaxBytes = 8 + r, err := ValidateBytes(context.Background(), []byte(foundationYAML("1.0.1")), "", WithLimits(limits)) + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorLimit || r.Complete { + t.Fatalf("%+v %v", r, err) + } + _, err = Validate(nil, Document{}) //nolint:staticcheck // Verify that the public API rejects a nil context. + if !errors.As(err, &tool) || tool.Kind != ErrorConfiguration { + t.Fatal(err) + } + _, err = Validate(context.Background(), Document{}, nil) + if !errors.As(err, &tool) || tool.Kind != ErrorConfiguration { + t.Fatal(err) + } + _, err = Validate(context.Background(), Document{}, WithLimits(Limits{})) + if !errors.As(err, &tool) || tool.Kind != ErrorConfiguration { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r, err = ValidateBytes(ctx, []byte(foundationYAML("1.0.1")), "") + if !errors.Is(err, context.Canceled) || !errors.As(err, &tool) || tool.Kind != ErrorOperational || r.Complete { + t.Fatalf("%+v %v", r, err) + } + r, err = Validate(ctx, Document{URI: "canceled.yaml"}) + if !errors.Is(err, context.Canceled) || !errors.As(err, &tool) || tool.Kind != ErrorOperational || r.Complete || len(r.Checks) != 1 { + t.Fatalf("node API cancellation was not a typed incomplete operation: %+v %v", r, err) + } +} + +func TestFoundationNoRetrieval(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + data := strings.Replace(foundationYAML("1.1.0"), "api.yaml", server.URL+"/api.yaml", 1) + data = strings.Replace(data, " steps:", " inputs: {$ref: '"+server.URL+"/schema.json'}\n steps:", 1) + r, err := ValidateBytes(context.Background(), []byte(data), "https://example.test/main.yaml") + if err != nil || !r.Valid() || r.Complete || requests.Load() != 0 { + t.Fatalf("%+v %v requests=%d", r, err, requests.Load()) + } +} + +func TestFoundationMergeAliasImmutability(t *testing.T) { + data := strings.Replace(foundationYAML("1.1.0"), " - {stepId: read, operationId: read}", " - &base {stepId: read, operationId: read}\n - {<<: *base, stepId: second}", 1) + var root yaml.Node + if err := yaml.Unmarshal([]byte(data), &root); err != nil { + t.Fatal(err) + } + before := cloneNodeGraph(&root) + var wg sync.WaitGroup + for range 16 { + wg.Add(1) + go func() { + defer wg.Done() + r, err := Validate(context.Background(), Document{Root: &root, URI: "main.yaml"}) + if err != nil || !r.Valid() { + t.Errorf("%+v %v", r, err) + } + }() + } + wg.Wait() + if !reflect.DeepEqual(before, &root) { + t.Fatal("caller tree was modified") + } +} + +func TestFoundationYAMLGraphsAndNumbers(t *testing.T) { + var root yaml.Node + data := "huge: 9007199254740993123456789\nzero: 0\n'false': false\n'null': null\nempty: {}\nhex: 0xFF\n" + if err := yaml.Unmarshal([]byte(data), &root); err != nil { + t.Fatal(err) + } + value, _, diags, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) + if err != nil || len(diags) > 0 { + t.Fatalf("%v %+v", err, diags) + } + m := object(value) + if m["huge"] != json.Number("9007199254740993123456789") || m["hex"] != json.Number("255") || m["false"] != false || m["null"] != nil || len(object(m["empty"])) != 0 { + t.Fatalf("lost values: %+v", m) + } + for _, data := range []string{"a: 1\na: 2\n", "true: false\n", "a: !!binary YQ==\n", "a: .inf\n", "a: &a {b: *a}\n"} { + if err := yaml.Unmarshal([]byte(data), &root); err != nil { + t.Fatal(err) + } + _, _, diags, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) + if err != nil || len(diags) == 0 { + t.Fatalf("%q: %+v %v", data, diags, err) + } + } + root = yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + root.Content = []*yaml.Node{&root} + _, _, diags, _, err = normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) + if err != nil || len(diags) != 1 { + t.Fatalf("recursive graph: %+v %v", diags, err) + } + limits := DefaultLimits() + limits.MaxNodes = 1 + _, _, _, _, err = normalizeWithStats(context.Background(), Document{Root: &root}, limits) + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorLimit { + t.Fatal(err) + } +} + +func TestFoundationPointerEscapesAndDeterminism(t *testing.T) { + data := foundationYAML("1.1.0") + "components:\n parameters:\n 'a/b~c': {name: q, in: query}\n '123': {name: n, in: query}\n" + r, err := ValidateBytes(context.Background(), []byte(data), "test.yaml") + if err != nil { + t.Fatal(err) + } + foundationFinding(t, r, CodeStructure, "/components/parameters/a~1b~0c/value") + foundationFinding(t, r, CodeStructure, "/components/parameters/123/value") + for range 8 { + again, err := ValidateBytes(context.Background(), []byte(data), "test.yaml") + if err != nil || !reflect.DeepEqual(r, again) { + t.Fatalf("unstable diagnostics: %+v %v", again, err) + } + } +} + +func TestFoundationNormativeCorrectionsAndAdvisories(t *testing.T) { + data := strings.Replace(foundationYAML("1.1.0"), "name: api", "name: api.source", 1) + data = strings.Replace(data, "operationId: read", "operationId: read, parameters: [{name: body, in: query, value: {context: literal, selector: '[', type: jsonpath}}]", 1) + r, err := ValidateBytes(context.Background(), []byte(data), "https://example.test/main.yaml") + if err != nil || !r.Valid() { + t.Fatalf("valid normative Any/source name: %+v %v", r, err) + } + for _, d := range r.Diagnostics { + if d.Severity == SeverityWarning { + t.Fatal("advice enabled without option") + } + } + r, err = ValidateBytes(context.Background(), []byte(data), "main.yaml", WithAdvisories()) + if err != nil || !r.Valid() { + t.Fatalf("%+v %v", r, err) + } + d := foundationFinding(t, r, CodeAdvisory, "/sourceDescriptions/0/name") + if d.Severity != SeverityWarning { + t.Fatal(d) + } + data = foundationYAML("1.1.0") + "components:\n successActions:\n go: {name: go, type: goto, workflowId: run, parameters: [123]}\n" + r, err = ValidateBytes(context.Background(), []byte(data), "main.yaml") + if err != nil || r.Valid() { + t.Fatalf("invalid action parameter escaped shape checks: %+v %v", r, err) + } + foundationFinding(t, r, CodeStructure, "/components/successActions/go/parameters/0") +} + +func TestFoundationCapsBeforeCompilation(t *testing.T) { + tests := []struct { + data string + limits Limits + }{ + {strings.Replace(foundationYAML("1.1.0"), " steps:", " inputs: {type: string, pattern: 'a very long pattern'}\n steps:", 1), func() Limits { l := DefaultLimits(); l.MaxPatternBytes = 4; return l }()}, + {strings.Replace(foundationYAML("1.1.0"), "operationId: read", "operationId: read, requestBody: {payload: 1e99999999999999}", 1), DefaultLimits()}, + {strings.Replace(foundationYAML("1.1.0"), " steps:", " x-a: {x-b: {x-c: {x-d: value}}}\n steps:", 1), func() Limits { l := DefaultLimits(); l.MaxDepth = 4; return l }()}, + } + for i, tt := range tests { + r, err := ValidateBytes(context.Background(), []byte(tt.data), "input.yaml", WithLimits(tt.limits)) + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorLimit || r.Complete { + t.Fatalf("case %d: %+v %v", i, r, err) + } + } +} + +// cloneNodeGraph snapshots caller-owned node graphs for immutability tests, +// preserving aliases and nil slices so comparisons detect actual changes. +func cloneNodeGraph(root *yaml.Node) *yaml.Node { + clones := make(map[*yaml.Node]*yaml.Node) + var clone func(*yaml.Node) *yaml.Node + clone = func(node *yaml.Node) *yaml.Node { + if node == nil { + return nil + } + if existing := clones[node]; existing != nil { + return existing + } + copyNode := *node + clones[node] = ©Node + if node.Content != nil { + copyNode.Content = make([]*yaml.Node, len(node.Content)) + } + for i, child := range node.Content { + copyNode.Content[i] = clone(child) + } + copyNode.Alias = clone(node.Alias) + return ©Node + } + return clone(root) +}