From f8f46d2ce19a294066606b6ace7a6780c128e420 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Wed, 7 Oct 2026 15:37:40 -0400 Subject: [PATCH 1/8] feat: add static Arazzo 1.0 and 1.1 validation --- README.md | 4 + arazzo/README.md | 78 ++ arazzo/REQUIREMENTS.md | 82 ++ arazzo/VALIDATION.md | 65 ++ arazzo/advice.go | 32 + arazzo/benchmark_test.go | 112 +++ arazzo/example_test.go | 44 + arazzo/expressions.go | 593 +++++++++++ arazzo/expressions_linked.go | 114 +++ arazzo/expressions_linked_test.go | 121 +++ arazzo/expressions_test.go | 257 +++++ arazzo/graph.go | 107 ++ arazzo/graph_test.go | 55 ++ arazzo/inputs.go | 414 ++++++++ arazzo/inputs_test.go | 154 +++ arazzo/nodes.go | 276 ++++++ arazzo/retention_test.go | 51 + arazzo/schema.go | 239 +++++ arazzo/schemas/LICENSE | 202 ++++ arazzo/schemas/NOTICE | 10 + arazzo/schemas/arazzo-1.0-2025-10-15.json | 792 +++++++++++++++ arazzo/schemas/arazzo-1.1-2026-04-15.json | 1042 ++++++++++++++++++++ arazzo/schemas/metadata.json | 16 + arazzo/semantics.go | 605 ++++++++++++ arazzo/semantics_test.go | 300 ++++++ arazzo/sources.go | 549 +++++++++++ arazzo/sources_graph.go | 230 +++++ arazzo/sources_metadata.go | 339 +++++++ arazzo/sources_targets.go | 511 ++++++++++ arazzo/sources_test.go | 697 +++++++++++++ arazzo/testdata/mixed-source/api.yaml | 7 + arazzo/testdata/mixed-source/events.yaml | 8 + arazzo/testdata/mixed-source/flows.yaml | 4 + arazzo/testdata/mixed-source/workflow.yaml | 13 + arazzo/types.go | 223 +++++ arazzo/validate.go | 394 ++++++++ arazzo/validate_test.go | 373 +++++++ 37 files changed, 9113 insertions(+) create mode 100644 arazzo/README.md create mode 100644 arazzo/REQUIREMENTS.md create mode 100644 arazzo/VALIDATION.md create mode 100644 arazzo/advice.go create mode 100644 arazzo/benchmark_test.go create mode 100644 arazzo/example_test.go create mode 100644 arazzo/expressions.go create mode 100644 arazzo/expressions_linked.go create mode 100644 arazzo/expressions_linked_test.go create mode 100644 arazzo/expressions_test.go create mode 100644 arazzo/graph.go create mode 100644 arazzo/graph_test.go create mode 100644 arazzo/inputs.go create mode 100644 arazzo/inputs_test.go create mode 100644 arazzo/nodes.go create mode 100644 arazzo/retention_test.go create mode 100644 arazzo/schema.go create mode 100644 arazzo/schemas/LICENSE create mode 100644 arazzo/schemas/NOTICE create mode 100644 arazzo/schemas/arazzo-1.0-2025-10-15.json create mode 100644 arazzo/schemas/arazzo-1.1-2026-04-15.json create mode 100644 arazzo/schemas/metadata.json create mode 100644 arazzo/semantics.go create mode 100644 arazzo/semantics_test.go create mode 100644 arazzo/sources.go create mode 100644 arazzo/sources_graph.go create mode 100644 arazzo/sources_metadata.go create mode 100644 arazzo/sources_targets.go create mode 100644 arazzo/sources_test.go create mode 100644 arazzo/testdata/mixed-source/api.yaml create mode 100644 arazzo/testdata/mixed-source/events.yaml create mode 100644 arazzo/testdata/mixed-source/flows.yaml create mode 100644 arazzo/testdata/mixed-source/workflow.yaml create mode 100644 arazzo/types.go create mode 100644 arazzo/validate.go create mode 100644 arazzo/validate_test.go diff --git a/README.md b/README.md index 96ae512..ad7f80e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ 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 additive [`arazzo` package](arazzo/README.md) validates Arazzo 1.0 and 1.1 +structure and static semantics. It accepts parsed YAML/JSON nodes and caller-supplied +source documents, with detached diagnostics and explicit coverage records. `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..0c17131 --- /dev/null +++ b/arazzo/README.md @@ -0,0 +1,78 @@ +# Static Arazzo validation + +`arazzo.Validate` accepts an existing `yaml.Node` and its retrieval URI. +`arazzo.ValidateBytes` accepts exactly one JSON or YAML document. Both use the +same validation path. Arazzo 1.0 and 1.1 feature sets accept compatible patch +versions and nonempty prerelease suffixes, as permitted by the official schemas. +The existing OpenAPI and HTTP validator APIs are unchanged. + +```go +result, err := arazzo.Validate(ctx, arazzo.Document{ + Root: root, + URI: "https://example.test/workflows.yaml", +}, arazzo.WithSources(candidates...)) +``` + +`candidates` uses the existing `libopenapi/arazzo.CandidateDocument` type. Sources +can contain original nodes, bytes, high models, or typed presence adapters. +`WithResolver` accepts the existing `SourceDocumentResolver` interface. A resolver +is explicit caller-owned retrieval authority; it must enforce its own access +policy and honor context cancellation. There is no implicit file or network I/O. +The executable [example](example_test.go) supplies OpenAPI, AsyncAPI and Arazzo +source nodes. Its fixture bundle is available to downstream consumers. + +## Outcomes + +| Outcome | Result | +| --- | --- | +| Conformance error | Diagnostic, normally no tool error; `Valid()` is false. | +| All applicable checks ran successfully | `Valid()` and `Complete` are true. | +| Source, detailed adapter metadata, or syntax capability unavailable | Local findings remain; `Checks` contains the affected URI, pointer, source and reason; `Complete` is false. | +| Known source proves missing target or wrong kind | Reference or source-type diagnostic at the authored field. | +| Unparseable bytes, unsupported feature version, invalid API configuration | Typed `*Error` with input, unsupported-version or configuration kind. | +| Cancellation, resource limit, resolver failure, compiler failure | Operational error with an incomplete partial result. Cancellation remains accessible through `errors.Is`. | + +Always check the returned error and `Complete`; `Valid()` alone means only that +no conformance errors were found. Structural failures stop semantic passes and +record that prerequisite failure in coverage. Missing or non-string versions are +structural findings; malformed version strings are typed input errors. + +Diagnostics have stable typed codes, severity, source URI, RFC 6901 pointer, and +one-based line/column where supplied. Missing fields use their nearest existing +parent. Invalid map names use key locations. Duplicate IDs include the first +declaration as a related location. Results contain only detached values and do +not retain nodes, models, indexes or compiler errors. + +## Boundaries + +Validation checks static structure, identities, references, expressions, +inheritance and prerequisites. It does not execute operations, evaluate criteria +against invented runtime data, fetch input schema references, authenticate, +schedule retries, or transport messages. Dynamic input schemas are checked +conservatively: absence from a direct properties map is not proof of absence +when references, composition or dynamic properties can supply that input. + +XPath and the legacy Goessner JSONPath dialect currently record unavailable +syntax coverage. RFC 9535 JSONPath, JSON Pointer, regex and simple conditions +have static parsers. Runtime-substituted condition syntax records incomplete +coverage after checking the embedded expressions. Presence-only source adapters +record the detailed metadata checks they cannot prove. Receive-step success can +also require message semantics that are unavailable statically. + +`WithLimits` replaces positive per-call limits. Defaults are 16 MiB of input and +expanded bytes, 200,000 nodes/derived work units, depth 128, 50 source documents, +1,000 diagnostics and 64 KiB per expression/pattern. Node/byte limits account for +alias expansion and cumulative supplied source data. Separate shared work budgets +bound derived graph edges, inherited values, reference hops, expression uses, +coverage records and repeated literal/condition bytes. Numeric exponents charge +their arbitrary-precision expansion before schema arithmetic. Source caches and +reference memoization last for one call. Only immutable official schemas are +cached globally. No goroutine is started per field or diagnostic. + +`WithAdvisories` enables naming and sequential forward-reference warnings. +Recommendations remain separate from required conformance errors. Vacuum can +map library codes and coverage to its own rules and severity policy. + +The [requirements ledger](REQUIREMENTS.md) records clauses, fixtures and schema +discrepancies. The [validation record](VALIDATION.md) contains checks, coverage and +benchmark results. Vacuum integration remains a separate consumer task. diff --git a/arazzo/REQUIREMENTS.md b/arazzo/REQUIREMENTS.md new file mode 100644 index 0000000..e986be2 --- /dev/null +++ b/arazzo/REQUIREMENTS.md @@ -0,0 +1,82 @@ +# 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 +unless the test name explicitly describes an internal graph or normalization unit. + +## 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`, `TestSemanticReusableOverridesAndDuplicateIdentity`, `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` | `TestSemanticReusableCollectionAndCycles`, `TestSemanticReusableOverridesAndDuplicateIdentity`, `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` | +| 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` | +| 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`, `TestInputPatternLimitAndLiteralAnnotations` | +| 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`; 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/VALIDATION.md b/arazzo/VALIDATION.md new file mode 100644 index 0000000..ba22c43 --- /dev/null +++ b/arazzo/VALIDATION.md @@ -0,0 +1,65 @@ +# Validation record + +Checked on 7 October 2026 in isolated branch `codex/arazzo-validation`, based on +`b1808fec4e5a405c08332df7b64f747b4a2d2c5a`. This record covers local checks before +PR publication. The primary checkout remains unchanged. + +All checks use Go 1.26.8, `GOWORK=off`, and the module's released dependencies. + +| Check | Result | +| --- | --- | +| `go test ./... -count=1` | All module tests pass, including the external-package mixed-source example. | +| `go test -race -coverprofile=… ./arazzo -count=1` | Pass; 77.3% package statement coverage. | +| `go test -race ./arazzo -run TestResultDoesNotRetainSourceGraphs -count=20` | Pass; retained diagnostics do not retain primary/source root graphs. | +| `go build ./...` | Pass. | +| `go vet ./...` | Pass. | +| `go mod tidy -diff` | Pass, no module or checksum changes. | +| `golangci-lint run ./arazzo/...` | Pass, zero issues. | +| gofumpt, gci and `git diff --check` | Pass. | +| Schema snapshot checksums and all 69 ledger test references | Pass. | +| Independent correctness and resource reviews | All reported findings repaired and verified; no remaining P0–P2 findings in the reviewed scope. | + +Statement coverage is measured coverage, not a claim of full branch coverage. +The [requirements ledger](REQUIREMENTS.md) records semantic fixtures and capability +limits. The [package guide](README.md) explains the result and coverage contract. + +## Benchmarks + +Apple M4 Max, darwin/arm64, default 16 logical processors. Values are medians of +five local samples. The warm benchmarks accept existing nodes; they include +per-call normalization, indexing, validation and detached result construction. +The cold benchmark compiles both embedded schemas without the global cache. + +| Case | ns/op | B/op | allocs/op | +| --- | ---: | ---: | ---: | +| Warm: 10 steps, operations and expressions | 254,632 | 342,606 | 4,058 | +| Warm: 100 steps, operations and expressions | 2,429,607 | 3,237,388 | 38,691 | +| Warm: 1,000 steps, operations and expressions | 21,610,055 | 33,074,060 | 387,775 | +| 10 findings | 163,165 | 211,506 | 2,851 | +| 100 findings | 1,585,993 | 1,988,292 | 27,090 | +| 500 findings | 7,223,785 | 9,953,842 | 136,037 | +| 10 workflows sharing a source prerequisite and reusable parameter | 396,294 | 340,435 | 3,967 | +| 100 workflows sharing a source prerequisite and reusable parameter | 3,042,558 | 2,964,794 | 35,805 | +| 1,000 workflows sharing a source prerequisite and reusable parameter | 21,864,433 | 29,375,616 | 356,961 | +| Cold: compile both official schemas | 4,679,181 | 5,043,806 | 66,463 | + +Time and allocation growth are approximately linear across these workloads. +These are local measurements, not service latency promises. Resolver transport +time is excluded: benchmark sources are supplied in memory. + +The resource review also probed workflow prerequisite fan-out and shared local +OpenAPI reference chains. Workflow prerequisites use a shared start node rather +than a steps × dependencies edge expansion. Reference chains use a per-call memo. +Inherited action/list expansion and repeated parser input have explicit budgets +before allocation or parsing, with public limit regressions. + +## Consumer handback + +Use `Validate` for parsed nodes and `ValidateBytes` for one JSON/YAML document. +Use upstream `CandidateDocument` and `SourceDocumentResolver` through options. +Always inspect the returned error, `Result.Valid()` and `Result.Complete`. + +The library performs no default retrieval or workflow execution. XPath, legacy +JSONPath, runtime-substituted conditions and unavailable detailed source metadata +produce explicit incomplete coverage. No upstream exports or dependency upgrades +are required. Vacuum integration is the next separate consumer change. 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..46f5f5f --- /dev/null +++ b/arazzo/benchmark_test.go @@ -0,0 +1,112 @@ +// 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 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(); 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) + } + } + }) + } +} 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..9180214 --- /dev/null +++ b/arazzo/expressions.go @@ -0,0 +1,593 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "encoding/json" + "fmt" + "mime" + "strconv" + "strings" + "unicode/utf8" + + "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 semanticKeys(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 semanticKeys(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 semanticKeys(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 semanticKeys(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 !utf8.ValidString(s) { + return fmt.Errorf("invalid UTF-8") + } + if s != "" && s[0] != '/' { + return fmt.Errorf("JSON Pointer must be empty or start with '/'") + } + for i := 0; i < len(s); i++ { + if s[i] == '~' { + if i+1 == len(s) || s[i+1] != '0' && s[i+1] != '1' { + return fmt.Errorf("invalid JSON Pointer escape") + } + i++ + } + } + 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 '$': + 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 + } + } +} diff --git a/arazzo/expressions_linked.go b/arazzo/expressions_linked.go new file mode 100644 index 0000000..fbded2f --- /dev/null +++ b/arazzo/expressions_linked.go @@ -0,0 +1,114 @@ +// 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 := make(map[string]map[string]any) + for _, raw := range array(target.node["steps"]) { + step := object(raw) + steps[text(step["stepId"])] = step + } + 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..fd35e2a --- /dev/null +++ b/arazzo/expressions_test.go @@ -0,0 +1,257 @@ +// 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) + } + } +} 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..e6cdd3a --- /dev/null +++ b/arazzo/inputs.go @@ -0,0 +1,414 @@ +// 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 + trusted bool +} +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 !r.trusted && 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, trusted: true} + compiler.UseRegexpEngine(regex.compile) + meta, err := compiler.Compile("https://json-schema.org/draft/2020-12/schema") + regex.trusted = false + if err != nil { + v.err = &Error{Kind: ErrorOperational, Cause: fmt.Errorf("compile input metaschema: %w", err)} + return + } + valid := true + for _, slot := range slots { + if !v.check() { + return + } + if err := meta.Validate(slot.value); err != nil { + var ve *jsonschema.ValidationError + if errors.As(err, &ve) { + inputSchemaFindings(v, ve, slot.path) + } else { + v.err = &Error{Kind: ErrorOperational, Location: v.location(slot.path), Cause: err} + return + } + valid = false + } + if regex.err != nil { + v.err = &Error{Kind: ErrorOperational, Location: v.location(slot.path), Cause: regex.err} + return + } + } + if !valid { + return + } + 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)) + if regex.err != nil { + v.err = &Error{Kind: ErrorOperational, Location: v.location(slot.path), Cause: regex.err} + return + } + } + // 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 regex.err != nil { + v.err = &Error{Kind: ErrorOperational, Location: v.location(slot.path), Cause: regex.err} + return + } + if err == nil { + continue + } + if v.err != nil { + return + } + 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 inputSchemaFindings(v *validation, e *jsonschema.ValidationError, base string) { + if !v.check() { + return + } + if len(e.Causes) > 0 { + for _, cause := range e.Causes { + inputSchemaFindings(v, cause, base) + } + return + } + path := base + for _, token := range e.InstanceLocation { + path = joinPtr(path, token) + } + v.add(CodeInputSchema, path, e.Error()) +} + +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) + } + } + // Only schema-bearing keywords count as schema references. In particular, + // objects inside const/default/examples are data, not subschema declarations. + for _, keyword := range []string{"$defs", "properties", "patternProperties", "dependentSchemas"} { + children := object(m[keyword]) + keys := make([]string, 0, len(children)) + for key := range children { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + childPath := joinPtr(joinPtr(path, keyword), key) + if candidate := schemaReferencePath(children[key], childPath); candidate != childPath { + return candidate + } + } + } + for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { + for i, child := range array(m[keyword]) { + childPath := joinPtr(joinPtr(path, keyword), strconv.Itoa(i)) + if candidate := schemaReferencePath(child, childPath); candidate != childPath { + return candidate + } + } + } + for _, keyword := range []string{"items", "contains", "not", "if", "then", "else", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "propertyNames", "contentSchema"} { + if child, ok := m[keyword]; ok { + childPath := joinPtr(path, keyword) + if candidate := schemaReferencePath(child, childPath); candidate != childPath { + return candidate + } + } + } + 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 + } + var invalid *jsonschema.SchemaValidationError + if errors.As(err, &invalid) { + var ve *jsonschema.ValidationError + if errors.As(invalid.Err, &ve) { + inputSchemaFindings(v, ve, "") + 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)}) + } + } + } + } + for _, keyword := range []string{"$defs", "properties", "patternProperties", "dependentSchemas"} { + children := object(m[keyword]) + keys := make([]string, 0, len(children)) + for key := range children { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + collectInputReferences(children[key], joinPtr(joinPtr(path, keyword), key), base, out, index) + } + } + for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { + for i, child := range array(m[keyword]) { + collectInputReferences(child, joinPtr(joinPtr(path, keyword), strconv.Itoa(i)), base, out, index) + } + } + for _, keyword := range []string{"items", "contains", "not", "if", "then", "else", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "propertyNames", "contentSchema"} { + if child, ok := m[keyword]; ok { + collectInputReferences(child, joinPtr(path, keyword), base, out, index) + } + } +} + +// 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 semanticKeys(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 + } + for _, keyword := range []string{"$defs", "properties", "patternProperties", "dependentSchemas"} { + children := object(m[keyword]) + keys := make([]string, 0, len(children)) + for key := range children { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + walkInputSchemas(v, children[key], joinPtr(joinPtr(path, keyword), key), visit) + } + } + for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { + for i, child := range array(m[keyword]) { + walkInputSchemas(v, child, joinPtr(joinPtr(path, keyword), strconv.Itoa(i)), visit) + } + } + for _, keyword := range []string{"items", "contains", "not", "if", "then", "else", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "propertyNames", "contentSchema"} { + if child, ok := m[keyword]; ok { + walkInputSchemas(v, child, joinPtr(path, keyword), visit) + } + } +} + +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..2318434 --- /dev/null +++ b/arazzo/nodes.go @@ -0,0 +1,276 @@ +// 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 +} + +// normalize 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. +func normalize(ctx context.Context, doc Document, limits Limits) (any, map[string]nodeLocation, []Diagnostic, error) { + value, nodes, diagnostics, _, err := normalizeWithStats(ctx, doc, limits) + return value, nodes, diagnostics, err +} + +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/retention_test.go b/arazzo/retention_test.go new file mode 100644 index 0000000..7d4a350 --- /dev/null +++ b/arazzo/retention_test.go @@ -0,0 +1,51 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "encoding/json" + "runtime" + "strings" + "testing" + "weak" + + "github.com/pb33f/go-yaml" + upstream "github.com/pb33f/libopenapi/arazzo" +) + +func retainedResult(t *testing.T) (*Result, weak.Pointer[yaml.Node], weak.Pointer[yaml.Node]) { + t.Helper() + root, source := new(yaml.Node), new(yaml.Node) + input := strings.Replace(foundationYAML("1.1.0"), "operationId: read}", "operationId: read, outputs: {id: 'invalid expression'}}", 1) + if err := yaml.Unmarshal([]byte(input), root); err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal([]byte("openapi: 3.1.0\ninfo: {title: API, version: '1'}\npaths: {/items: {get: {operationId: read, responses: {'200': {description: OK}}}}}\n"), source); err != nil { + t.Fatal(err) + } + result, err := Validate(context.Background(), Document{Root: root, URI: "https://example.test/main.yaml"}, WithSources(upstream.CandidateDocument{RootNode: source, RetrievalURI: "https://example.test/api.yaml"})) + if err != nil || !result.Complete || len(result.Diagnostics) == 0 { + t.Fatalf("retention fixture did not produce completed findings: %+v %v", result, err) + } + return result, weak.Make(root), weak.Make(source) +} + +func TestResultDoesNotRetainSourceGraphs(t *testing.T) { + result, primary, source := retainedResult(t) + for range 10 { + runtime.GC() + if primary.Value() == nil && source.Value() == nil { + break + } + runtime.Gosched() + } + if primary.Value() != nil || source.Value() != nil { + t.Fatal("retained diagnostics kept a caller source graph alive") + } + if _, err := json.Marshal(result); err != nil { + t.Fatalf("detached result is not serializable: %v", err) + } + runtime.KeepAlive(result) +} diff --git a/arazzo/schema.go b/arazzo/schema.go new file mode 100644 index 0000000..c7adf32 --- /dev/null +++ b/arazzo/schema.go @@ -0,0 +1,239 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "bytes" + "embed" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/dlclark/regexp2" + "github.com/pb33f/jsonschema/v6" + "github.com/pb33f/jsonschema/v6/kind" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +//go:embed schemas/*.json +var schemaFiles embed.FS + +var officialSchemas = sync.OnceValues(compileOfficialSchemas) + +func compileOfficialSchemas() (map[string]*jsonschema.Schema, error) { + compiled := make(map[string]*jsonschema.Schema, 2) + for _, entry := range []struct{ version, file string }{ + {"1.0", "schemas/arazzo-1.0-2025-10-15.json"}, + {"1.1", "schemas/arazzo-1.1-2026-04-15.json"}, + } { + data, err := schemaFiles.ReadFile(entry.file) + if err != nil { + return nil, err + } + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return nil, err + } + compiler := jsonschema.NewCompiler() + compiler.UseLoader(denyLoader{}) + compiler.UseRegexpEngine(compileSchemaPattern) + compiler.AssertFormat() + // Source-name conventions are SHOULD in both specifications, not MUST. + // The semantic advice pass applies this pattern only when requested. + delete(object(object(object(object(value)["$defs"])["source-description-object"])["properties"])["name"].(map[string]any), "pattern") + if entry.version == "1.1" { + // Parameter.value is Any in the normative field table (5.8.6.1). + // The dated schema omits literal objects from this union. Correct only + // this slot in the compilation view; preserve the embedded snapshot. + defs := object(object(value)["$defs"]) + object(object(defs["parameter-object"])["properties"])["value"] = true + // Actions accept the same Parameter/Reusable union (5.8.7/5.8.8). + // The dated schema's items:true otherwise accepts numbers and null. + for _, name := range []string{"success-action-object", "failure-action-object"} { + object(object(object(defs[name])["properties"])["parameters"])["items"] = map[string]any{"oneOf": []any{ + map[string]any{"$ref": "#/$defs/parameter-object"}, map[string]any{"$ref": "#/$defs/reusable-object"}, + }} + } + } + id := text(object(value)["$id"]) + if err := compiler.AddResource(id, value); err != nil { + return nil, err + } + schema, err := compiler.Compile(id) + if err != nil { + return nil, err + } + compiled[entry.version] = schema + } + return compiled, nil +} + +type externalResourceError struct{ URI string } + +func (e *externalResourceError) Error() string { + return "external schema resource is not supplied: " + e.URI +} + +type denyLoader struct{} + +func (denyLoader) Load(uri string) (any, error) { return nil, &externalResourceError{URI: uri} } + +// The fixed lookahead has a linear implementation equivalent to the pinned +// regexp2 ECMAScript engine. The exhaustive boundary test includes every line +// separator, empty strings, dollar positions and non-ASCII characters. +type nonDollarPattern struct{} + +func (nonDollarPattern) String() string { return `^(?!\$).+$` } +func (nonDollarPattern) MatchString(s string) bool { + return s != "" && s[0] != '$' && !strings.ContainsAny(s, "\r\n") +} + +type ( + patternMatchError struct{ err error } + ecmaPattern struct{ regex *regexp2.Regexp } +) + +func (p ecmaPattern) String() string { return p.regex.String() } +func (p ecmaPattern) MatchString(s string) bool { + match, err := p.regex.MatchString(s) + // The compiler's Regexp interface cannot return an error. A private panic + // transports this operational failure to the validation boundary; it cannot + // silently turn a timeout into a clean or invalid document result. + if err != nil { + panic(patternMatchError{err}) + } + return match +} + +func compileSchemaPattern(pattern string) (jsonschema.Regexp, error) { + if len(pattern) > DefaultLimits().MaxPatternBytes { + return nil, fmt.Errorf("pattern exceeds compile limit") + } + if pattern == `^(?!\$).+$` { + return nonDollarPattern{}, nil + } + if compiled, err := regexp.Compile(pattern); err == nil { + return compiled, nil + } + compiled, err := regexp2.Compile(pattern, regexp2.ECMAScript) + if err != nil { + return nil, err + } + compiled.MatchTimeout = 50 * time.Millisecond + return ecmaPattern{compiled}, nil +} + +func checkStructure(v *validation) { + 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 { + v.add(CodeStructure, "/workflows/"+strconv.Itoa(wi)+"/steps/"+strconv.Itoa(si), "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 + } + var validationError *jsonschema.ValidationError + if !errors.As(err, &validationError) { + v.err = &Error{Kind: ErrorOperational, Cause: err} + return + } + printer := message.NewPrinter(language.English) + var collect func(*jsonschema.ValidationError, string) + collect = func(failure *jsonschema.ValidationError, keyPath string) { + if !v.check() { + return + } + path := "" + for _, token := range failure.InstanceLocation { + path = joinPtr(path, token) + } + if keyPath != "" { + path = keyPath + } + schemaPath := failure.SchemaURL + for _, token := range failure.ErrorKind.KeywordPath() { + schemaPath = joinPtr(schemaPath, token) + } + emit := func(path, msg string, key bool) { + 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, keyPath) + } + return + } + switch failure.ErrorKind.(type) { + case *kind.Schema, *kind.Group, *kind.Reference: + return + 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, "") + if len(v.result.Diagnostics) == 0 && v.err == nil { + v.add(CodeStructure, "", err.Error()) + } +} 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/semantics.go b/arazzo/semantics.go new file mode 100644 index 0000000..e26d783 --- /dev/null +++ b/arazzo/semantics.go @@ -0,0 +1,605 @@ +// 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 +} + +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 semanticKeys(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 { + 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) + v.checkActionExpressions(action.value, action.path, exprScope{workflowID: w.id, stepID: s.id}) + for _, p := range v.resolveList(action.value["parameters"], action.path+"/parameters", "parameters") { + v.checkParameterExpressions(p.value, p.path, exprScope{workflowID: w.id, stepID: s.id}) + } + } + } + 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 semanticKeys(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) { + 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}, 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..7915723 --- /dev/null +++ b/arazzo/semantics_test.go @@ -0,0 +1,300 @@ +// 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 semanticRun(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 +} + +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 { + for _, d := range v.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.result.Diagnostics) != 0 { + t.Fatalf("valid inherited header: %+v", v.result.Diagnostics) + } + v = semanticRun(t, `{"workflows":[{"workflowId":"run","parameters":[{"name":"id","value":0}],"steps":[{"stepId":"first","operationId":"fetch"}]}]}`, "1.0") + if !semanticHas(v, CodeParameter, "/workflows/0/parameters/0") { + t.Fatalf("missing effective location: %+v", v.result.Diagnostics) + } +} + +func TestSemanticReusableOverridesAndDuplicateIdentity(t *testing.T) { + for _, raw := range []string{"false", "0", "null"} { + t.Run(raw, func(t *testing.T) { + v := semanticRun(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 := semanticRun(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 semanticHas(v, CodeReference, "/workflows/0/steps/0/onSuccess/0/stepId") { + t.Fatal("valid component target rejected") + } + if !semanticHas(v, CodeReference, "/workflows/1/steps/0/onSuccess/0/stepId") { + t.Fatalf("consuming workflow target missed: %+v", v.result.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 !semanticHas(v, CodeDuplicateID, "/workflows/0/steps/0/onSuccess/1") { + t.Fatalf("effective action duplicate missed: %+v", v.result.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.result.Diagnostics) != 0 { + t.Fatalf("legal field-specific dependency rejected: %+v", v.result.Diagnostics) + } + v = semanticRun(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","dependsOn":["second"]},{"stepId":"second","operationId":"fetch","dependsOn":["first"]}]}]}`, "1.1") + if !semanticHas(v, CodeDependency, "/workflows/0/steps/1/dependsOn/0") { + t.Fatalf("step cycle missed: %+v", v.result.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 !semanticHas(v, CodeDependency, "/workflows/1/dependsOn/0") { + t.Fatalf("workflow cycle missed: %+v", v.result.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 !semanticHas(v, CodeDependency, "/workflows/0/steps/1/parameters/0/value") { + t.Fatalf("implicit output cycle missed: %+v", v.result.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.result.Diagnostics) != 0 { + t.Fatalf("dotted output name rejected: %+v", v.result.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.result.Diagnostics) != 0 { + t.Fatalf("completed current output falsely became a prerequisite: %+v", v.result.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 TestSemanticReusableCollectionAndCycles(t *testing.T) { + v := semanticRun(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 = semanticRun(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/sources.go b/arazzo/sources.go new file mode 100644 index 0000000..2708602 --- /dev/null +++ b/arazzo/sources.go @@ -0,0 +1,549 @@ +// 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 + base string + byURI map[string]*linkedSource + byName map[string]map[string]any + requests map[string]*linkedSource + count, bytes, nodes int + graphVisited map[*linkedSource]bool + scopePath string + scopeSource *linkedSource + descriptionPaths map[string]string + targets map[dependencyID]*linkedTarget +} + +type linkedSource struct { + owner *sourceSession + nodes map[string]nodeLocation + unresolvedRefs 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 +} + +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, byURI: make(map[string]*linkedSource), byName: make(map[string]map[string]any), requests: make(map[string]*linkedSource), graphVisited: make(map[*linkedSource]bool)} + 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 semanticKeys(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 + if s.nodes > s.v.opts.limits.MaxNodes { + s.fail(ErrorLimit, "total source node limit exceeded") + return nil + } + 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.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 + } + + 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..9ecbe7a --- /dev/null +++ b/arazzo/sources_graph.go @@ -0,0 +1,230 @@ +// 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 + } + for _, entry := range array(target.node["steps"]) { + if text(object(entry)["stepId"]) == step { + 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 := *s + child.base = source.base + child.scopePath = path + child.scopeSource = source + child.descriptionPaths = make(map[string]string) + child.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 := false + for _, candidate := range array(target.node["steps"]) { + if text(object(candidate)["stepId"]) == otherStep { + found = true + break + } + } + 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 := false + for _, candidate := range array(source.workflows[other]["steps"]) { + if text(object(candidate)["stepId"]) == otherStep { + found = true + break + } + } + 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) + s.complete("external-prerequisites", path, source.identity) + s.count = child.count + s.bytes = child.bytes + s.nodes = child.nodes +} + +// 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 := *s + child.v = nested + nested.sources = &child + checkExpressions(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.err != nil { + s.v.err = nested.err + } + s.count, s.bytes, s.nodes = child.count, child.bytes, child.nodes +} diff --git a/arazzo/sources_metadata.go b/arazzo/sources_metadata.go new file mode 100644 index 0000000..153d1d7 --- /dev/null +++ b/arazzo/sources_metadata.go @@ -0,0 +1,339 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "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 + } + } + case "openapi": + for _, collection := range []string{"paths", "webhooks"} { + values := object(source.root[collection]) + for _, key := range semanticKeys(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 semanticKeys(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 semanticKeys(callbacks) { + value := callbacks[key] + callback := source.deref(object(value)) + for _, expression := range semanticKeys(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 semanticKeys(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 semanticKeys(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) + } + } + } +} + +// 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 { + for method, operation := range item.AdditionalOperations.FromOldest() { + 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 semanticKeys(item) { + value := item[method] + if httpMethod(method) { + operations[method] = source.deref(object(value)) + } + } + additional := object(item["additionalOperations"]) + for _, method := range semanticKeys(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 semanticKeys(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 semanticKeys(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_targets.go b/arazzo/sources_targets.go new file mode 100644 index 0000000..fab3c73 --- /dev/null +++ b/arazzo/sources_targets.go @@ -0,0 +1,511 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "fmt" + "net/url" + "sort" + "strings" + + 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 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/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..460065e --- /dev/null +++ b/arazzo/types.go @@ -0,0 +1,223 @@ +// 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{16 << 20, 200000, 128, 50, 1000, 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..759e175 --- /dev/null +++ b/arazzo/validate.go @@ -0,0 +1,394 @@ +// 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. +func ValidateBytes(ctx context.Context, data []byte, uri string, opts ...Option) (*Result, error) { + o, err := configure(ctx, opts) + if err != nil { + return nil, 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 nil, &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 nil, &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 == nil { + continue + } + 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 nil, 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 recovered := recover(); recovered != nil { + if patternError, ok := recovered.(patternMatchError); ok { + v.err = &Error{Kind: ErrorOperational, Cause: patternError.err} + } else { + panic(recovered) + } + } + 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() && v.graph != nil { + if err := v.graph.check(ctx, func(path, msg string) { v.add(CodeDependency, path, msg) }); err != nil { + v.err = err + } + } + 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..628eca5 --- /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 := normalize(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 := normalize(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 := normalize(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 := normalize(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 = normalize(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 = normalize(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) +} From 2bdb6bace207853b5431495282a79b9be195b57c Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Wed, 7 Oct 2026 16:37:27 -0400 Subject: [PATCH 2/8] fix: preserve header tokens in Arazzo criteria --- arazzo/expressions.go | 50 +++++++++++++++++++++++++++++--------- arazzo/expressions_test.go | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/arazzo/expressions.go b/arazzo/expressions.go index 9180214..acfe8f7 100644 --- a/arazzo/expressions.go +++ b/arazzo/expressions.go @@ -521,20 +521,32 @@ func (p *simpleParser) atom() error { return fmt.Errorf("unclosed string literal") } case '$': - 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 + 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 @@ -591,3 +603,17 @@ func (p *simpleParser) atom() error { } } } + +// 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_test.go b/arazzo/expressions_test.go index fd35e2a..073f90b 100644 --- a/arazzo/expressions_test.go +++ b/arazzo/expressions_test.go @@ -255,3 +255,51 @@ func TestExpressionCappedDiagnosticsDeterministic(t *testing.T) { } } } + +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) + } + }) + } + } +} From 3592f3469511e1d148ab85be7822e429b7e04ec8 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Wed, 7 Oct 2026 16:37:39 -0400 Subject: [PATCH 3/8] fix: bound linked Arazzo prerequisite validation --- arazzo/benchmark_test.go | 27 +++++ arazzo/expressions_linked.go | 6 +- arazzo/schema.go | 7 +- arazzo/sources.go | 4 + arazzo/sources_graph.go | 33 +++--- arazzo/sources_metadata.go | 31 ++++++ arazzo/sources_regression_test.go | 171 ++++++++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 26 deletions(-) create mode 100644 arazzo/sources_regression_test.go diff --git a/arazzo/benchmark_test.go b/arazzo/benchmark_test.go index 46f5f5f..582e1f7 100644 --- a/arazzo/benchmark_test.go +++ b/arazzo/benchmark_test.go @@ -110,3 +110,30 @@ func BenchmarkValidateSharedDependencies(b *testing.B) { }) } } + +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/expressions_linked.go b/arazzo/expressions_linked.go index fbded2f..871f068 100644 --- a/arazzo/expressions_linked.go +++ b/arazzo/expressions_linked.go @@ -87,11 +87,7 @@ func finalizeLinkedExpressionUses(v *validation) { continue } case strings.HasPrefix(tail, "steps."): - steps := make(map[string]map[string]any) - for _, raw := range array(target.node["steps"]) { - step := object(raw) - steps[text(step["stepId"])] = step - } + 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") diff --git a/arazzo/schema.go b/arazzo/schema.go index c7adf32..09da300 100644 --- a/arazzo/schema.go +++ b/arazzo/schema.go @@ -49,10 +49,15 @@ func compileOfficialSchemas() (map[string]*jsonschema.Schema, error) { // The semantic advice pass applies this pattern only when requested. delete(object(object(object(object(value)["$defs"])["source-description-object"])["properties"])["name"].(map[string]any), "pattern") if entry.version == "1.1" { + defs := object(object(value)["$defs"]) + // Source names are advisory conventions. Keep the same decision for + // qualified step prerequisites; the semantic pass resolves exact names. + dependencies := object(object(object(defs["step-object-base"])["properties"])["dependsOn"]) + alternatives := array(object(dependencies["items"])["oneOf"]) + object(alternatives[2])["pattern"] = `^\$sourceDescriptions\.[\s\S]+\.[^.]+\.steps\.[^.]+$` // Parameter.value is Any in the normative field table (5.8.6.1). // The dated schema omits literal objects from this union. Correct only // this slot in the compilation view; preserve the embedded snapshot. - defs := object(object(value)["$defs"]) object(object(defs["parameter-object"])["properties"])["value"] = true // Actions accept the same Parameter/Reusable union (5.8.7/5.8.8). // The dated schema's items:true otherwise accepts numbers and null. diff --git a/arazzo/sources.go b/arazzo/sources.go index 2708602..99fdfb3 100644 --- a/arazzo/sources.go +++ b/arazzo/sources.go @@ -35,6 +35,7 @@ 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 @@ -42,6 +43,7 @@ type linkedSource struct { 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 { @@ -297,6 +299,7 @@ func (s *sourceSession) build(source *upstream.ResolvedSource) *linkedSource { } 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 { @@ -356,6 +359,7 @@ func (s *sourceSession) build(source *upstream.ResolvedSource) *linkedSource { item.workflows[workflow.WorkflowId] = wf } item.root["workflows"] = workflows + item.indexSteps() } return item diff --git a/arazzo/sources_graph.go b/arazzo/sources_graph.go index 9ecbe7a..dd77809 100644 --- a/arazzo/sources_graph.go +++ b/arazzo/sources_graph.go @@ -26,11 +26,9 @@ func (s *sourceSession) externalStep(value, path string) (dependencyID, bool) { s.v.incomplete("step-dependency", path, name, "adapter does not expose workflow steps") return dependencyID{}, false } - for _, entry := range array(target.node["steps"]) { - if text(object(entry)["stepId"]) == step { - s.graphSource(target.source, path, 1) - return dependencyID{document: target.source.identity, workflow: wf, step: step}, true - } + 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 @@ -115,13 +113,7 @@ func (s *sourceSession) graphSource(source *linkedSource, path string, depth int if target == nil { continue } - found := false - for _, candidate := range array(target.node["steps"]) { - if text(object(candidate)["stepId"]) == otherStep { - found = true - break - } - } + 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") @@ -139,13 +131,7 @@ func (s *sourceSession) graphSource(source *linkedSource, path string, depth int s.v.add(CodeReference, path, "invalid cross-workflow step dependency") continue } - found := false - for _, candidate := range array(source.workflows[other]["steps"]) { - if text(object(candidate)["stepId"]) == otherStep { - found = true - break - } - } + found := source.steps[other][otherStep] != nil if !found { s.v.add(CodeReference, path, "external cross-workflow step prerequisite does not exist") continue @@ -160,7 +146,11 @@ func (s *sourceSession) graphSource(source *linkedSource, path string, depth int } } child.graphExpressions(source) - s.complete("external-prerequisites", path, source.identity) + 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) + } s.count = child.count s.bytes = child.bytes s.nodes = child.nodes @@ -222,6 +212,9 @@ func (s *sourceSession) graphExpressions(source *linkedSource) { 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 index 153d1d7..d5768a1 100644 --- a/arazzo/sources_metadata.go +++ b/arazzo/sources_metadata.go @@ -4,6 +4,7 @@ package arazzo import ( + "fmt" "sort" "strconv" "strings" @@ -22,6 +23,7 @@ func (source *linkedSource) indexRaw() { source.workflows[id] = workflow } } + source.indexSteps() case "openapi": for _, collection := range []string{"paths", "webhooks"} { values := object(source.root[collection]) @@ -72,6 +74,29 @@ func (source *linkedSource) indexRaw() { } } +// 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 { @@ -175,7 +200,13 @@ func (source *linkedSource) indexOpenAPI(document *v3.Document, s *sourceSession } 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 } } 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") + } + } + } + } +} From ea17d654917d7baaa5a8dc5dae67c443ac96ebd1 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Wed, 7 Oct 2026 16:37:41 -0400 Subject: [PATCH 4/8] test: cover Arazzo validation boundaries and failures --- arazzo/README.md | 5 + arazzo/REQUIREMENTS.md | 7 +- arazzo/VALIDATION.md | 46 ++- arazzo/foundation_contracts_test.go | 285 ++++++++++++++ arazzo/nodes_boundaries_test.go | 121 ++++++ arazzo/semantic_contracts_test.go | 569 ++++++++++++++++++++++++++++ arazzo/source_contracts_test.go | 555 +++++++++++++++++++++++++++ arazzo/target_contracts_test.go | 505 ++++++++++++++++++++++++ 8 files changed, 2076 insertions(+), 17 deletions(-) create mode 100644 arazzo/foundation_contracts_test.go create mode 100644 arazzo/nodes_boundaries_test.go create mode 100644 arazzo/semantic_contracts_test.go create mode 100644 arazzo/source_contracts_test.go create mode 100644 arazzo/target_contracts_test.go diff --git a/arazzo/README.md b/arazzo/README.md index 0c17131..709c771 100644 --- a/arazzo/README.md +++ b/arazzo/README.md @@ -58,6 +58,11 @@ have static parsers. Runtime-substituted condition syntax records incomplete coverage after checking the embedded expressions. Presence-only source adapters record the detailed metadata checks they cannot prove. Receive-step success can also require message semantics that are unavailable statically. +Rootless Arazzo high models expose only a partial expression projection; their +prerequisite coverage remains incomplete. Supply original nodes or bytes to +check all expression-bearing fields. +Separate logical operators from bare header expressions with whitespace: +`&` and `|` are legal characters inside header names. `WithLimits` replaces positive per-call limits. Defaults are 16 MiB of input and expanded bytes, 200,000 nodes/derived work units, depth 128, 50 source documents, diff --git a/arazzo/REQUIREMENTS.md b/arazzo/REQUIREMENTS.md index e986be2..4a16e3f 100644 --- a/arazzo/REQUIREMENTS.md +++ b/arazzo/REQUIREMENTS.md @@ -40,6 +40,7 @@ unless the test name explicitly describes an internal graph or normalization uni | 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` | @@ -55,6 +56,7 @@ unless the test name explicitly describes an internal graph or normalization uni | --- | --- | --- | | 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` | @@ -73,9 +75,10 @@ unless the test name explicitly describes an internal graph or normalization uni | 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`, `TestInputPatternLimitAndLiteralAnnotations` | +| 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`; five samples per case | +| 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 diff --git a/arazzo/VALIDATION.md b/arazzo/VALIDATION.md index ba22c43..b17107a 100644 --- a/arazzo/VALIDATION.md +++ b/arazzo/VALIDATION.md @@ -1,25 +1,38 @@ # Validation record Checked on 7 October 2026 in isolated branch `codex/arazzo-validation`, based on -`b1808fec4e5a405c08332df7b64f747b4a2d2c5a`. This record covers local checks before -PR publication. The primary checkout remains unchanged. +`b1808fec4e5a405c08332df7b64f747b4a2d2c5a`. This record covers local validation of the package. The primary checkout remains unchanged. All checks use Go 1.26.8, `GOWORK=off`, and the module's released dependencies. | Check | Result | | --- | --- | | `go test ./... -count=1` | All module tests pass, including the external-package mixed-source example. | -| `go test -race -coverprofile=… ./arazzo -count=1` | Pass; 77.3% package statement coverage. | +| `go test -coverprofile=… ./... -count=1` | Pass; 98.8% repository statement coverage, including the CLI. Existing packages measure 98.662%; Arazzo measures 99.130%. | +| `go test -race -coverprofile=… ./arazzo -count=1` | Pass; 99.1% package statement coverage. | | `go test -race ./arazzo -run TestResultDoesNotRetainSourceGraphs -count=20` | Pass; retained diagnostics do not retain primary/source root graphs. | | `go build ./...` | Pass. | | `go vet ./...` | Pass. | | `go mod tidy -diff` | Pass, no module or checksum changes. | -| `golangci-lint run ./arazzo/...` | Pass, zero issues. | +| `golangci-lint run ./...` | Pass, zero issues. | | gofumpt, gci and `git diff --check` | Pass. | -| Schema snapshot checksums and all 69 ledger test references | Pass. | +| Schema snapshot checksums and all 77 ledger test references | Pass. | | Independent correctness and resource reviews | All reported findings repaired and verified; no remaining P0–P2 findings in the reviewed scope. | Statement coverage is measured coverage, not a claim of full branch coverage. +The five boundary test files cover source/model projection, references, target +metadata, dependency graphs, expressions, schema diagnostics, cancellation and +resource limits. Tests assert findings and capabilities, with external source +coordinates and caller immutability where applicable. Disabling integer or float +retagging makes the public numeric-bound regression fail. Independent correctness +and de-slop reviews found no remaining material issues in these tests. + +The initial `f8f46d2` report measured 68.44% Codecov patch coverage and 91.38% +project coverage against the 98.27% base. The updated tests and six review repairs +have passed local validation. Codecov line coverage on the final published head +must meet or exceed the base before the PR is ready. Thresholds, exclusions and +the CI coverage command are unchanged. + The [requirements ledger](REQUIREMENTS.md) records semantic fixtures and capability limits. The [package guide](README.md) explains the result and coverage contract. @@ -32,16 +45,19 @@ The cold benchmark compiles both embedded schemas without the global cache. | Case | ns/op | B/op | allocs/op | | --- | ---: | ---: | ---: | -| Warm: 10 steps, operations and expressions | 254,632 | 342,606 | 4,058 | -| Warm: 100 steps, operations and expressions | 2,429,607 | 3,237,388 | 38,691 | -| Warm: 1,000 steps, operations and expressions | 21,610,055 | 33,074,060 | 387,775 | -| 10 findings | 163,165 | 211,506 | 2,851 | -| 100 findings | 1,585,993 | 1,988,292 | 27,090 | -| 500 findings | 7,223,785 | 9,953,842 | 136,037 | -| 10 workflows sharing a source prerequisite and reusable parameter | 396,294 | 340,435 | 3,967 | -| 100 workflows sharing a source prerequisite and reusable parameter | 3,042,558 | 2,964,794 | 35,805 | -| 1,000 workflows sharing a source prerequisite and reusable parameter | 21,864,433 | 29,375,616 | 356,961 | -| Cold: compile both official schemas | 4,679,181 | 5,043,806 | 66,463 | +| Warm: 10 steps, operations and expressions | 304,601 | 343,487 | 4,065 | +| Warm: 100 steps, operations and expressions | 2,375,296 | 3,241,226 | 38,697 | +| Warm: 1,000 steps, operations and expressions | 23,764,141 | 33,128,780 | 387,783 | +| 10 findings | 182,586 | 212,284 | 2,858 | +| 100 findings | 1,709,425 | 1,991,975 | 27,096 | +| 500 findings | 7,729,827 | 9,981,505 | 136,043 | +| 10 workflows sharing a source prerequisite and reusable parameter | 280,759 | 344,519 | 4,007 | +| 100 workflows sharing a source prerequisite and reusable parameter | 2,459,634 | 3,000,619 | 36,122 | +| 1,000 workflows sharing a source prerequisite and reusable parameter | 22,486,128 | 29,770,868 | 360,728 | +| 10 prerequisites sharing the final external step | 301,489 | 360,126 | 4,432 | +| 100 prerequisites sharing the final external step | 2,991,594 | 3,126,669 | 38,691 | +| 1,000 prerequisites sharing the final external step | 23,188,979 | 30,686,837 | 386,776 | +| Cold: compile both official schemas | 5,419,673 | 5,042,258 | 66,395 | Time and allocation growth are approximately linear across these workloads. These are local measurements, not service latency promises. Resolver transport diff --git a/arazzo/foundation_contracts_test.go b/arazzo/foundation_contracts_test.go new file mode 100644 index 0000000..79cb8f0 --- /dev/null +++ b/arazzo/foundation_contracts_test.go @@ -0,0 +1,285 @@ +// Copyright 2023-2026 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/dlclark/regexp2" + "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 || !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 }{ + {`^(?=ab)ab$`, "ab", "ac"}, {`^(?!\$).+$`, "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) + } + regex, err := regexp2.Compile(`^(a+)+$`, regexp2.ECMAScript) + if err != nil { + t.Fatal(err) + } + regex.MatchTimeout = time.Nanosecond + func() { + defer func() { + if _, ok := recover().(patternMatchError); !ok { + t.Error("regexp timeout must cross the interface as an operational failure") + } + }() + (ecmaPattern{regex}).MatchString(strings.Repeat("a", 1000) + "!") + }() +} + +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"}, + } + 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) + } + 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) + } + } +} + +type failingPattern struct{ failure any } + +func (f failingPattern) String() string { return "injected-regexp-failure" } +func (f failingPattern) MatchString(string) bool { panic(f.failure) } + +func TestFoundationRegexpFailureBoundary(t *testing.T) { + original := officialSchemas + defer func() { officialSchemas = original }() + cause := errors.New("pattern execution failure") + for _, failure := range []any{patternMatchError{cause}, cause} { + schema := &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{"info": {Properties: map[string]*jsonschema.Schema{"title": {Pattern: failingPattern{failure}}}}}} + officialSchemas = func() (map[string]*jsonschema.Schema, error) { + return map[string]*jsonschema.Schema{"1.1": schema}, nil + } + func() { + defer func() { + got := recover() + if failure == cause && got != cause { + t.Errorf("unexpected failures must propagate: %v", got) + } + if failure != cause && got != nil { + t.Errorf("pattern failure escaped: %v", got) + } + }() + r, err := ValidateBytes(context.Background(), []byte(foundationYAML("1.1.0")), "regex.yaml") + var tool *Error + if !errors.As(err, &tool) || tool.Kind != ErrorOperational || !errors.Is(err, cause) || r.Complete { + t.Fatalf("pattern error boundary: %+v %v", r, 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) + } + } +} diff --git a/arazzo/nodes_boundaries_test.go b/arazzo/nodes_boundaries_test.go new file mode 100644 index 0000000..89d2665 --- /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 := normalize(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 := normalize(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 := normalize(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 := normalize(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/< 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/source_contracts_test.go b/arazzo/source_contracts_test.go new file mode 100644 index 0000000..9854fcb --- /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, base: "https://example.test/main.yaml", byURI: map[string]*linkedSource{}, byName: map[string]map[string]any{}, requests: map[string]*linkedSource{}, 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/target_contracts_test.go b/arazzo/target_contracts_test.go new file mode 100644 index 0000000..78d0a81 --- /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, base: v.doc.URI, byName: map[string]map[string]any{"api": {"name": "api", "url": "api.yaml", "type": kind}}, byURI: map[string]*linkedSource{}, requests: map[string]*linkedSource{}, graphVisited: map[*linkedSource]bool{}, targets: map[dependencyID]*linkedTarget{}, 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") + } +} From 6a20c14cd72f82f983061eaee2ad8d47c8a2e950 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Wed, 7 Oct 2026 16:57:34 -0400 Subject: [PATCH 5/8] fix: address Arazzo validation review findings --- arazzo/expressions.go | 24 +--- arazzo/foundation_contracts_test.go | 52 +------- arazzo/inputs.go | 157 ++++++---------------- arazzo/nodes.go | 7 +- arazzo/nodes_boundaries_test.go | 10 +- arazzo/review_regression_test.go | 193 ++++++++++++++++++++++++++++ arazzo/schema.go | 64 ++++----- arazzo/semantic_contracts_test.go | 37 ++---- arazzo/semantics.go | 30 +++-- arazzo/semantics_test.go | 109 +++++++++++----- arazzo/source_contracts_test.go | 2 +- arazzo/sources.go | 30 +++-- arazzo/sources_graph.go | 22 ++-- arazzo/sources_metadata.go | 20 +-- arazzo/sources_targets.go | 4 + arazzo/target_contracts_test.go | 2 +- arazzo/types.go | 11 +- arazzo/validate.go | 22 +--- arazzo/validate_test.go | 12 +- 19 files changed, 460 insertions(+), 348 deletions(-) create mode 100644 arazzo/review_regression_test.go diff --git a/arazzo/expressions.go b/arazzo/expressions.go index acfe8f7..53107d2 100644 --- a/arazzo/expressions.go +++ b/arazzo/expressions.go @@ -9,7 +9,6 @@ import ( "mime" "strconv" "strings" - "unicode/utf8" "github.com/dlclark/regexp2" "github.com/pb33f/jsonpath/pkg/jsonpath" @@ -59,12 +58,12 @@ func checkExpressions(v *validation) { } } components := object(v.root["components"]) - for _, name := range semanticKeys(object(components["parameters"])) { + 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 semanticKeys(object(components[kind])) { + for _, name := range sortedKeys(object(components[kind])) { raw := object(components[kind])[name] v.checkActionExpressions(object(raw), joinPtr(joinPtr("/components", kind), name), exprScope{}) } @@ -90,7 +89,7 @@ func (v *validation) checkActions(raw any, path string, scope exprScope) { func (v *validation) checkOutputs(raw any, path string, scope exprScope) { values := object(raw) - for _, name := range semanticKeys(values) { + for _, name := range sortedKeys(values) { value := values[name] p := joinPtr(path, name) if s, ok := value.(string); ok { @@ -141,7 +140,7 @@ func (v *validation) checkValue(value any, path string, scope exprScope) { _, hasContext := val["context"] _, hasSelector := val["selector"] _, hasType := val["type"] - for _, key := range semanticKeys(val) { + 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 @@ -369,19 +368,8 @@ func (v *validation) checkDialectSyntax(kind, version, s, path string, code Code } func validatePointer(s string) error { - if !utf8.ValidString(s) { - return fmt.Errorf("invalid UTF-8") - } - if s != "" && s[0] != '/' { - return fmt.Errorf("JSON Pointer must be empty or start with '/'") - } - for i := 0; i < len(s); i++ { - if s[i] == '~' { - if i+1 == len(s) || s[i+1] != '0' && s[i+1] != '1' { - return fmt.Errorf("invalid JSON Pointer escape") - } - i++ - } + if _, ok := linkedPointerTokens(s); !ok { + return fmt.Errorf("invalid JSON Pointer syntax or UTF-8") } return nil } diff --git a/arazzo/foundation_contracts_test.go b/arazzo/foundation_contracts_test.go index 79cb8f0..cd4ca6d 100644 --- a/arazzo/foundation_contracts_test.go +++ b/arazzo/foundation_contracts_test.go @@ -9,9 +9,7 @@ import ( "reflect" "strings" "testing" - "time" - "github.com/dlclark/regexp2" "github.com/pb33f/go-yaml" "github.com/pb33f/jsonschema/v6" ) @@ -23,7 +21,7 @@ func TestFoundationConfigurationAndNodeErrors(t *testing.T) { for _, option := range []Option{WithBaseURI("http://[invalid"), WithResolver(nil)} { r, err := ValidateBytes(context.Background(), nil, "config.yaml", option) var tool *Error - if r != nil || !errors.As(err, &tool) || tool.Kind != ErrorConfiguration { + if r == nil || r.Complete || !errors.As(err, &tool) || tool.Kind != ErrorConfiguration { t.Fatalf("%+v %v", r, err) } } @@ -66,7 +64,7 @@ func TestFoundationOverflowNumbersRemainNumeric(t *testing.T) { func TestSchemaPatternAdapters(t *testing.T) { for _, tc := range []struct{ pattern, accepted, rejected string }{ - {`^(?=ab)ab$`, "ab", "ac"}, {`^(?!\$).+$`, "literal", "$expression"}, {`^a+$`, "aa", "b"}, + {`^(?!\$).+$`, "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) { @@ -83,19 +81,6 @@ func TestSchemaPatternAdapters(t *testing.T) { if !errors.As(err, &unavailable) || !strings.Contains(err.Error(), unavailable.URI) { t.Fatal(err) } - regex, err := regexp2.Compile(`^(a+)+$`, regexp2.ECMAScript) - if err != nil { - t.Fatal(err) - } - regex.MatchTimeout = time.Nanosecond - func() { - defer func() { - if _, ok := recover().(patternMatchError); !ok { - t.Error("regexp timeout must cross the interface as an operational failure") - } - }() - (ecmaPattern{regex}).MatchString(strings.Repeat("a", 1000) + "!") - }() } func TestStructureCompilerFailureAndBudget(t *testing.T) { @@ -233,39 +218,6 @@ func TestStructureDiagnosticAdapter(t *testing.T) { } } -type failingPattern struct{ failure any } - -func (f failingPattern) String() string { return "injected-regexp-failure" } -func (f failingPattern) MatchString(string) bool { panic(f.failure) } - -func TestFoundationRegexpFailureBoundary(t *testing.T) { - original := officialSchemas - defer func() { officialSchemas = original }() - cause := errors.New("pattern execution failure") - for _, failure := range []any{patternMatchError{cause}, cause} { - schema := &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{"info": {Properties: map[string]*jsonschema.Schema{"title": {Pattern: failingPattern{failure}}}}}} - officialSchemas = func() (map[string]*jsonschema.Schema, error) { - return map[string]*jsonschema.Schema{"1.1": schema}, nil - } - func() { - defer func() { - got := recover() - if failure == cause && got != cause { - t.Errorf("unexpected failures must propagate: %v", got) - } - if failure != cause && got != nil { - t.Errorf("pattern failure escaped: %v", got) - } - }() - r, err := ValidateBytes(context.Background(), []byte(foundationYAML("1.1.0")), "regex.yaml") - var tool *Error - if !errors.As(err, &tool) || tool.Kind != ErrorOperational || !errors.Is(err, cause) || r.Complete { - t.Fatalf("pattern error boundary: %+v %v", r, 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. diff --git a/arazzo/inputs.go b/arazzo/inputs.go index e6cdd3a..d472f97 100644 --- a/arazzo/inputs.go +++ b/arazzo/inputs.go @@ -35,9 +35,8 @@ type inputSchemaLoader struct{} func (inputSchemaLoader) Load(uri string) (any, error) { return nil, &inputResourceUnavailable{uri} } type inputRegexpState struct { - v *validation - err error - trusted bool + v *validation + err error } type inputRegexp struct { re *regexp2.Regexp @@ -59,7 +58,7 @@ func (r *inputRegexp) MatchString(s string) bool { } func (r *inputRegexpState) compile(pattern string) (jsonschema.Regexp, error) { - if !r.trusted && len(pattern) > r.v.opts.limits.MaxPatternBytes { + 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 } @@ -79,37 +78,8 @@ func checkInputs(v *validation) { compiler := jsonschema.NewCompiler() compiler.DefaultDraft(jsonschema.Draft2020) compiler.UseLoader(inputSchemaLoader{}) - regex := &inputRegexpState{v: v, trusted: true} + regex := &inputRegexpState{v: v} compiler.UseRegexpEngine(regex.compile) - meta, err := compiler.Compile("https://json-schema.org/draft/2020-12/schema") - regex.trusted = false - if err != nil { - v.err = &Error{Kind: ErrorOperational, Cause: fmt.Errorf("compile input metaschema: %w", err)} - return - } - valid := true - for _, slot := range slots { - if !v.check() { - return - } - if err := meta.Validate(slot.value); err != nil { - var ve *jsonschema.ValidationError - if errors.As(err, &ve) { - inputSchemaFindings(v, ve, slot.path) - } else { - v.err = &Error{Kind: ErrorOperational, Location: v.location(slot.path), Cause: err} - return - } - valid = false - } - if regex.err != nil { - v.err = &Error{Kind: ErrorOperational, Location: v.location(slot.path), Cause: regex.err} - return - } - } - if !valid { - return - } base := inputSchemaBase(v) if err := compiler.AddResource(base, v.root); err != nil { v.err = &Error{Kind: ErrorOperational, Cause: err} @@ -190,23 +160,6 @@ func inputSchemaBase(v *validation) string { return u.String() } -func inputSchemaFindings(v *validation, e *jsonschema.ValidationError, base string) { - if !v.check() { - return - } - if len(e.Causes) > 0 { - for _, cause := range e.Causes { - inputSchemaFindings(v, cause, base) - } - return - } - path := base - for _, token := range e.InstanceLocation { - path = joinPtr(path, token) - } - v.add(CodeInputSchema, path, e.Error()) -} - func schemaReferencePath(value any, path string) string { m := object(value) for _, keyword := range []string{"$ref", "$dynamicRef", "$id", "$anchor", "$dynamicAnchor", "$schema"} { @@ -214,37 +167,16 @@ func schemaReferencePath(value any, path string) string { return joinPtr(path, keyword) } } - // Only schema-bearing keywords count as schema references. In particular, - // objects inside const/default/examples are data, not subschema declarations. - for _, keyword := range []string{"$defs", "properties", "patternProperties", "dependentSchemas"} { - children := object(m[keyword]) - keys := make([]string, 0, len(children)) - for key := range children { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - childPath := joinPtr(joinPtr(path, keyword), key) - if candidate := schemaReferencePath(children[key], childPath); candidate != childPath { - return candidate - } - } - } - for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { - for i, child := range array(m[keyword]) { - childPath := joinPtr(joinPtr(path, keyword), strconv.Itoa(i)) - if candidate := schemaReferencePath(child, childPath); candidate != childPath { - return candidate - } - } - } - for _, keyword := range []string{"items", "contains", "not", "if", "then", "else", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "propertyNames", "contentSchema"} { - if child, ok := m[keyword]; ok { - childPath := joinPtr(path, keyword) - if candidate := schemaReferencePath(child, childPath); candidate != childPath { - return candidate - } + 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, "/") } @@ -267,14 +199,6 @@ func reportInputCompile(v *validation, regex *inputRegexpState, err error, path v.incomplete("input-schema-reference", path, unavailable.uri, "external JSON Schema resource is not supplied") return } - var invalid *jsonschema.SchemaValidationError - if errors.As(err, &invalid) { - var ve *jsonschema.ValidationError - if errors.As(invalid.Err, &ve) { - inputSchemaFindings(v, ve, "") - return - } - } v.add(CodeInputSchema, path, err.Error()) } @@ -302,27 +226,10 @@ func collectInputReferences(value any, path, base string, out *[]inputReference, } } } - for _, keyword := range []string{"$defs", "properties", "patternProperties", "dependentSchemas"} { - children := object(m[keyword]) - keys := make([]string, 0, len(children)) - for key := range children { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - collectInputReferences(children[key], joinPtr(joinPtr(path, keyword), key), base, out, index) - } - } - for _, keyword := range []string{"allOf", "anyOf", "oneOf", "prefixItems"} { - for i, child := range array(m[keyword]) { - collectInputReferences(child, joinPtr(joinPtr(path, keyword), strconv.Itoa(i)), base, out, index) - } - } - for _, keyword := range []string{"items", "contains", "not", "if", "then", "else", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "propertyNames", "contentSchema"} { - if child, ok := m[keyword]; ok { - collectInputReferences(child, joinPtr(path, keyword), base, out, index) - } - } + 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 @@ -334,7 +241,7 @@ func checkInputLimits(v *validation) { 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 semanticKeys(object(schema["patternProperties"])) { + 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 @@ -374,25 +281,35 @@ func walkInputSchemas(v *validation, value any, path string, visit func(map[stri 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]) - keys := make([]string, 0, len(children)) - for key := range children { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - walkInputSchemas(v, children[key], joinPtr(joinPtr(path, keyword), key), visit) + 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]) { - walkInputSchemas(v, child, joinPtr(joinPtr(path, keyword), strconv.Itoa(i)), visit) + 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 { - walkInputSchemas(v, child, joinPtr(path, keyword), visit) + if !visit(child, joinPtr(path, keyword)) { + return + } } } } diff --git a/arazzo/nodes.go b/arazzo/nodes.go index 2318434..b5d689f 100644 --- a/arazzo/nodes.go +++ b/arazzo/nodes.go @@ -31,14 +31,9 @@ type normalizer struct { count, bytes int } -// normalize converts the JSON-compatible node graph once, indexing each instance +// 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. -func normalize(ctx context.Context, doc Document, limits Limits) (any, map[string]nodeLocation, []Diagnostic, error) { - value, nodes, diagnostics, _, err := normalizeWithStats(ctx, doc, limits) - return value, nodes, diagnostics, err -} - type nodeStats struct{ nodes, bytes int } func normalizeWithStats(ctx context.Context, doc Document, limits Limits) (any, map[string]nodeLocation, []Diagnostic, nodeStats, error) { diff --git a/arazzo/nodes_boundaries_test.go b/arazzo/nodes_boundaries_test.go index 89d2665..8bc9adb 100644 --- a/arazzo/nodes_boundaries_test.go +++ b/arazzo/nodes_boundaries_test.go @@ -38,7 +38,7 @@ func TestNodeMalformedGraphs(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { before := cloneNodeGraph(tc.root) - _, _, ds, err := normalize(context.Background(), Document{Root: tc.root, URI: "nodes.yaml"}, DefaultLimits()) + _, _, 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 { @@ -64,14 +64,14 @@ func TestNodeNumericSpellings(t *testing.T) { {"!!int", "0o17", "15"}, } { root := &yaml.Node{Kind: yaml.ScalarNode, Tag: tc.tag, Value: tc.value} - v, _, ds, err := normalize(context.Background(), Document{Root: root}, DefaultLimits()) + 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 := normalize(ctx, Document{Root: &yaml.Node{}}, DefaultLimits()) + _, _, _, _, err := normalizeWithStats(ctx, Document{Root: &yaml.Node{}}, DefaultLimits()) if !errors.Is(err, context.Canceled) { t.Fatal(err) } @@ -83,7 +83,7 @@ func TestNodeMergePrecedenceAndLocations(t *testing.T) { if err := yaml.Unmarshal([]byte(data), &root); err != nil { t.Fatal(err) } - v, locations, ds, err := normalize(context.Background(), Document{Root: &root}, DefaultLimits()) + v, locations, ds, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) if err != nil || len(ds) != 0 { t.Fatalf("%+v %v", ds, err) } @@ -112,7 +112,7 @@ func TestNodeLimitsAtEachBoundary(t *testing.T) { } { l := DefaultLimits() l.MaxNodes, l.MaxBytes, l.MaxDiagnostics = tc.nodes, tc.bytes, tc.diagnostics - _, _, _, err := normalize(context.Background(), Document{Root: tc.root}, l) + _, _, _, _, err := normalizeWithStats(context.Background(), Document{Root: tc.root}, l) var tool *Error if !errors.As(err, &tool) || (tool.Kind != ErrorLimit && tool.Kind != ErrorInput) { t.Fatalf("expected bounded failure: %v", err) diff --git a/arazzo/review_regression_test.go b/arazzo/review_regression_test.go new file mode 100644 index 0000000..dba9365 --- /dev/null +++ b/arazzo/review_regression_test.go @@ -0,0 +1,193 @@ +// 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" +) + +func TestReviewAllFailuresReturnIncompleteResult(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + for _, call := range []func() (*Result, error){ + func() (*Result, error) { return ValidateBytes(context.Background(), []byte("["), "input.yaml") }, + func() (*Result, error) { + return ValidateBytes(context.Background(), []byte("---\n{}\n---\n{}"), "input.yaml") + }, + func() (*Result, error) { + return ValidateBytes(context.Background(), []byte(foundationYAML("2.0.0")), "input.yaml") + }, + func() (*Result, error) { return ValidateBytes(ctx, []byte(foundationYAML("1.1.0")), "input.yaml") }, + func() (*Result, error) { return ValidateBytes(context.Background(), nil, "input.yaml", nil) }, + func() (*Result, error) { return Validate(context.Background(), Document{}) }, + func() (*Result, error) { return Validate(context.Background(), Document{}, nil) }, + } { + r, err := call() + var typed *Error + if r == nil || r.Complete || !errors.As(err, &typed) { + t.Fatalf("mixed failure contract: %+v %v", r, err) + } + } +} + +func TestReviewTargetUnionReportsOnlyCause(t *testing.T) { + for _, target := range []string{"operationId: read, workflowId: run", "operationId: read, channelPath: '{$sourceDescriptions.api.url}#/channels/events'", ""} { + data := strings.Replace(foundationYAML("1.1.0"), "operationId: read", target, 1) + r, err := ValidateBytes(context.Background(), []byte(data), "target.yaml") + if err != nil || r.Valid() || len(r.Diagnostics) != 1 { + t.Fatalf("target fallout: %+v %v", r, err) + } + foundationFinding(t, r, CodeStructure, "/workflows/0/steps/0") + } + for _, target := range []string{"operationId: read, workflowId: run", ""} { + data := strings.Replace(foundationYAML("1.1.0"), "operationId: read", strings.TrimPrefix(target+", parameters: [{name: id, value: 1}]", ", "), 1) + r, err := ValidateBytes(context.Background(), []byte(data), "parameters.yaml") + if err != nil || len(r.Diagnostics) != 1 { + t.Fatalf("target-dependent parameter fallout: %+v %v", r, err) + } + foundationFinding(t, r, CodeStructure, "/workflows/0/steps/0") + } + data := strings.Replace(foundationYAML("1.1.0"), "operationId: read", "operationId: read, workflowId: run", 1) + data = strings.Replace(data, "title: Example", "title: 123", 1) + r, err := ValidateBytes(context.Background(), []byte(data), "target.yaml") + if err != nil || len(r.Diagnostics) != 2 { + t.Fatalf("unrelated errors hidden: %+v %v", r, err) + } + foundationFinding(t, r, CodeStructure, "/info/title") + for _, version := range []string{"1.0.0", "1.1.0"} { + for _, defect := range []struct{ from, to, pointer string }{ + {"stepId: read", "stepId: 123", "/workflows/0/steps/0/stepId"}, + {"stepId: read, ", "", "/workflows/0/steps/0/stepId"}, + {"operationId: read", "operationId: read, parameters: 123", "/workflows/0/steps/0/parameters"}, + {"operationId: read", "operationId: read, surprise: 123", "/workflows/0/steps/0/surprise"}, + } { + fixture := strings.Replace(foundationYAML(version), "operationId: read", "operationId: read, workflowId: run", 1) + fixture = strings.Replace(fixture, defect.from, defect.to, 1) + limits := DefaultLimits() + limits.MaxDiagnostics = 2 + r, err := ValidateBytes(context.Background(), []byte(fixture), "target.yaml", WithLimits(limits)) + if err != nil || len(r.Diagnostics) != 2 { + t.Fatalf("same-step independent errors lost: %+v %v", r, err) + } + foundationFinding(t, r, CodeStructure, defect.pointer) + } + } +} + +func TestReviewInlineExpressionsRecordedOnce(t *testing.T) { + for _, version := range []string{"1.0", "1.1"} { + v := semanticUnit(t, `{"workflows":[{"workflowId":"run","inputs":{"type":"object"},"steps":[{"stepId":"read","operationId":"read","parameters":[{"name":"id","in":"query","value":"$inputs.foo"}],"onSuccess":[{"name":"done","type":"end","criteria":[{"condition":"$statusCode == 200"}]}]}]}]}`, version) + for _, path := range []string{"/workflows/0/steps/0/parameters/0/value", "/workflows/0/steps/0/onSuccess/0/criteria/0/condition"} { + count := 0 + for _, use := range v.expressionUses { + if use.Path == path { + count++ + } + } + if count != 1 { + t.Fatalf("%s recorded %d times in %s", path, count, version) + } + } + l := DefaultLimits() + l.MaxDiagnostics = 1 + data := strings.Replace(foundationYAML(version+".0"), "operationId: read", "operationId: read, parameters: [{name: id, in: query, value: '$inputs.foo'}]", 1) + data = strings.Replace(data, " steps:", " inputs: {type: object, additionalProperties: false}\n steps:", 1) + r, err := ValidateBytes(context.Background(), []byte(data), "once.yaml", WithLimits(l)) + if err != nil || len(r.Diagnostics) != 1 { + t.Fatalf("duplicate finding consumed diagnostics budget: %+v %v", r, err) + } + foundationFinding(t, r, CodeReference, "/workflows/0/steps/0/parameters/0/value") + } + for _, target := range []string{"parameter", "action parameter"} { + data := strings.Replace(foundationYAML("1.1.0"), " steps:", " inputs: {type: object, properties: {id: {type: string}}, additionalProperties: false}\n steps:", 1) + value := "parameters: [{reference: '$components.parameters.id', value: '$inputs.foo'}]" + if target == "action parameter" { + value = "onSuccess: [{name: next, type: goto, workflowId: run, " + value + "}]" + } + data = strings.Replace(data, "operationId: read}", "operationId: read, "+value+"}", 1) + data += "components:\n parameters:\n id: {name: id, in: query, value: literal}\n" + if target == "action parameter" { + data = strings.Replace(data, "name: id, in: query", "name: id", 1) + } + limits := DefaultLimits() + limits.MaxDiagnostics = 1 + r, err := ValidateBytes(context.Background(), []byte(data), "override.yaml", WithLimits(limits)) + if err != nil || len(r.Diagnostics) != 1 || r.Diagnostics[0].Code != CodeReference { + t.Fatalf("%s override double charged: %+v %v", target, r, err) + } + } +} + +func TestReviewReferencedAnnotationMustBeValidSchema(t *testing.T) { + r := inputValidate(t, " $ref: '#/workflows/0/inputs/default'\n default: {type: unknown}\n", "") + if r.Valid() { + t.Fatal("referenced malformed schema accepted") + } + foundationFinding(t, r, CodeInputSchema, "/workflows/0/inputs/$ref") +} + +func TestReviewReusableActionParameterUsesConsumingInputs(t *testing.T) { + for _, member := range []string{"id", "absent"} { + data := strings.Replace(foundationYAML("1.1.0"), " steps:", " inputs: {type: object, properties: {id: {type: string}}, additionalProperties: false}\n steps:", 1) + data = strings.Replace(data, "operationId: read}", "operationId: read, onSuccess: [{name: call, type: goto, workflowId: target, parameters: [{reference: '$components.parameters.arg'}]}]}", 1) + data += " - workflowId: target\n inputs: {type: object, properties: {id: {type: string}}, additionalProperties: false}\n steps: [{stepId: read, operationId: read}]\ncomponents:\n parameters:\n arg: {name: id, value: '$inputs." + member + "'}\n" + r, err := ValidateBytes(context.Background(), []byte(data), "actions.yaml") + if err != nil || r.Valid() != (member == "id") { + t.Fatalf("reusable action context: %+v %v", r, err) + } + if member == "absent" { + foundationFinding(t, r, CodeReference, "/workflows/0/steps/0/onSuccess/0/parameters/0/value") + } + } +} + +func TestReviewExternalInputSchemas(t *testing.T) { + main := strings.Replace(sourceTestDocument(" workflowId: $sourceDescriptions.api.remote\n"), "type: openapi", "type: arazzo", 1) + for _, kind := range []string{"string", "invalid"} { + external := "arazzo: 1.1.0\ninfo: {title: Other, version: '1'}\nsourceDescriptions: []\nworkflows:\n- workflowId: remote\n inputs: {type: " + kind + "}\n steps: [{stepId: read, operationId: read}]\n" + r, err := ValidateBytes(context.Background(), []byte(main), "https://example.test/main.yaml", WithSources(sourceCandidate(external))) + if err != nil || r.Valid() != (kind == "string") { + t.Fatalf("external inputs: %+v %v", r, err) + } + if kind == "invalid" { + d := foundationFinding(t, r, CodeInputSchema, "/workflows/0/inputs") + if d.URI != "https://example.test/api.yaml" || d.Line != 6 { + t.Fatalf("foreign schema coordinates: %+v", d) + } + } + } +} + +func TestReviewSiblingSourceScopesShareLimits(t *testing.T) { + main := "arazzo: 1.1.0\ninfo: {title: Main, version: '1'}\nsourceDescriptions:\n- {name: left, url: left/flows.yaml, type: arazzo}\n- {name: right, url: right/flows.yaml, type: arazzo}\nworkflows:\n- workflowId: main\n dependsOn: ['$sourceDescriptions.left.run', '$sourceDescriptions.right.run']\n steps: [{stepId: call, workflowId: '$sourceDescriptions.left.run'}]\n" + parent := "arazzo: 1.1.0\ninfo: {title: Parent, version: '1'}\nsourceDescriptions: [{name: child, url: child.yaml, type: arazzo}]\nworkflows:\n- workflowId: run\n dependsOn: ['$sourceDescriptions.child.leaf']\n steps: [{stepId: read, operationId: read}]\n" + leaf := "arazzo: 1.1.0\ninfo: {title: Leaf, version: '1'}\nsourceDescriptions: []\nworkflows: [{workflowId: leaf, steps: [{stepId: read, operationId: read}]}]\n" + for _, maximum := range []int{4, 3} { + calls := map[string]int{} + resolver := sourceResolverFunc(func(_ context.Context, req upstream.SourceRequest) (*upstream.ResolvedSource, error) { + calls[req.URL]++ + return &upstream.ResolvedSource{RetrievalURI: req.URL, SourceBytes: []byte(leaf)}, nil + }) + limits := DefaultLimits() + limits.MaxSources = maximum + r, err := ValidateBytes(context.Background(), []byte(main), "https://example.test/main.yaml", WithSources( + upstream.CandidateDocument{RetrievalURI: "https://example.test/left/flows.yaml", SourceBytes: []byte(parent)}, + upstream.CandidateDocument{RetrievalURI: "https://example.test/right/flows.yaml", SourceBytes: []byte(parent)}), WithResolver(resolver), WithLimits(limits)) + if maximum == 4 { + if err != nil || !r.Valid() || !r.Complete || calls["https://example.test/left/child.yaml"] != 1 || calls["https://example.test/right/child.yaml"] != 1 { + t.Fatalf("sibling scopes collided: %+v %v %+v", r, err, calls) + } + } else { + var typed *Error + if !errors.As(err, &typed) || typed.Kind != ErrorLimit || r.Complete { + t.Fatalf("sibling limits not shared: %+v %v", r, err) + } + } + } +} diff --git a/arazzo/schema.go b/arazzo/schema.go index 09da300..0464761 100644 --- a/arazzo/schema.go +++ b/arazzo/schema.go @@ -13,9 +13,7 @@ import ( "strconv" "strings" "sync" - "time" - "github.com/dlclark/regexp2" "github.com/pb33f/jsonschema/v6" "github.com/pb33f/jsonschema/v6/kind" "golang.org/x/text/language" @@ -100,23 +98,6 @@ func (nonDollarPattern) MatchString(s string) bool { return s != "" && s[0] != '$' && !strings.ContainsAny(s, "\r\n") } -type ( - patternMatchError struct{ err error } - ecmaPattern struct{ regex *regexp2.Regexp } -) - -func (p ecmaPattern) String() string { return p.regex.String() } -func (p ecmaPattern) MatchString(s string) bool { - match, err := p.regex.MatchString(s) - // The compiler's Regexp interface cannot return an error. A private panic - // transports this operational failure to the validation boundary; it cannot - // silently turn a timeout into a clean or invalid document result. - if err != nil { - panic(patternMatchError{err}) - } - return match -} - func compileSchemaPattern(pattern string) (jsonschema.Regexp, error) { if len(pattern) > DefaultLimits().MaxPatternBytes { return nil, fmt.Errorf("pattern exceeds compile limit") @@ -124,18 +105,11 @@ func compileSchemaPattern(pattern string) (jsonschema.Regexp, error) { if pattern == `^(?!\$).+$` { return nonDollarPattern{}, nil } - if compiled, err := regexp.Compile(pattern); err == nil { - return compiled, nil - } - compiled, err := regexp2.Compile(pattern, regexp2.ECMAScript) - if err != nil { - return nil, err - } - compiled.MatchTimeout = 50 * time.Millisecond - return ecmaPattern{compiled}, 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 @@ -145,7 +119,9 @@ func checkStructure(v *validation) { } } if count != 1 { - v.add(CodeStructure, "/workflows/"+strconv.Itoa(wi)+"/steps/"+strconv.Itoa(si), "step must specify exactly one operation, workflow or channel target") + 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") } } } @@ -164,6 +140,7 @@ func checkStructure(v *validation) { return } printer := message.NewPrinter(language.English) + seen := make(map[struct{ path, message string }]bool) var collect func(*jsonschema.ValidationError, string) collect = func(failure *jsonschema.ValidationError, keyPath string) { if !v.check() { @@ -181,6 +158,35 @@ func checkStructure(v *validation) { 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) diff --git a/arazzo/semantic_contracts_test.go b/arazzo/semantic_contracts_test.go index 10ef183..cae7bef 100644 --- a/arazzo/semantic_contracts_test.go +++ b/arazzo/semantic_contracts_test.go @@ -18,11 +18,8 @@ func contractValidation(root map[string]any) *validation { } func TestContractInputSchemaDiagnostics(t *testing.T) { - v := contractValidation(map[string]any{"workflows": []any{map[string]any{"inputs": map[string]any{"properties": map[string]any{"count": map[string]any{"type": "unknown"}}}}}}) - checkInputs(v) - if v.err != nil || !semanticHas(v, CodeInputSchema, "/workflows/0/inputs/properties/count/type") { - t.Fatalf("schema leaf location lost: %+v %v", v.result.Diagnostics, v.err) - } + 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 { @@ -32,9 +29,9 @@ func TestContractInputSchemaDiagnostics(t *testing.T) { if err == nil { t.Fatal("invalid schema accepted") } - v = contractValidation(nil) + v := contractValidation(nil) reportInputCompile(v, &inputRegexpState{v: v}, &jsonschema.SchemaValidationError{Err: err}, "/inputs") - if !semanticHas(v, CodeInputSchema, "/type") { + if !semanticHas(v, CodeInputSchema, "/inputs") { t.Fatalf("nested compiler diagnostic lost: %+v", v.result.Diagnostics) } sentinel := errors.New("regex failure") @@ -127,7 +124,7 @@ func TestContractInputSchemaResourceBases(t *testing.T) { } func TestContractSemanticTargetsAndContext(t *testing.T) { - v := semanticRun(t, `{"sourceDescriptions":[{"name":"api"},{"name":"api"}],"workflows":[{"workflowId":"run","steps":[{"stepId":"first","workflowId":"missing","parameters":[{"reference":"$components.parameters.absent"}]}]},{"workflowId":"run","steps":[]}]}`, "1.1") + 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 @@ -136,7 +133,7 @@ func TestContractSemanticTargetsAndContext(t *testing.T) { t.Fatalf("missing %s %s: %+v", tc.code, tc.path, v.result.Diagnostics) } } - v = semanticRun(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") + 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 @@ -146,7 +143,7 @@ func TestContractSemanticTargetsAndContext(t *testing.T) { } } for _, ref := range []string{"$workflows.absent.steps.first", "$steps.first", "missing"} { - v = semanticRun(t, fmt.Sprintf(`{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","dependsOn":[%q]}]}]}`, ref), "1.1") + 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) } @@ -170,7 +167,7 @@ func TestContractRuntimeSymbolResolution(t *testing.T) { {"$components.failureActions.absent", true}, {"$sourceDescriptions.absent.workflows.run", true}, } { - v := semanticRun(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") + 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) @@ -192,12 +189,12 @@ func TestContractExpressionLiteralSelectorsAndContainers(t *testing.T) { `{"context":"$response.body","selector":"$.item","type":"unknown"}`, `{"context":"$response.body","selector":"$.item","type":"jsonpath","literal":true}`, } { - v := semanticRun(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","parameters":[{"name":"data","in":"query","value":`+value+`}]}]}]}`, "1.1") + 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 := semanticRun(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") + 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) } @@ -216,7 +213,7 @@ func TestContractCriteriaEmbeddingAndReplacementCapabilities(t *testing.T) { {`{"condition":"$.item","type":{"type":"jsonpath","version":"unsupported"},"context":"$response.body"}`, "", "", "selector-syntax"}, } { // semanticRun parses JSON and checks all applicable criterion scopes. - v := semanticRun(t, `{"components":{"successActions":{"test":{"name":"test","type":"end","criteria":[`+tc.criterion+`]}}}}`, "1.1") + 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) @@ -232,7 +229,7 @@ func TestContractCriteriaEmbeddingAndReplacementCapabilities(t *testing.T) { } } for _, tc := range []struct{ media, want string }{{"application/xml", "selector-syntax"}, {"application/octet-stream", "replacement-target"}} { - v := semanticRun(t, fmt.Sprintf(`{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","requestBody":{"contentType":%q,"replacements":[{"target":"/root/id","value":"literal"}]}}]}]}`, tc.media), "1.1") + 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 @@ -445,7 +442,7 @@ func TestContractPassStructuralOwnership(t *testing.T) { } func TestContractSemanticComponentScopeAndGraphCancellation(t *testing.T) { - v := semanticRun(t, `{"components":{"parameters":{"id":{"name":"id","in":"query","value":"$steps.first.outputs.id"}}}}`, "1.1") + 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) } @@ -474,7 +471,7 @@ func TestContractSemanticComponentScopeAndGraphCancellation(t *testing.T) { } func TestContractFinalizationCancellation(t *testing.T) { - v := semanticRun(t, `{"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"read","outputs":{"id":"$statusCode"}}]}]}`, "1.1") + 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 @@ -482,12 +479,6 @@ func TestContractFinalizationCancellation(t *testing.T) { if !errors.Is(v.err, context.Canceled) { t.Fatalf("canceled symbols finalized: %v", v.err) } - v = contractValidation(nil) - v.ctx = ctx - inputSchemaFindings(v, &jsonschema.ValidationError{}, "/inputs") - if !errors.Is(v.err, context.Canceled) || len(v.result.Diagnostics) != 0 { - t.Fatalf("canceled schema emitted findings: %v %+v", v.err, v.result.Diagnostics) - } 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") diff --git a/arazzo/semantics.go b/arazzo/semantics.go index e26d783..43a87fe 100644 --- a/arazzo/semantics.go +++ b/arazzo/semantics.go @@ -43,9 +43,10 @@ type semanticIndex struct { // 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 + value map[string]any + path string + definition string + authoredValue bool } func checkSemantics(v *validation) { @@ -94,7 +95,7 @@ func checkSemantics(v *validation) { } // Every component body is checked, including definitions with no uses. for _, kind := range []string{"successActions", "failureActions"} { - for _, key := range semanticKeys(v.local.components[kind]) { + 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) } @@ -151,7 +152,11 @@ func checkSemantics(v *validation) { v.checkWorkflowInputParameters(effective, text(s.value["workflowId"])) } for _, p := range effective { - v.checkParameterExpressions(p.value, p.path, exprScope{workflowID: w.id, stepID: s.id}) + // 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 @@ -161,9 +166,15 @@ func checkSemantics(v *validation) { v.uniqueActions(inline) for _, action := range v.mergeEffective(group.defaults, inline, actionIdentity, s.path+"/"+group.field) { v.checkAction(action, w) - v.checkActionExpressions(action.value, action.path, exprScope{workflowID: w.id, stepID: s.id}) + 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") { - v.checkParameterExpressions(p.value, p.path, exprScope{workflowID: w.id, stepID: s.id}) + if inherited || p.definition != "" && !p.authoredValue { + v.checkParameterExpressions(p.value, p.path, scope) + } } } } @@ -179,7 +190,7 @@ func checkSemantics(v *validation) { } } -func semanticKeys(m map[string]any) []string { +func sortedKeys(m map[string]any) []string { keys := make([]string, 0, len(m)) for key := range m { keys = append(keys, key) @@ -226,6 +237,7 @@ func (v *validation) resolveList(raw any, path, kind string) []effectiveObject { } 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 } @@ -279,7 +291,7 @@ func (v *validation) resolveReusable(m map[string]any, path, kind string) (effec } m = clone } - return effectiveObject{value: m, path: path, definition: definition}, true + return effectiveObject{value: m, path: path, definition: definition, authoredValue: authoredValue}, true } type parameterKey struct{ name, location string } diff --git a/arazzo/semantics_test.go b/arazzo/semantics_test.go index 7915723..0b38371 100644 --- a/arazzo/semantics_test.go +++ b/arazzo/semantics_test.go @@ -14,7 +14,7 @@ import ( "github.com/pb33f/go-yaml" ) -func semanticRun(t *testing.T, body string, version string) *validation { +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 { @@ -31,6 +31,53 @@ func semanticRun(t *testing.T, body string, version string) *validation { 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} { @@ -59,7 +106,11 @@ func TestSemanticPublicWorkflowInputParameters(t *testing.T) { } func semanticHas(v *validation, code Code, path string) bool { - for _, d := range v.result.Diagnostics { + 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 } @@ -69,19 +120,19 @@ func semanticHas(v *validation, code Code, path string) bool { 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.result.Diagnostics) != 0 { - t.Fatalf("valid inherited header: %+v", v.result.Diagnostics) + 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 !semanticHas(v, CodeParameter, "/workflows/0/parameters/0") { - t.Fatalf("missing effective location: %+v", v.result.Diagnostics) + if !hasDiagnostic(v, CodeParameter, "/workflows/0/parameters/0") { + t.Fatalf("missing effective location: %+v", v.Diagnostics) } } -func TestSemanticReusableOverridesAndDuplicateIdentity(t *testing.T) { +func TestSemanticUnitReusableOverridesAndDuplicateIdentity(t *testing.T) { for _, raw := range []string{"false", "0", "null"} { t.Run(raw, func(t *testing.T) { - v := semanticRun(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") + 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 { @@ -95,7 +146,7 @@ func TestSemanticReusableOverridesAndDuplicateIdentity(t *testing.T) { } }) } - v := semanticRun(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") + 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) } @@ -103,48 +154,48 @@ func TestSemanticReusableOverridesAndDuplicateIdentity(t *testing.T) { 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 semanticHas(v, CodeReference, "/workflows/0/steps/0/onSuccess/0/stepId") { + if hasDiagnostic(v, CodeReference, "/workflows/0/steps/0/onSuccess/0/stepId") { t.Fatal("valid component target rejected") } - if !semanticHas(v, CodeReference, "/workflows/1/steps/0/onSuccess/0/stepId") { - t.Fatalf("consuming workflow target missed: %+v", v.result.Diagnostics) + 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 !semanticHas(v, CodeDuplicateID, "/workflows/0/steps/0/onSuccess/1") { - t.Fatalf("effective action duplicate missed: %+v", v.result.Diagnostics) + 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.result.Diagnostics) != 0 { - t.Fatalf("legal field-specific dependency rejected: %+v", v.result.Diagnostics) + 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 !semanticHas(v, CodeDependency, "/workflows/0/steps/1/dependsOn/0") { - t.Fatalf("step cycle missed: %+v", v.result.Diagnostics) + 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 !semanticHas(v, CodeDependency, "/workflows/1/dependsOn/0") { - t.Fatalf("workflow cycle missed: %+v", v.result.Diagnostics) + 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 !semanticHas(v, CodeDependency, "/workflows/0/steps/1/parameters/0/value") { - t.Fatalf("implicit output cycle missed: %+v", v.result.Diagnostics) + 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.result.Diagnostics) != 0 { - t.Fatalf("dotted output name rejected: %+v", v.result.Diagnostics) + 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.result.Diagnostics) != 0 { - t.Fatalf("completed current output falsely became a prerequisite: %+v", v.result.Diagnostics) + if len(v.Diagnostics) != 0 { + t.Fatalf("completed current output falsely became a prerequisite: %+v", v.Diagnostics) } } @@ -240,12 +291,12 @@ func TestSemanticPublicImplicitCycleAndOverride(t *testing.T) { } } -func TestSemanticReusableCollectionAndCycles(t *testing.T) { - v := semanticRun(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") +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 = semanticRun(t, `{"components":{"parameters":{"a":{"name":"id","value":0}}},"workflows":[{"workflowId":"run","steps":[{"stepId":"first","operationId":"fetch","onSuccess":[{"reference":"$components.parameters.a"}]}]}]}`, "1.0") + 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) } diff --git a/arazzo/source_contracts_test.go b/arazzo/source_contracts_test.go index 9854fcb..cbe92f0 100644 --- a/arazzo/source_contracts_test.go +++ b/arazzo/source_contracts_test.go @@ -22,7 +22,7 @@ import ( // 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, base: "https://example.test/main.yaml", byURI: map[string]*linkedSource{}, byName: map[string]map[string]any{}, requests: map[string]*linkedSource{}, descriptionPaths: map[string]string{}} + 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) { diff --git a/arazzo/sources.go b/arazzo/sources.go index 99fdfb3..c764613 100644 --- a/arazzo/sources.go +++ b/arazzo/sources.go @@ -18,19 +18,29 @@ import ( // sourceSession holds only request-local source metadata. SourceDescription names // belong to their containing document; canonical identities belong to documents. type sourceSession struct { - v *validation - base string + v *validation + *sourceState + sourceScope +} + +// sourceState owns the shared caches and resource totals for one validation. +type sourceState struct { byURI map[string]*linkedSource - byName map[string]map[string]any requests map[string]*linkedSource count, bytes, nodes int graphVisited map[*linkedSource]bool - scopePath string - scopeSource *linkedSource - descriptionPaths map[string]string 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 @@ -56,7 +66,7 @@ type linkedTarget struct { type linkedParameterKey struct{ name, in string } func checkSources(v *validation) { - s := &sourceSession{v: v, byURI: make(map[string]*linkedSource), byName: make(map[string]map[string]any), requests: make(map[string]*linkedSource), graphVisited: make(map[*linkedSource]bool)} + 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) @@ -149,7 +159,7 @@ func checkSources(v *validation) { s.requestExpressions(v.expressionUses) for _, collection := range []string{"successActions", "failureActions"} { definitions := object(object(v.root["components"])[collection]) - for _, name := range semanticKeys(definitions) { + for _, name := range sortedKeys(definitions) { value := definitions[name] action := object(value) path := joinPtr("/components/"+collection, name) @@ -244,10 +254,6 @@ func (s *sourceSession) build(source *upstream.ResolvedSource) *linkedSource { } s.nodes += stats.nodes s.bytes += stats.bytes - if s.nodes > s.v.opts.limits.MaxNodes { - s.fail(ErrorLimit, "total source node limit exceeded") - return nil - } item.root = object(value) item.nodes = nodes switch { diff --git a/arazzo/sources_graph.go b/arazzo/sources_graph.go index dd77809..977723c 100644 --- a/arazzo/sources_graph.go +++ b/arazzo/sources_graph.go @@ -49,12 +49,10 @@ func (s *sourceSession) graphSource(source *linkedSource, path string, depth int s.v.incomplete("external-prerequisites", path, source.identity, "adapter does not expose workflow dependency metadata") return } - child := *s - child.base = source.base - child.scopePath = path - child.scopeSource = source - child.descriptionPaths = make(map[string]string) - child.byName = make(map[string]map[string]any) + 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 @@ -151,9 +149,6 @@ func (s *sourceSession) graphSource(source *linkedSource, path string, depth int } else { s.complete("external-prerequisites", path, source.identity) } - s.count = child.count - s.bytes = child.bytes - s.nodes = child.nodes } // graphExpressions reuses the expression traversal and symbol checks on supplied @@ -201,10 +196,12 @@ func (s *sourceSession) graphExpressions(source *linkedSource) { 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 := *s - child.v = nested - nested.sources = &child + 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) } @@ -219,5 +216,4 @@ func (s *sourceSession) graphExpressions(source *linkedSource) { if nested.err != nil { s.v.err = nested.err } - s.count, s.bytes, s.nodes = child.count, child.bytes, child.nodes } diff --git a/arazzo/sources_metadata.go b/arazzo/sources_metadata.go index d5768a1..7fd8371 100644 --- a/arazzo/sources_metadata.go +++ b/arazzo/sources_metadata.go @@ -27,21 +27,21 @@ func (source *linkedSource) indexRaw() { case "openapi": for _, collection := range []string{"paths", "webhooks"} { values := object(source.root[collection]) - for _, key := range semanticKeys(values) { + 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 semanticKeys(items) { + 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 semanticKeys(callbacks) { + for _, key := range sortedKeys(callbacks) { value := callbacks[key] callback := source.deref(object(value)) - for _, expression := range semanticKeys(callback) { + 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)) @@ -51,7 +51,7 @@ func (source *linkedSource) indexRaw() { case "asyncapi": channels := object(source.root["channels"]) - for _, name := range semanticKeys(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} @@ -63,7 +63,7 @@ func (source *linkedSource) indexRaw() { } } operations := object(source.root["operations"]) - for _, name := range semanticKeys(operations) { + for _, name := range sortedKeys(operations) { value := operations[name] operation := source.deref(object(value)) target := source.addOperation(operation, joinPtr("/operations", name), nil) @@ -316,14 +316,14 @@ func (source *linkedSource) indexPathItem(raw map[string]any, path string, regis return } operations := map[string]map[string]any{} - for _, method := range semanticKeys(item) { + for _, method := range sortedKeys(item) { value := item[method] if httpMethod(method) { operations[method] = source.deref(object(value)) } } additional := object(item["additionalOperations"]) - for _, method := range semanticKeys(additional) { + for _, method := range sortedKeys(additional) { value := additional[method] operations[joinPtr("additionalOperations", method)] = source.deref(object(value)) } @@ -337,7 +337,7 @@ func (source *linkedSource) indexPathItem(raw map[string]any, path string, regis opPath := path + "/" + method source.addOperationMetadata(operation, opPath, item["parameters"], registerID) callbacks := object(operation["callbacks"]) - for _, name := range semanticKeys(callbacks) { + for _, name := range sortedKeys(callbacks) { value := callbacks[name] rawCallback := object(value) ref := text(rawCallback["$ref"]) @@ -349,7 +349,7 @@ func (source *linkedSource) indexPathItem(raw map[string]any, path string, regis active[ref] = true } callback := source.deref(rawCallback) - for _, expression := range semanticKeys(callback) { + 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) diff --git a/arazzo/sources_targets.go b/arazzo/sources_targets.go index fab3c73..4c0956c 100644 --- a/arazzo/sources_targets.go +++ b/arazzo/sources_targets.go @@ -8,6 +8,7 @@ import ( "net/url" "sort" "strings" + "unicode/utf8" upstream "github.com/pb33f/libopenapi/arazzo" "github.com/pb33f/libopenapi/arazzo/expression" @@ -223,6 +224,9 @@ func (s *sourceSession) pointer(value, path, field string, step map[string]any, } func linkedPointerTokens(pointer string) ([]string, bool) { + if !utf8.ValidString(pointer) { + return nil, false + } if pointer == "" { return nil, true } diff --git a/arazzo/target_contracts_test.go b/arazzo/target_contracts_test.go index 78d0a81..f9034b1 100644 --- a/arazzo/target_contracts_test.go +++ b/arazzo/target_contracts_test.go @@ -31,7 +31,7 @@ func targetContractSession(t *testing.T, kind string, adapter upstream.SourceDoc 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, base: v.doc.URI, byName: map[string]map[string]any{"api": {"name": "api", "url": "api.yaml", "type": kind}}, byURI: map[string]*linkedSource{}, requests: map[string]*linkedSource{}, graphVisited: map[*linkedSource]bool{}, targets: map[dependencyID]*linkedTarget{}, descriptionPaths: map[string]string{"api": "/sourceDescriptions/0"}} + 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) { diff --git a/arazzo/types.go b/arazzo/types.go index 460065e..2a2f57d 100644 --- a/arazzo/types.go +++ b/arazzo/types.go @@ -142,7 +142,16 @@ type Limits struct { } // DefaultLimits returns the default per-call limits. -func DefaultLimits() Limits { return Limits{16 << 20, 200000, 128, 50, 1000, 65536} } +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 ( diff --git a/arazzo/validate.go b/arazzo/validate.go index 759e175..293570c 100644 --- a/arazzo/validate.go +++ b/arazzo/validate.go @@ -44,10 +44,11 @@ func configure(ctx context.Context, opts []Option) (options, error) { // 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 nil, err + return &Result{}, err } if err := ctx.Err(); err != nil { return &Result{}, &Error{Kind: ErrorOperational, Location: Location{URI: uri}, Cause: err} @@ -58,13 +59,13 @@ func ValidateBytes(ctx context.Context, data []byte, uri string, opts ...Option) var root, extra yaml.Node decoder := yaml.NewDecoder(bytes.NewReader(data)) if err := decoder.Decode(&root); err != nil { - return nil, &Error{Kind: ErrorInput, Location: Location{URI: uri}, Cause: err} + 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 nil, &Error{Kind: ErrorInput, Location: Location{URI: uri, Line: extra.Line, Column: extra.Column}, Cause: err} + 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. @@ -95,7 +96,7 @@ func ValidateBytes(ctx context.Context, data []byte, uri string, opts ...Option) func Validate(ctx context.Context, doc Document, opts ...Option) (*Result, error) { o, err := configure(ctx, opts) if err != nil { - return nil, err + return &Result{}, err } return validate(ctx, doc, o) } @@ -104,13 +105,6 @@ func validate(ctx context.Context, doc Document, opts options) (result *Result, result = &Result{} v := &validation{ctx: ctx, doc: doc, opts: opts, result: result, budget: &workBudget{}} defer func() { - if recovered := recover(); recovered != nil { - if patternError, ok := recovered.(patternMatchError); ok { - v.err = &Error{Kind: ErrorOperational, Cause: patternError.err} - } else { - panic(recovered) - } - } if v.err != nil { var toolError *Error if !errors.As(v.err, &toolError) { @@ -199,10 +193,8 @@ func validate(ctx context.Context, doc Document, opts options) (result *Result, if v.check() { finalizeLinkedExpressionUses(v) } - if v.check() && v.graph != nil { - if err := v.graph.check(ctx, func(path, msg string) { v.add(CodeDependency, path, msg) }); err != nil { - v.err = err - } + if v.check() { + checkDependencyGraph(v) } if v.check() { v.complete("local-semantics", "") diff --git a/arazzo/validate_test.go b/arazzo/validate_test.go index 628eca5..7948079 100644 --- a/arazzo/validate_test.go +++ b/arazzo/validate_test.go @@ -93,7 +93,7 @@ func TestFoundationVersionsAndJSONParity(t *testing.T) { if err := yaml.Unmarshal(data, &root); err != nil { t.Fatal(err) } - value, _, _, err := normalize(context.Background(), Document{Root: &root}, DefaultLimits()) + value, _, _, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) if err != nil { t.Fatal(err) } @@ -151,7 +151,7 @@ func TestFoundationStructuralLocations(t *testing.T) { if err := yaml.Unmarshal([]byte(data), &root); err != nil { t.Fatal(err) } - _, nodes, _, err := normalize(context.Background(), Document{Root: &root}, DefaultLimits()) + _, nodes, _, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) if err != nil { t.Fatal(err) } @@ -252,7 +252,7 @@ func TestFoundationYAMLGraphsAndNumbers(t *testing.T) { if err := yaml.Unmarshal([]byte(data), &root); err != nil { t.Fatal(err) } - value, _, diags, err := normalize(context.Background(), Document{Root: &root}, DefaultLimits()) + value, _, diags, _, err := normalizeWithStats(context.Background(), Document{Root: &root}, DefaultLimits()) if err != nil || len(diags) > 0 { t.Fatalf("%v %+v", err, diags) } @@ -264,20 +264,20 @@ func TestFoundationYAMLGraphsAndNumbers(t *testing.T) { if err := yaml.Unmarshal([]byte(data), &root); err != nil { t.Fatal(err) } - _, _, diags, err := normalize(context.Background(), Document{Root: &root}, DefaultLimits()) + _, _, 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 = normalize(context.Background(), Document{Root: &root}, DefaultLimits()) + _, _, 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 = normalize(context.Background(), Document{Root: &root}, limits) + _, _, _, _, err = normalizeWithStats(context.Background(), Document{Root: &root}, limits) var tool *Error if !errors.As(err, &tool) || tool.Kind != ErrorLimit { t.Fatal(err) From a6d0076e18ae32b143d467795b67d54c22758337 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Wed, 7 Oct 2026 16:57:34 -0400 Subject: [PATCH 6/8] test: benchmark Arazzo input schema validation --- arazzo/benchmark_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/arazzo/benchmark_test.go b/arazzo/benchmark_test.go index 582e1f7..286be3a 100644 --- a/arazzo/benchmark_test.go +++ b/arazzo/benchmark_test.go @@ -22,6 +22,23 @@ func benchmarkNodes(b *testing.B, data string) *yaml.Node { 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) { From 7272b3146fbd65990104f4986fea7b696b8a06ce Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Wed, 7 Oct 2026 16:57:34 -0400 Subject: [PATCH 7/8] docs: simplify Arazzo validation guidance --- README.md | 5 +- arazzo/README.md | 117 ++++++++++++++--------------------------- arazzo/REQUIREMENTS.md | 6 +-- arazzo/VALIDATION.md | 81 ---------------------------- 4 files changed, 44 insertions(+), 165 deletions(-) delete mode 100644 arazzo/VALIDATION.md diff --git a/README.md b/README.md index ad7f80e..8685b3f 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,8 @@ 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 additive [`arazzo` package](arazzo/README.md) validates Arazzo 1.0 and 1.1 -structure and static semantics. It accepts parsed YAML/JSON nodes and caller-supplied -source documents, with detached diagnostics and explicit coverage records. +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 index 709c771..13f0396 100644 --- a/arazzo/README.md +++ b/arazzo/README.md @@ -1,83 +1,44 @@ -# Static Arazzo validation +# Validate Arazzo workflows -`arazzo.Validate` accepts an existing `yaml.Node` and its retrieval URI. -`arazzo.ValidateBytes` accepts exactly one JSON or YAML document. Both use the -same validation path. Arazzo 1.0 and 1.1 feature sets accept compatible patch -versions and nonempty prerelease suffixes, as permitted by the official schemas. -The existing OpenAPI and HTTP validator APIs are unchanged. +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.Validate(ctx, arazzo.Document{ - Root: root, - URI: "https://example.test/workflows.yaml", -}, arazzo.WithSources(candidates...)) +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) +} ``` -`candidates` uses the existing `libopenapi/arazzo.CandidateDocument` type. Sources -can contain original nodes, bytes, high models, or typed presence adapters. -`WithResolver` accepts the existing `SourceDocumentResolver` interface. A resolver -is explicit caller-owned retrieval authority; it must enforce its own access -policy and honor context cancellation. There is no implicit file or network I/O. -The executable [example](example_test.go) supplies OpenAPI, AsyncAPI and Arazzo -source nodes. Its fixture bundle is available to downstream consumers. - -## Outcomes - -| Outcome | Result | -| --- | --- | -| Conformance error | Diagnostic, normally no tool error; `Valid()` is false. | -| All applicable checks ran successfully | `Valid()` and `Complete` are true. | -| Source, detailed adapter metadata, or syntax capability unavailable | Local findings remain; `Checks` contains the affected URI, pointer, source and reason; `Complete` is false. | -| Known source proves missing target or wrong kind | Reference or source-type diagnostic at the authored field. | -| Unparseable bytes, unsupported feature version, invalid API configuration | Typed `*Error` with input, unsupported-version or configuration kind. | -| Cancellation, resource limit, resolver failure, compiler failure | Operational error with an incomplete partial result. Cancellation remains accessible through `errors.Is`. | - -Always check the returned error and `Complete`; `Valid()` alone means only that -no conformance errors were found. Structural failures stop semantic passes and -record that prerequisite failure in coverage. Missing or non-string versions are -structural findings; malformed version strings are typed input errors. - -Diagnostics have stable typed codes, severity, source URI, RFC 6901 pointer, and -one-based line/column where supplied. Missing fields use their nearest existing -parent. Invalid map names use key locations. Duplicate IDs include the first -declaration as a related location. Results contain only detached values and do -not retain nodes, models, indexes or compiler errors. - -## Boundaries - -Validation checks static structure, identities, references, expressions, -inheritance and prerequisites. It does not execute operations, evaluate criteria -against invented runtime data, fetch input schema references, authenticate, -schedule retries, or transport messages. Dynamic input schemas are checked -conservatively: absence from a direct properties map is not proof of absence -when references, composition or dynamic properties can supply that input. - -XPath and the legacy Goessner JSONPath dialect currently record unavailable -syntax coverage. RFC 9535 JSONPath, JSON Pointer, regex and simple conditions -have static parsers. Runtime-substituted condition syntax records incomplete -coverage after checking the embedded expressions. Presence-only source adapters -record the detailed metadata checks they cannot prove. Receive-step success can -also require message semantics that are unavailable statically. -Rootless Arazzo high models expose only a partial expression projection; their -prerequisite coverage remains incomplete. Supply original nodes or bytes to -check all expression-bearing fields. -Separate logical operators from bare header expressions with whitespace: -`&` and `|` are legal characters inside header names. - -`WithLimits` replaces positive per-call limits. Defaults are 16 MiB of input and -expanded bytes, 200,000 nodes/derived work units, depth 128, 50 source documents, -1,000 diagnostics and 64 KiB per expression/pattern. Node/byte limits account for -alias expansion and cumulative supplied source data. Separate shared work budgets -bound derived graph edges, inherited values, reference hops, expression uses, -coverage records and repeated literal/condition bytes. Numeric exponents charge -their arbitrary-precision expansion before schema arithmetic. Source caches and -reference memoization last for one call. Only immutable official schemas are -cached globally. No goroutine is started per field or diagnostic. - -`WithAdvisories` enables naming and sequential forward-reference warnings. -Recommendations remain separate from required conformance errors. Vacuum can -map library codes and coverage to its own rules and severity policy. - -The [requirements ledger](REQUIREMENTS.md) records clauses, fixtures and schema -discrepancies. The [validation record](VALIDATION.md) contains checks, coverage and -benchmark results. Vacuum integration remains a separate consumer task. +`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 index 4a16e3f..4703809 100644 --- a/arazzo/REQUIREMENTS.md +++ b/arazzo/REQUIREMENTS.md @@ -10,7 +10,7 @@ checks the original bytes and offline compilation. No dependency change is neede 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 -unless the test name explicitly describes an internal graph or normalization unit. +except explicit Unit/Contract tests of graph, normalization and defensive internal boundaries. ## Clause and regression ledger @@ -21,10 +21,10 @@ unless the test name explicitly describes an internal graph or normalization uni | 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`, `TestSemanticReusableOverridesAndDuplicateIdentity`, `TestSemanticPublicWorkflowInputParameters`, `TestSourcesExternalStepMetadata` | +| 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` | `TestSemanticReusableCollectionAndCycles`, `TestSemanticReusableOverridesAndDuplicateIdentity`, `TestSemanticPublicImplicitCycleAndOverride` | +| 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` | diff --git a/arazzo/VALIDATION.md b/arazzo/VALIDATION.md deleted file mode 100644 index b17107a..0000000 --- a/arazzo/VALIDATION.md +++ /dev/null @@ -1,81 +0,0 @@ -# Validation record - -Checked on 7 October 2026 in isolated branch `codex/arazzo-validation`, based on -`b1808fec4e5a405c08332df7b64f747b4a2d2c5a`. This record covers local validation of the package. The primary checkout remains unchanged. - -All checks use Go 1.26.8, `GOWORK=off`, and the module's released dependencies. - -| Check | Result | -| --- | --- | -| `go test ./... -count=1` | All module tests pass, including the external-package mixed-source example. | -| `go test -coverprofile=… ./... -count=1` | Pass; 98.8% repository statement coverage, including the CLI. Existing packages measure 98.662%; Arazzo measures 99.130%. | -| `go test -race -coverprofile=… ./arazzo -count=1` | Pass; 99.1% package statement coverage. | -| `go test -race ./arazzo -run TestResultDoesNotRetainSourceGraphs -count=20` | Pass; retained diagnostics do not retain primary/source root graphs. | -| `go build ./...` | Pass. | -| `go vet ./...` | Pass. | -| `go mod tidy -diff` | Pass, no module or checksum changes. | -| `golangci-lint run ./...` | Pass, zero issues. | -| gofumpt, gci and `git diff --check` | Pass. | -| Schema snapshot checksums and all 77 ledger test references | Pass. | -| Independent correctness and resource reviews | All reported findings repaired and verified; no remaining P0–P2 findings in the reviewed scope. | - -Statement coverage is measured coverage, not a claim of full branch coverage. -The five boundary test files cover source/model projection, references, target -metadata, dependency graphs, expressions, schema diagnostics, cancellation and -resource limits. Tests assert findings and capabilities, with external source -coordinates and caller immutability where applicable. Disabling integer or float -retagging makes the public numeric-bound regression fail. Independent correctness -and de-slop reviews found no remaining material issues in these tests. - -The initial `f8f46d2` report measured 68.44% Codecov patch coverage and 91.38% -project coverage against the 98.27% base. The updated tests and six review repairs -have passed local validation. Codecov line coverage on the final published head -must meet or exceed the base before the PR is ready. Thresholds, exclusions and -the CI coverage command are unchanged. - -The [requirements ledger](REQUIREMENTS.md) records semantic fixtures and capability -limits. The [package guide](README.md) explains the result and coverage contract. - -## Benchmarks - -Apple M4 Max, darwin/arm64, default 16 logical processors. Values are medians of -five local samples. The warm benchmarks accept existing nodes; they include -per-call normalization, indexing, validation and detached result construction. -The cold benchmark compiles both embedded schemas without the global cache. - -| Case | ns/op | B/op | allocs/op | -| --- | ---: | ---: | ---: | -| Warm: 10 steps, operations and expressions | 304,601 | 343,487 | 4,065 | -| Warm: 100 steps, operations and expressions | 2,375,296 | 3,241,226 | 38,697 | -| Warm: 1,000 steps, operations and expressions | 23,764,141 | 33,128,780 | 387,783 | -| 10 findings | 182,586 | 212,284 | 2,858 | -| 100 findings | 1,709,425 | 1,991,975 | 27,096 | -| 500 findings | 7,729,827 | 9,981,505 | 136,043 | -| 10 workflows sharing a source prerequisite and reusable parameter | 280,759 | 344,519 | 4,007 | -| 100 workflows sharing a source prerequisite and reusable parameter | 2,459,634 | 3,000,619 | 36,122 | -| 1,000 workflows sharing a source prerequisite and reusable parameter | 22,486,128 | 29,770,868 | 360,728 | -| 10 prerequisites sharing the final external step | 301,489 | 360,126 | 4,432 | -| 100 prerequisites sharing the final external step | 2,991,594 | 3,126,669 | 38,691 | -| 1,000 prerequisites sharing the final external step | 23,188,979 | 30,686,837 | 386,776 | -| Cold: compile both official schemas | 5,419,673 | 5,042,258 | 66,395 | - -Time and allocation growth are approximately linear across these workloads. -These are local measurements, not service latency promises. Resolver transport -time is excluded: benchmark sources are supplied in memory. - -The resource review also probed workflow prerequisite fan-out and shared local -OpenAPI reference chains. Workflow prerequisites use a shared start node rather -than a steps × dependencies edge expansion. Reference chains use a per-call memo. -Inherited action/list expansion and repeated parser input have explicit budgets -before allocation or parsing, with public limit regressions. - -## Consumer handback - -Use `Validate` for parsed nodes and `ValidateBytes` for one JSON/YAML document. -Use upstream `CandidateDocument` and `SourceDocumentResolver` through options. -Always inspect the returned error, `Result.Valid()` and `Result.Complete`. - -The library performs no default retrieval or workflow execution. XPath, legacy -JSONPath, runtime-substituted conditions and unavailable detailed source metadata -produce explicit incomplete coverage. No upstream exports or dependency upgrades -are required. Vacuum integration is the next separate consumer change. From d80b603cbfad6ce75ec8d507205a34a7393814c3 Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Wed, 7 Oct 2026 17:09:39 -0400 Subject: [PATCH 8/8] test: complete Arazzo validation coverage --- arazzo/benchmark_test.go | 2 +- arazzo/foundation_contracts_test.go | 73 +++++++++++++++++++++++++++++ arazzo/inputs.go | 11 ----- arazzo/schema.go | 41 +++++++--------- arazzo/validate.go | 3 -- 5 files changed, 90 insertions(+), 40 deletions(-) diff --git a/arazzo/benchmark_test.go b/arazzo/benchmark_test.go index 286be3a..b87b879 100644 --- a/arazzo/benchmark_test.go +++ b/arazzo/benchmark_test.go @@ -93,7 +93,7 @@ func BenchmarkValidateManyFindings(b *testing.B) { func BenchmarkCompileOfficialSchemas(b *testing.B) { b.ReportAllocs() for b.Loop() { - if _, err := compileOfficialSchemas(); err != nil { + if _, err := compileOfficialSchemas(schemaFiles); err != nil { b.Fatal(err) } } diff --git a/arazzo/foundation_contracts_test.go b/arazzo/foundation_contracts_test.go index cd4ca6d..f4dd042 100644 --- a/arazzo/foundation_contracts_test.go +++ b/arazzo/foundation_contracts_test.go @@ -5,10 +5,13 @@ package arazzo import ( "context" + "encoding/json" "errors" + "io/fs" "reflect" "strings" "testing" + "testing/fstest" "github.com/pb33f/go-yaml" "github.com/pb33f/jsonschema/v6" @@ -148,11 +151,16 @@ func TestValidationSharedBudgetAndOrdering(t *testing.T) { {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...) @@ -235,3 +243,68 @@ func TestFoundationCancellationAtEveryPhase(t *testing.T) { } } } + +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/inputs.go b/arazzo/inputs.go index d472f97..88757f6 100644 --- a/arazzo/inputs.go +++ b/arazzo/inputs.go @@ -94,10 +94,6 @@ func checkInputs(v *validation) { return } _, _ = compiler.Compile(base + "#" + url.PathEscape(slot.path)) - if regex.err != nil { - v.err = &Error{Kind: ErrorOperational, Location: v.location(slot.path), Cause: regex.err} - return - } } // Resolve each authored reference separately. An unavailable external sibling // must not prevent diagnostics for a definite missing local target. @@ -120,16 +116,9 @@ func checkInputs(v *validation) { return } _, err := compiler.Compile(base + "#" + url.PathEscape(slot.path)) - if regex.err != nil { - v.err = &Error{Kind: ErrorOperational, Location: v.location(slot.path), Cause: regex.err} - return - } if err == nil { continue } - if v.err != nil { - return - } reportInputCompile(v, regex, err, schemaReferencePath(slot.value, slot.path)) } } diff --git a/arazzo/schema.go b/arazzo/schema.go index 0464761..915b615 100644 --- a/arazzo/schema.go +++ b/arazzo/schema.go @@ -6,8 +6,8 @@ package arazzo import ( "bytes" "embed" - "errors" "fmt" + "io/fs" "regexp" "sort" "strconv" @@ -23,21 +23,23 @@ import ( //go:embed schemas/*.json var schemaFiles embed.FS -var officialSchemas = sync.OnceValues(compileOfficialSchemas) +var officialSchemas = sync.OnceValues(func() (map[string]*jsonschema.Schema, error) { + return compileOfficialSchemas(schemaFiles) +}) -func compileOfficialSchemas() (map[string]*jsonschema.Schema, error) { +func compileOfficialSchemas(files fs.ReadFileFS) (map[string]*jsonschema.Schema, error) { compiled := make(map[string]*jsonschema.Schema, 2) for _, entry := range []struct{ version, file string }{ {"1.0", "schemas/arazzo-1.0-2025-10-15.json"}, {"1.1", "schemas/arazzo-1.1-2026-04-15.json"}, } { - data, err := schemaFiles.ReadFile(entry.file) + data, err := files.ReadFile(entry.file) if err != nil { - return nil, err + return nil, fmt.Errorf("read embedded schema %s: %w", entry.file, err) } value, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) if err != nil { - return nil, err + return nil, fmt.Errorf("decode embedded schema %s: %w", entry.file, err) } compiler := jsonschema.NewCompiler() compiler.UseLoader(denyLoader{}) @@ -67,11 +69,11 @@ func compileOfficialSchemas() (map[string]*jsonschema.Schema, error) { } id := text(object(value)["$id"]) if err := compiler.AddResource(id, value); err != nil { - return nil, err + return nil, fmt.Errorf("register embedded schema %s: %w", entry.file, err) } schema, err := compiler.Compile(id) if err != nil { - return nil, err + return nil, fmt.Errorf("compile embedded schema %s: %w", entry.file, err) } compiled[entry.version] = schema } @@ -134,15 +136,12 @@ func checkStructure(v *validation) { if err == nil { return } - var validationError *jsonschema.ValidationError - if !errors.As(err, &validationError) { - v.err = &Error{Kind: ErrorOperational, Cause: err} - 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, string) - collect = func(failure *jsonschema.ValidationError, keyPath string) { + var collect func(*jsonschema.ValidationError) + collect = func(failure *jsonschema.ValidationError) { if !v.check() { return } @@ -150,9 +149,6 @@ func checkStructure(v *validation) { for _, token := range failure.InstanceLocation { path = joinPtr(path, token) } - if keyPath != "" { - path = keyPath - } schemaPath := failure.SchemaURL for _, token := range failure.ErrorKind.KeywordPath() { schemaPath = joinPtr(schemaPath, token) @@ -228,13 +224,11 @@ func checkStructure(v *validation) { return a.ErrorKind.LocalizedString(printer) < b.ErrorKind.LocalizedString(printer) }) for _, child := range children { - collect(child, keyPath) + collect(child) } return } switch failure.ErrorKind.(type) { - case *kind.Schema, *kind.Group, *kind.Reference: - return case *kind.FalseSchema: if strings.Contains(failure.SchemaURL, "/unevaluatedProperties") { emit(path, "property is not permitted", true) @@ -243,8 +237,5 @@ func checkStructure(v *validation) { } emit(path, failure.ErrorKind.LocalizedString(printer), false) } - collect(validationError, "") - if len(v.result.Diagnostics) == 0 && v.err == nil { - v.add(CodeStructure, "", err.Error()) - } + collect(validationError) } diff --git a/arazzo/validate.go b/arazzo/validate.go index 293570c..1f4aa13 100644 --- a/arazzo/validate.go +++ b/arazzo/validate.go @@ -73,9 +73,6 @@ func ValidateBytes(ctx context.Context, data []byte, uri string, opts ...Option) for len(stack) > 0 { n := stack[len(stack)-1] stack = stack[:len(stack)-1] - if n == nil { - continue - } 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") {