From fab1a0f31af656db3051f1f8d1d12a6792c95a72 Mon Sep 17 00:00:00 2001 From: quobix Date: Sun, 26 Jul 2026 11:12:45 -0400 Subject: [PATCH 1/3] feat: Arazzo 1.1 support, source resolution, and schema index attribution Arazzo: - Add 1.1 model surface: selectors, output values, version/origin helpers, and the extended step/workflow/action fields, in both low and high models. - Rework the runtime expression parser and evaluator with a shared symbol table, 1.1 runtime sources, and stricter grammar/version conformance. - Add a pluggable source description resolver (CandidateDocumentProvider, SourceDocumentResolver, and OpenAPI/AsyncAPI/Arazzo adapters) so callers can supply source documents without libopenapi performing I/O. - Tighten validation and error reporting, including negative 1.1 fixtures, and extend the execution engine preflight checks. Schema and index: - Add SpecIndex.ResolveDocumentVersion so version dependent decisions can fall back to the rolodex root and decline when no version is reachable. - Correct schema index attribution during build so schemas report the file their content actually came from; document the SchemaProxy.GetIndex contract. - Fix inline $ref rendering for examples and v3 models. what-changed: - Copy only public fields when building the allOf comparison view, avoiding a copy of the schema's internal sync.Map. Plus extensive test coverage across arazzo, orderedmap, json, and the new index attribution fixtures. --- .github/workflows/build.yaml | 2 + .gitignore | 1 + arazzo.go | 53 +- arazzo/arazzo_benchmark_test.go | 84 ++ arazzo/coverage_test.go | 46 +- arazzo/criterion.go | 39 +- arazzo/engine.go | 251 +++++- arazzo/engine_coverage_test.go | 66 +- arazzo/engine_test.go | 8 +- arazzo/errors.go | 62 +- arazzo/execution_preflight_test.go | 438 +++++++++++ arazzo/expression/conformance_test.go | 245 ++++++ arazzo/expression/evaluator.go | 310 ++++++-- arazzo/expression/evaluator_test.go | 12 +- arazzo/expression/expression.go | 76 +- arazzo/expression/gap_coverage_test.go | 2 +- arazzo/expression/parser.go | 447 ++++++++--- arazzo/expression/parser_test.go | 105 ++- arazzo/expression/runtime_sources_test.go | 208 +++++ arazzo/expression/symbols.go | 156 ++++ arazzo/expression/version_grammar_test.go | 182 +++++ arazzo/final_coverage_test.go | 100 ++- arazzo/gap_coverage_test.go | 6 +- arazzo/malformed_input_test.go | 175 +++++ arazzo/model_matrix_test.go | 190 +++++ arazzo/resolve.go | 106 ++- arazzo/resolve_context_test.go | 464 +++++++++++ arazzo/resolve_test.go | 13 + arazzo/source_resolver.go | 479 +++++++++++ arazzo/source_resolver_test.go | 741 ++++++++++++++++++ arazzo/step.go | 57 +- arazzo/testdata/arazzo-1.0-render.yaml | 21 + arazzo/testdata/arazzo-1.1-official.yaml | 83 ++ .../negative-11/action-not-scalar.yaml | 11 + .../action-parameters-not-sequence.yaml | 15 + .../negative-11/channel-path-not-scalar.yaml | 11 + .../correlation-id-not-scalar.yaml | 12 + .../negative-11/depends-on-not-sequence.yaml | 12 + .../testdata/negative-11/self-not-scalar.yaml | 12 + .../negative-11/timeout-not-integer.yaml | 11 + arazzo/testdata/official-schema-metadata.json | 16 + arazzo/validation.go | 130 ++- arazzo/validation_regression_test.go | 342 ++++++++ arazzo/validation_test.go | 12 +- arazzo_test.go | 252 ++++++ datamodel/high/arazzo/arazzo.go | 106 ++- datamodel/high/arazzo/arazzo_test.go | 20 +- datamodel/high/arazzo/build_helpers.go | 98 +++ datamodel/high/arazzo/coverage_test.go | 14 +- datamodel/high/arazzo/criterion.go | 20 +- .../high/arazzo/criterion_expression_type.go | 30 +- datamodel/high/arazzo/failure_action.go | 9 +- datamodel/high/arazzo/fuzz_test.go | 42 + datamodel/high/arazzo/model_rendering_test.go | 458 +++++++++++ datamodel/high/arazzo/origin.go | 92 +++ datamodel/high/arazzo/output_value.go | 147 ++++ .../high/arazzo/output_value_example_test.go | 32 + datamodel/high/arazzo/parameter.go | 34 +- datamodel/high/arazzo/payload_replacement.go | 79 +- datamodel/high/arazzo/request_body.go | 11 +- datamodel/high/arazzo/selector.go | 109 +++ datamodel/high/arazzo/step.go | 65 +- datamodel/high/arazzo/success_action.go | 9 +- datamodel/high/arazzo/version.go | 80 ++ datamodel/high/arazzo/version_origin_test.go | 157 ++++ datamodel/high/arazzo/workflow.go | 27 +- .../high/base/example_inline_ref_test.go | 80 ++ datamodel/high/base/schema_proxy.go | 25 +- datamodel/high/node_builder_test.go | 6 - datamodel/high/v3/inline_ref_render_test.go | 141 ++++ datamodel/low/arazzo/arazzo.go | 12 +- datamodel/low/arazzo/arazzo_test.go | 40 +- datamodel/low/arazzo/components.go | 6 +- datamodel/low/arazzo/constants.go | 7 + datamodel/low/arazzo/coverage_test.go | 31 +- .../low/arazzo/criterion_expression_type.go | 25 +- datamodel/low/arazzo/failure_action.go | 42 +- datamodel/low/arazzo/final_coverage_test.go | 34 +- datamodel/low/arazzo/helpers.go | 308 +++++++- datamodel/low/arazzo/model_fields_test.go | 346 ++++++++ datamodel/low/arazzo/node_kind_test.go | 593 ++++++++++++++ datamodel/low/arazzo/output_value.go | 104 +++ datamodel/low/arazzo/payload_replacement.go | 38 +- datamodel/low/arazzo/selector.go | 115 +++ datamodel/low/arazzo/step.go | 63 +- datamodel/low/arazzo/success_action.go | 38 +- datamodel/low/arazzo/workflow.go | 19 +- datamodel/low/base/circ_check.go | 8 +- datamodel/low/base/resolve_exclusive_test.go | 95 +++ datamodel/low/base/schema.go | 26 +- datamodel/low/base/schema_build.go | 102 +-- datamodel/low/base/schema_build_helpers.go | 44 ++ datamodel/low/base/schema_hash.go | 9 +- datamodel/low/base/schema_proxy.go | 6 + datamodel/low/v3/paths.go | 11 +- .../low/v3/schema_index_attribution_test.go | 544 +++++++++++++ index/index_model.go | 31 + index/resolve_document_version_test.go | 111 +++ json/json_test.go | 25 + orderedmap/accessors_test.go | 108 +++ orderedmap/builder_test.go | 40 + schema_quickhash_attribution_test.go | 63 ++ test_specs/index_attribution/circ_a.yaml | 5 + test_specs/index_attribution/circ_b.yaml | 5 + test_specs/index_attribution/circ_root.yaml | 9 + test_specs/index_attribution/ext31.yaml | 10 + test_specs/index_attribution/frag30.yaml | 3 + test_specs/index_attribution/level1.yaml | 71 ++ test_specs/index_attribution/level2.yaml | 6 + test_specs/index_attribution/refs_a.yaml | 3 + test_specs/index_attribution/refs_b.yaml | 3 + test_specs/index_attribution/root.yaml | 18 + test_specs/index_attribution/root_30.yaml | 9 + .../index_attribution/root_30_ext31.yaml | 9 + .../index_attribution/root_collide.yaml | 21 + test_specs/index_attribution/shared_two.yaml | 10 + what-changed/model/breaking_rules_test.go | 9 +- what-changed/model/change_types.go | 2 +- what-changed/model/schema.go | 20 +- what-changed/model/schema_test.go | 3 +- 120 files changed, 10480 insertions(+), 735 deletions(-) create mode 100644 arazzo/arazzo_benchmark_test.go create mode 100644 arazzo/execution_preflight_test.go create mode 100644 arazzo/expression/conformance_test.go create mode 100644 arazzo/expression/runtime_sources_test.go create mode 100644 arazzo/expression/symbols.go create mode 100644 arazzo/expression/version_grammar_test.go create mode 100644 arazzo/malformed_input_test.go create mode 100644 arazzo/model_matrix_test.go create mode 100644 arazzo/resolve_context_test.go create mode 100644 arazzo/source_resolver.go create mode 100644 arazzo/source_resolver_test.go create mode 100644 arazzo/testdata/arazzo-1.0-render.yaml create mode 100644 arazzo/testdata/arazzo-1.1-official.yaml create mode 100644 arazzo/testdata/negative-11/action-not-scalar.yaml create mode 100644 arazzo/testdata/negative-11/action-parameters-not-sequence.yaml create mode 100644 arazzo/testdata/negative-11/channel-path-not-scalar.yaml create mode 100644 arazzo/testdata/negative-11/correlation-id-not-scalar.yaml create mode 100644 arazzo/testdata/negative-11/depends-on-not-sequence.yaml create mode 100644 arazzo/testdata/negative-11/self-not-scalar.yaml create mode 100644 arazzo/testdata/negative-11/timeout-not-integer.yaml create mode 100644 arazzo/testdata/official-schema-metadata.json create mode 100644 arazzo/validation_regression_test.go create mode 100644 datamodel/high/arazzo/fuzz_test.go create mode 100644 datamodel/high/arazzo/model_rendering_test.go create mode 100644 datamodel/high/arazzo/origin.go create mode 100644 datamodel/high/arazzo/output_value.go create mode 100644 datamodel/high/arazzo/output_value_example_test.go create mode 100644 datamodel/high/arazzo/selector.go create mode 100644 datamodel/high/arazzo/version.go create mode 100644 datamodel/high/arazzo/version_origin_test.go create mode 100644 datamodel/high/base/example_inline_ref_test.go create mode 100644 datamodel/high/v3/inline_ref_render_test.go create mode 100644 datamodel/low/arazzo/model_fields_test.go create mode 100644 datamodel/low/arazzo/node_kind_test.go create mode 100644 datamodel/low/arazzo/output_value.go create mode 100644 datamodel/low/arazzo/selector.go create mode 100644 datamodel/low/base/resolve_exclusive_test.go create mode 100644 datamodel/low/v3/schema_index_attribution_test.go create mode 100644 index/resolve_document_version_test.go create mode 100644 orderedmap/accessors_test.go create mode 100644 schema_quickhash_attribution_test.go create mode 100644 test_specs/index_attribution/circ_a.yaml create mode 100644 test_specs/index_attribution/circ_b.yaml create mode 100644 test_specs/index_attribution/circ_root.yaml create mode 100644 test_specs/index_attribution/ext31.yaml create mode 100644 test_specs/index_attribution/frag30.yaml create mode 100644 test_specs/index_attribution/level1.yaml create mode 100644 test_specs/index_attribution/level2.yaml create mode 100644 test_specs/index_attribution/refs_a.yaml create mode 100644 test_specs/index_attribution/refs_b.yaml create mode 100644 test_specs/index_attribution/root.yaml create mode 100644 test_specs/index_attribution/root_30.yaml create mode 100644 test_specs/index_attribution/root_30_ext31.yaml create mode 100644 test_specs/index_attribution/root_collide.yaml create mode 100644 test_specs/index_attribution/shared_two.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8b6f40b80..e08559b75 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -47,6 +47,8 @@ jobs: - name: Test with coverage run: go test -coverprofile=coverage.out ./... + - name: Race detector + run: go test -race ./... - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index d890c2ef0..8c078ebd6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ test-operation.yaml .idea/ *.iml .zed/ +/arazzz-me.md diff --git a/arazzo.go b/arazzo.go index c732db4db..1edf3d30e 100644 --- a/arazzo.go +++ b/arazzo.go @@ -6,6 +6,7 @@ package libopenapi import ( gocontext "context" "fmt" + "log/slog" high "github.com/pb33f/libopenapi/datamodel/high/arazzo" "github.com/pb33f/libopenapi/datamodel/low" @@ -13,8 +14,33 @@ import ( "go.yaml.in/yaml/v4" ) +// ArazzoDocumentConfiguration supplies immutable parsing and origin context. +type ArazzoDocumentConfiguration struct { + Context gocontext.Context + RetrievalURI string + ApplicationBaseURI string + + // Logger receives debug detail about how the document identity and effective + // base URI were derived. Origin resolution weighs $self, the retrieval URI and + // the application base URI against each other, so the outcome is worth tracing + // when a relative source URL resolves somewhere unexpected. Nil disables logging. + Logger *slog.Logger +} + // NewArazzoDocument parses raw bytes into a high-level Arazzo document. func NewArazzoDocument(arazzoBytes []byte) (*high.Arazzo, error) { + return NewArazzoDocumentWithConfiguration(arazzoBytes, nil) +} + +// NewArazzoDocumentWithConfiguration parses an Arazzo document with retrieval and base-URI context. +func NewArazzoDocumentWithConfiguration( + arazzoBytes []byte, + configuration *ArazzoDocumentConfiguration, +) (*high.Arazzo, error) { + config := ArazzoDocumentConfiguration{} + if configuration != nil { + config = *configuration + } var rootNode yaml.Node if err := yaml.Unmarshal(arazzoBytes, &rootNode); err != nil { return nil, fmt.Errorf("failed to parse YAML: %w", err) @@ -35,12 +61,35 @@ func NewArazzoDocument(arazzoBytes []byte) (*high.Arazzo, error) { return nil, fmt.Errorf("failed to build low-level model: %w", err) } - ctx := gocontext.Background() + ctx := config.Context + if ctx == nil { + ctx = gocontext.Background() + } if err := lowDoc.Build(ctx, nil, mappingNode, nil); err != nil { return nil, fmt.Errorf("failed to build arazzo document: %w", err) } + origin, err := high.ResolveDocumentOrigin( + lowDoc.Self.Value, + config.RetrievalURI, + config.ApplicationBaseURI, + ) + if err != nil { + return nil, fmt.Errorf("failed to resolve arazzo document origin: %w", err) + } + + // ResolveDocumentOrigin returns a non-nil origin on every non-error path, so only + // the logger itself needs guarding. + if config.Logger != nil { + config.Logger.DebugContext(ctx, "resolved arazzo document origin", + "authoredSelf", origin.AuthoredSelf, + "resolvedIdentity", origin.ResolvedIdentity, + "retrievalURI", origin.RetrievalURI, + "applicationBaseURI", origin.ApplicationBaseURI, + "effectiveBaseURI", origin.EffectiveBaseURI) + } + // Build the high-level model - highDoc := high.NewArazzo(lowDoc) + highDoc := high.NewArazzoWithOrigin(lowDoc, origin) return highDoc, nil } diff --git a/arazzo/arazzo_benchmark_test.go b/arazzo/arazzo_benchmark_test.go new file mode 100644 index 000000000..cda2cd757 --- /dev/null +++ b/arazzo/arazzo_benchmark_test.go @@ -0,0 +1,84 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "fmt" + "strings" + "testing" + + libopenapi "github.com/pb33f/libopenapi" +) + +func largeArazzoDocument(stepCount, outputCount int) []byte { + var source strings.Builder + source.Grow(256 + stepCount*80 + outputCount*120) + source.WriteString(`arazzo: 1.1.0 +info: + title: large benchmark + version: 1.0.0 +sourceDescriptions: + - name: api + url: openapi.yaml + type: openapi +workflows: + - workflowId: benchmark + steps: +`) + for index := range stepCount { + fmt.Fprintf( + &source, + " - stepId: step-%d\n operationId: operation-%d\n", + index, + index, + ) + if index == 0 && outputCount > 0 { + source.WriteString(" outputs:\n") + for outputIndex := range outputCount { + fmt.Fprintf( + &source, + " output-%d:\n context: $response.body\n selector: $.items[%d]\n type: jsonpath\n", + outputIndex, + outputIndex, + ) + } + } + } + return []byte(source.String()) +} + +func BenchmarkNewLargeArazzoDocument(b *testing.B) { + source := largeArazzoDocument(1_000, 0) + b.ReportAllocs() + b.SetBytes(int64(len(source))) + b.ResetTimer() + for range b.N { + document, err := libopenapi.NewArazzoDocument(source) + if err != nil { + b.Fatal(err) + } + if len(document.Workflows) != 1 || + len(document.Workflows[0].Steps) != 1_000 { + b.Fatal("large Arazzo document was not fully parsed") + } + } +} + +func BenchmarkRenderLargeArazzoSelectorMap(b *testing.B) { + document, err := libopenapi.NewArazzoDocument(largeArazzoDocument(1, 1_000)) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + rendered, renderErr := document.Render() + if renderErr != nil { + b.Fatal(renderErr) + } + if len(rendered) == 0 { + b.Fatal("large Arazzo render was empty") + } + } +} diff --git a/arazzo/coverage_test.go b/arazzo/coverage_test.go index f54a4af9f..214646d72 100644 --- a/arazzo/coverage_test.go +++ b/arazzo/coverage_test.go @@ -1617,7 +1617,7 @@ func TestValidateSourceURL_FileSchemeSkipsHostCheck(t *testing.T) { func TestFetchSourceBytes_UnsupportedScheme(t *testing.T) { u := mustParseURL("ftp://example.com/api.yaml") config := &ResolveConfig{MaxBodySize: 10 * 1024 * 1024} - _, _, err := fetchSourceBytes(u, config) + _, _, err := fetchSourceBytes(context.Background(), u, config) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported source scheme") } @@ -1631,7 +1631,7 @@ func TestFetchSourceBytes_HTTP(t *testing.T) { u := mustParseURL(server.URL + "/api.yaml") config := &ResolveConfig{MaxBodySize: 1024, Timeout: 5e9} - b, resolvedURL, err := fetchSourceBytes(u, config) + b, resolvedURL, err := fetchSourceBytes(context.Background(), u, config) require.NoError(t, err) assert.Equal(t, "http-content", string(b)) assert.Contains(t, resolvedURL, server.URL) @@ -1644,7 +1644,7 @@ func TestFetchSourceBytes_File(t *testing.T) { u := &url.URL{Scheme: "file", Path: filepath.ToSlash(filePath)} config := &ResolveConfig{MaxBodySize: 1024} - b, resolvedURL, err := fetchSourceBytes(u, config) + b, resolvedURL, err := fetchSourceBytes(context.Background(), u, config) require.NoError(t, err) assert.Equal(t, "file-content", string(b)) assert.Contains(t, resolvedURL, "file://") @@ -1653,7 +1653,7 @@ func TestFetchSourceBytes_File(t *testing.T) { func TestFetchSourceBytes_FileError(t *testing.T) { u := mustParseURL("file:///nonexistent/path/file.yaml") config := &ResolveConfig{MaxBodySize: 1024} - _, _, err := fetchSourceBytes(u, config) + _, _, err := fetchSourceBytes(context.Background(), u, config) require.Error(t, err) } @@ -1665,7 +1665,7 @@ func TestFetchSourceBytes_HTTPError(t *testing.T) { u := mustParseURL(server.URL + "/api.yaml") config := &ResolveConfig{MaxBodySize: 1024, Timeout: 5e9} - _, _, err := fetchSourceBytes(u, config) + _, _, err := fetchSourceBytes(context.Background(), u, config) require.Error(t, err) } @@ -1680,7 +1680,7 @@ func TestFetchHTTPSourceBytes_CustomHandler(t *testing.T) { return []byte("response body"), nil }, } - b, err := fetchHTTPSourceBytes("https://example.com/api.yaml", config) + b, err := fetchHTTPSourceBytes(context.Background(), "https://example.com/api.yaml", config) require.NoError(t, err) assert.Equal(t, "response body", string(b)) } @@ -1692,7 +1692,7 @@ func TestFetchHTTPSourceBytes_CustomHandler_ExceedsMax(t *testing.T) { return []byte("this is too long"), nil }, } - _, err := fetchHTTPSourceBytes("https://example.com/api.yaml", config) + _, err := fetchHTTPSourceBytes(context.Background(), "https://example.com/api.yaml", config) require.Error(t, err) assert.Contains(t, err.Error(), "exceeds max size") } @@ -1704,7 +1704,7 @@ func TestFetchHTTPSourceBytes_CustomHandler_Error(t *testing.T) { return nil, fmt.Errorf("handler error") }, } - _, err := fetchHTTPSourceBytes("https://example.com/api.yaml", config) + _, err := fetchHTTPSourceBytes(context.Background(), "https://example.com/api.yaml", config) require.Error(t, err) assert.Contains(t, err.Error(), "handler error") } @@ -1720,7 +1720,7 @@ func TestFetchHTTPSourceBytes_RealHTTP_Success(t *testing.T) { MaxBodySize: 1024, Timeout: 5e9, // 5 seconds } - b, err := fetchHTTPSourceBytes(server.URL, config) + b, err := fetchHTTPSourceBytes(context.Background(), server.URL, config) require.NoError(t, err) assert.Equal(t, "openapi: 3.1.0", string(b)) } @@ -1735,7 +1735,7 @@ func TestFetchHTTPSourceBytes_RealHTTP_StatusError(t *testing.T) { MaxBodySize: 1024, Timeout: 5e9, } - _, err := fetchHTTPSourceBytes(server.URL, config) + _, err := fetchHTTPSourceBytes(context.Background(), server.URL, config) require.Error(t, err) assert.Contains(t, err.Error(), "unexpected status code 500") } @@ -1751,7 +1751,7 @@ func TestFetchHTTPSourceBytes_RealHTTP_BodyExceedsMax(t *testing.T) { MaxBodySize: 5, Timeout: 5e9, } - _, err := fetchHTTPSourceBytes(server.URL, config) + _, err := fetchHTTPSourceBytes(context.Background(), server.URL, config) require.Error(t, err) assert.Contains(t, err.Error(), "exceeds max size") } @@ -1845,6 +1845,30 @@ func TestResolveFilePath_AbsoluteInsideRoots(t *testing.T) { assert.Equal(t, path, resolved) } +// TestResolveFilePath_AbsoluteSymlinkEvaluationFails covers the second root check for +// absolute paths. The first check canonicalizes via EvalSymlinks and skips the result +// when that call fails, so a path that is lexically inside a root passes it. Descending +// through a regular file makes EvalSymlinks fail with ENOTDIR rather than ErrNotExist, +// which ensureResolvedPathWithinRoots surfaces instead of treating as a missing file. +func TestResolveFilePath_AbsoluteSymlinkEvaluationFails(t *testing.T) { + // Resolve the temp dir up front. On macOS t.TempDir() sits under /var, which is + // itself a symlink to /private/var; leaving it unresolved would trip the first + // containment check instead of reaching the branch under test. + tmpDir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + + regularFile := filepath.Join(tmpDir, "not-a-directory.yaml") + require.NoError(t, os.WriteFile(regularFile, []byte("content"), 0o600)) + + // Lexically inside tmpDir, but traverses through a regular file. + path := filepath.Join(regularFile, "child.yaml") + + _, err = resolveFilePath(path, []string{tmpDir}) + require.Error(t, err) + assert.NotContains(t, err.Error(), "outside configured roots", + "expected the raw EvalSymlinks failure, not the containment rejection") +} + // --------------------------------------------------------------------------- // isPathWithinRoots // --------------------------------------------------------------------------- diff --git a/arazzo/criterion.go b/arazzo/criterion.go index c78ec75e5..6a34d014d 100644 --- a/arazzo/criterion.go +++ b/arazzo/criterion.go @@ -25,6 +25,13 @@ type cachedCriterionJSONPath struct { err error } +type criterionJSONPathDialect string + +const ( + criterionJSONPathRFC9535 criterionJSONPathDialect = "rfc9535" + criterionJSONPathLegacy criterionJSONPathDialect = "draft-goessner-dispatch-jsonpath-00" +) + // criterionCaches holds per-Engine caches for compiled criterion patterns. // Using plain maps instead of sync.Map because Engine is not safe for concurrent use. type criterionCaches struct { @@ -280,7 +287,11 @@ func evaluateJSONPathCriterion(criterion *high.Criterion, exprCtx *expression.Co return false, fmt.Errorf("failed to evaluate context expression: %w", err) } - path, err := compileCriterionJSONPath(criterion.Condition, caches) + dialect := criterionJSONPathRFC9535 + if criterion.ExpressionType != nil && criterion.ExpressionType.Version != "" { + dialect = criterionJSONPathDialect(criterion.ExpressionType.Version) + } + path, err := compileCriterionJSONPath(criterion.Condition, dialect, caches) if err != nil { return false, fmt.Errorf("invalid jsonpath %q: %w", criterion.Condition, err) } @@ -309,15 +320,33 @@ func compileCriterionRegex(raw string, caches *criterionCaches) (*regexp.Regexp, return re, err } -func compileCriterionJSONPath(raw string, caches *criterionCaches) (*jsonpath.JSONPath, error) { +func compileCriterionJSONPath( + raw string, + dialect criterionJSONPathDialect, + caches *criterionCaches, +) (*jsonpath.JSONPath, error) { + cacheKey := string(dialect) + "\x00" + raw if caches != nil { - if cached, ok := caches.jsonPath[raw]; ok { + if cached, ok := caches.jsonPath[cacheKey]; ok { return cached.path, cached.err } } - path, err := jsonpath.NewPath(raw, jsonpathconfig.WithPropertyNameExtension(), jsonpathconfig.WithLazyContextTracking()) + var path *jsonpath.JSONPath + var err error + switch dialect { + case criterionJSONPathRFC9535: + path, err = jsonpath.NewPath( + raw, + jsonpathconfig.WithStrictRFC9535(), + jsonpathconfig.WithLazyContextTracking(), + ) + case criterionJSONPathLegacy: + err = fmt.Errorf("%w: %s", ErrUnsupportedExpressionDialect, dialect) + default: + err = fmt.Errorf("unknown JSONPath dialect %q", dialect) + } if caches != nil { - caches.jsonPath[raw] = cachedCriterionJSONPath{path: path, err: err} + caches.jsonPath[cacheKey] = cachedCriterionJSONPath{path: path, err: err} } return path, err } diff --git a/arazzo/engine.go b/arazzo/engine.go index ada2e6e47..cda3be1be 100644 --- a/arazzo/engine.go +++ b/arazzo/engine.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/pb33f/libopenapi/arazzo/expression" @@ -62,18 +63,34 @@ type EngineConfig struct { // Engine orchestrates the execution of Arazzo workflows. // An Engine is NOT safe for concurrent use from multiple goroutines. type Engine struct { - document *high.Arazzo - executor Executor - sources map[string]*ResolvedSource - defaultSource *ResolvedSource // cached for single-source fast path - sourceOrder []string // deterministic source ordering from document - workflows map[string]*high.Workflow - config *EngineConfig + document *high.Arazzo + executor Executor + sources map[string]*ResolvedSource + defaultSource *ResolvedSource // cached for single-source fast path + sourceOrder []string // deterministic source ordering from document + workflows map[string]*high.Workflow + config *EngineConfig + // exprCache is keyed by expression text alone. That is safe only because + // exprVersion is written once in NewEngine and never mutated, so one engine + // always parses under a single grammar. Making the version mutable would + // require adding it to the cache key. exprCache map[string]expression.Expression + exprVersion expression.SpecVersion // grammar selected from the document's arazzo version criterionCaches *criterionCaches cachedComponents *expression.ComponentsContext // immutable component maps, built once } +// expressionVersionForDocument selects the runtime-expression grammar matching the +// document's declared Arazzo version. A 1.0 document may use the general +// "$components." name production, which the 1.1 grammar no longer permits; anything +// that is not 1.0.x (including an absent version) parses under 1.1. +func expressionVersionForDocument(doc *high.Arazzo) expression.SpecVersion { + if doc != nil && strings.HasPrefix(doc.Arazzo, "1.0.") { + return expression.Arazzo10 + } + return expression.Arazzo11 +} + // NewEngine creates a new Engine for executing Arazzo workflows. func NewEngine(doc *high.Arazzo, executor Executor, sources []*ResolvedSource) *Engine { sourceMap := make(map[string]*ResolvedSource, len(sources)) @@ -121,6 +138,7 @@ func NewEngine(doc *high.Arazzo, executor Executor, sources []*ResolvedSource) * workflows: workflowMap, config: &EngineConfig{}, exprCache: make(map[string]expression.Expression), + exprVersion: expressionVersionForDocument(doc), criterionCaches: newCriterionCaches(), } e.criterionCaches.parseExpr = e.parseExpression @@ -146,6 +164,9 @@ func (e *Engine) ClearCaches() { // RunWorkflow executes a single workflow by its ID. func (e *Engine) RunWorkflow(ctx context.Context, workflowId string, inputs map[string]any) (*WorkflowResult, error) { + if err := e.preflightExecution(workflowId); err != nil { + return nil, err + } state := &executionState{ workflowResults: make(map[string]*WorkflowResult), workflowContexts: make(map[string]*expression.WorkflowContext), @@ -158,6 +179,9 @@ func (e *Engine) RunWorkflow(ctx context.Context, workflowId string, inputs map[ // RunAll executes all workflows in dependency order. func (e *Engine) RunAll(ctx context.Context, inputs map[string]map[string]any) (*RunResult, error) { + if err := e.preflightExecution(); err != nil { + return nil, err + } start := time.Now() result := &RunResult{ Success: true, @@ -194,14 +218,14 @@ func (e *Engine) RunAll(ctx context.Context, inputs map[string]map[string]any) ( } } - wfInputs := inputs[wfId] - wfResult, execErr := e.runWorkflow(ctx, wfId, wfInputs, state) - if failedResult := workflowExecutionFailureResult(wfId, wfInputs, execErr); failedResult != nil { - result.Success = false - state.workflowResults[wfId] = failedResult - result.Workflows = append(result.Workflows, failedResult) - continue - } + wfInputs := inputs[wfId] + wfResult, execErr := e.runWorkflow(ctx, wfId, wfInputs, state) + if failedResult := workflowExecutionFailureResult(wfId, wfInputs, execErr); failedResult != nil { + result.Success = false + state.workflowResults[wfId] = failedResult + result.Workflows = append(result.Workflows, failedResult) + continue + } result.Workflows = append(result.Workflows, wfResult) if !wfResult.Success { result.Success = false @@ -212,6 +236,173 @@ func (e *Engine) RunAll(ctx context.Context, inputs map[string]map[string]any) ( return result, nil } +// preflightExecution rejects modeled 1.1 features whose execution semantics are +// deferred before any workflow step can invoke the external executor. With no +// roots it checks every workflow; otherwise it follows workflow-targeting steps +// from the selected root. +func (e *Engine) preflightExecution(roots ...string) error { + if e == nil { + return nil + } + if len(roots) == 0 { + roots = make([]string, 0, len(e.workflows)) + if e.document != nil { + for _, workflow := range e.document.Workflows { + if workflow != nil { + roots = append(roots, workflow.WorkflowId) + } + } + } + } + seen := make(map[string]struct{}, len(e.workflows)) + var visit func(string) error + unsupported := func(workflowId, stepId, field string) error { + return &UnsupportedExecutionFeatureError{ + WorkflowId: workflowId, + StepId: stepId, + Field: field, + } + } + hasSelectorParameter := func(parameters []*high.Parameter) bool { + for _, parameter := range parameters { + if parameter != nil && parameter.IsSelector() { + return true + } + } + return false + } + // An action whose reusable reference cannot be resolved is skipped rather than + // reported. Reference integrity belongs to validation, and a broken onFailure + // reference is never resolved unless the step actually fails. + collectSuccessTargets := func(workflowId, stepId string, actions []*high.SuccessAction, targets *[]string) error { + for _, action := range actions { + resolved, err := e.resolveSuccessAction(action) + if err != nil || resolved == nil { + continue + } + if len(resolved.Parameters) > 0 { + return unsupported(workflowId, stepId, "successAction.parameters") + } + if resolved.Type == "goto" && resolved.WorkflowId != "" { + *targets = append(*targets, resolved.WorkflowId) + } + } + return nil + } + collectFailureTargets := func(workflowId, stepId string, actions []*high.FailureAction, targets *[]string) error { + for _, action := range actions { + resolved, err := e.resolveFailureAction(action) + if err != nil || resolved == nil { + continue + } + if len(resolved.Parameters) > 0 { + return unsupported(workflowId, stepId, "failureAction.parameters") + } + if resolved.Type == "goto" && resolved.WorkflowId != "" { + *targets = append(*targets, resolved.WorkflowId) + } + } + return nil + } + visit = func(workflowId string) error { + if _, ok := seen[workflowId]; ok { + return nil + } + seen[workflowId] = struct{}{} + workflow := e.workflows[workflowId] + if workflow == nil { + return nil + } + if workflow.Outputs != nil { + for name, output := range workflow.Outputs.FromOldest() { + if output != nil && output.IsSelector() { + return &UnsupportedSelectorOutputError{ + WorkflowId: workflowId, + OutputName: name, + } + } + } + } + if hasSelectorParameter(workflow.Parameters) { + return unsupported(workflowId, "", "parameters.value") + } + var targets []string + if err := collectSuccessTargets(workflowId, "", workflow.SuccessActions, &targets); err != nil { + return err + } + if err := collectFailureTargets(workflowId, "", workflow.FailureActions, &targets); err != nil { + return err + } + for _, step := range workflow.Steps { + if step == nil { + continue + } + if step.Outputs != nil { + for name, output := range step.Outputs.FromOldest() { + if output != nil && output.IsSelector() { + return &UnsupportedSelectorOutputError{ + WorkflowId: workflowId, + StepId: step.StepId, + OutputName: name, + } + } + } + } + switch { + case step.ChannelPath != "": + return unsupported(workflowId, step.StepId, "channelPath") + case step.Action != "": + return unsupported(workflowId, step.StepId, "action") + case step.CorrelationId != "": + return unsupported(workflowId, step.StepId, "correlationId") + case step.Timeout != nil: + return unsupported(workflowId, step.StepId, "timeout") + case len(step.DependsOn) > 0: + return unsupported(workflowId, step.StepId, "dependsOn") + case hasSelectorParameter(step.Parameters): + return unsupported(workflowId, step.StepId, "parameters.value") + } + if step.RequestBody != nil { + if len(step.RequestBody.GetSelectors()) > 0 { + return unsupported(workflowId, step.StepId, "requestBody.payload") + } + for _, replacement := range step.RequestBody.Replacements { + if replacement == nil { + continue + } + if replacement.TargetSelectorType != "" || replacement.TargetSelectorExpressionType != nil { + return unsupported(workflowId, step.StepId, "requestBody.replacements.targetSelectorType") + } + if len(replacement.GetSelectors()) > 0 { + return unsupported(workflowId, step.StepId, "requestBody.replacements.value") + } + } + } + if err := collectSuccessTargets(workflowId, step.StepId, step.OnSuccess, &targets); err != nil { + return err + } + if err := collectFailureTargets(workflowId, step.StepId, step.OnFailure, &targets); err != nil { + return err + } + if step.WorkflowId != "" { + targets = append(targets, step.WorkflowId) + } + } + for _, target := range targets { + if err := visit(target); err != nil { + return err + } + } + return nil + } + for _, root := range roots { + if err := visit(root); err != nil { + return err + } + } + return nil +} + type executionState struct { workflowResults map[string]*WorkflowResult workflowContexts map[string]*expression.WorkflowContext @@ -312,20 +503,20 @@ func (e *Engine) runWorkflow(ctx context.Context, workflowId string, inputs map[ } continue } - if actionResult.endWorkflow { - result.Success = false - result.Error = stepFailureOrDefault(step.StepId, stepResult.Error) - break - } + if actionResult.endWorkflow { + result.Success = false + result.Error = stepFailureOrDefault(step.StepId, stepResult.Error) + break + } if actionResult.jumpToStepIdx >= 0 { stepIdx = actionResult.jumpToStepIdx continue } - result.Success = false - result.Error = stepFailureOrDefault(step.StepId, stepResult.Error) - break - } + result.Success = false + result.Error = stepFailureOrDefault(step.StepId, stepResult.Error) + break + } if result.Success { if err := e.populateWorkflowOutputs(wf, result, exprCtx); err != nil { result.Success = false @@ -456,7 +647,7 @@ func (e *Engine) parseExpression(input string) (expression.Expression, error) { if cached, ok := e.exprCache[input]; ok { return cached, nil } - expr, err := expression.Parse(input) + expr, err := expression.ParseWithVersion(input, e.exprVersion) if err != nil { return expression.Expression{}, err } @@ -501,8 +692,16 @@ func (e *Engine) newExpressionContext(inputs map[string]any, state *executionSta Workflows: copyWorkflowContexts(state.workflowContexts), SourceDescs: make(map[string]*expression.SourceDescContext), } + if e.document != nil { + ctx.Self = e.document.Self + if e.document.Self != "" { + if origin := e.document.GetDocumentOrigin(); origin != nil && origin.ResolvedIdentity != "" { + ctx.Self = origin.ResolvedIdentity + } + } + } for name, source := range e.sources { - ctx.SourceDescs[name] = &expression.SourceDescContext{URL: source.URL} + ctx.SourceDescs[name] = &expression.SourceDescContext{URL: source.URL, Type: source.Type} } if e.cachedComponents != nil { components := &expression.ComponentsContext{ diff --git a/arazzo/engine_coverage_test.go b/arazzo/engine_coverage_test.go index a08f7a06a..4b8437452 100644 --- a/arazzo/engine_coverage_test.go +++ b/arazzo/engine_coverage_test.go @@ -843,9 +843,9 @@ func TestEvaluateStringValue_EmbeddedWithLiteralBracesBeforeExpression(t *testin Inputs: map[string]any{"id": "abc-123"}, } - val, err := engine.evaluateStringValue("literal {brace} {$inputs.id}", exprCtx) - require.NoError(t, err) - assert.Equal(t, "literal {brace} abc-123", val) + _, err := engine.evaluateStringValue("literal {brace} {$inputs.id}", exprCtx) + require.Error(t, err) + assert.Contains(t, err.Error(), "literal opening brace") } func TestEvaluateStringValue_EmbeddedParseError(t *testing.T) { @@ -898,32 +898,32 @@ func TestPopulateStepOutputs_NilOutputs(t *testing.T) { result := &StepResult{Outputs: make(map[string]any)} exprCtx := &expression.Context{} - err := engine.populateStepOutputs(step, result, exprCtx) + err := engine.populateStepOutputs(step, nil, result, exprCtx) require.NoError(t, err) } func TestPopulateStepOutputs_EmptyOutputs(t *testing.T) { doc := &high.Arazzo{Arazzo: "1.0.1"} engine := NewEngine(doc, nil, nil) - outputs := orderedmap.New[string, string]() + outputs := orderedmap.New[string, *high.OutputValue]() step := &high.Step{StepId: "s1", Outputs: outputs} result := &StepResult{Outputs: make(map[string]any)} exprCtx := &expression.Context{} - err := engine.populateStepOutputs(step, result, exprCtx) + err := engine.populateStepOutputs(step, nil, result, exprCtx) require.NoError(t, err) } func TestPopulateStepOutputs_ValidOutputs(t *testing.T) { doc := &high.Arazzo{Arazzo: "1.0.1"} engine := NewEngine(doc, nil, nil) - outputs := orderedmap.New[string, string]() - outputs.Set("statusResult", "$statusCode") + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("statusResult", high.NewExpressionOutputValue("$statusCode")) step := &high.Step{StepId: "s1", Outputs: outputs} result := &StepResult{Outputs: make(map[string]any)} exprCtx := &expression.Context{StatusCode: 201} - err := engine.populateStepOutputs(step, result, exprCtx) + err := engine.populateStepOutputs(step, nil, result, exprCtx) require.NoError(t, err) assert.Equal(t, 201, result.Outputs["statusResult"]) } @@ -931,13 +931,13 @@ func TestPopulateStepOutputs_ValidOutputs(t *testing.T) { func TestPopulateStepOutputs_EvalError(t *testing.T) { doc := &high.Arazzo{Arazzo: "1.0.1"} engine := NewEngine(doc, nil, nil) - outputs := orderedmap.New[string, string]() - outputs.Set("badOutput", "$inputs.missing") + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("badOutput", high.NewExpressionOutputValue("$inputs.missing")) step := &high.Step{StepId: "s1", Outputs: outputs} result := &StepResult{Outputs: make(map[string]any)} exprCtx := &expression.Context{} - err := engine.populateStepOutputs(step, result, exprCtx) + err := engine.populateStepOutputs(step, nil, result, exprCtx) require.Error(t, err) assert.Contains(t, err.Error(), "failed to evaluate output") } @@ -960,7 +960,7 @@ func TestPopulateWorkflowOutputs_NilOutputs(t *testing.T) { func TestPopulateWorkflowOutputs_EmptyOutputs(t *testing.T) { doc := &high.Arazzo{Arazzo: "1.0.1"} engine := NewEngine(doc, nil, nil) - outputs := orderedmap.New[string, string]() + outputs := orderedmap.New[string, *high.OutputValue]() wf := &high.Workflow{WorkflowId: "wf1", Outputs: outputs} result := &WorkflowResult{Outputs: make(map[string]any)} exprCtx := &expression.Context{Outputs: make(map[string]any)} @@ -972,8 +972,8 @@ func TestPopulateWorkflowOutputs_EmptyOutputs(t *testing.T) { func TestPopulateWorkflowOutputs_ValidOutputs(t *testing.T) { doc := &high.Arazzo{Arazzo: "1.0.1"} engine := NewEngine(doc, nil, nil) - outputs := orderedmap.New[string, string]() - outputs.Set("finalStatus", "$statusCode") + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("finalStatus", high.NewExpressionOutputValue("$statusCode")) wf := &high.Workflow{WorkflowId: "wf1", Outputs: outputs} result := &WorkflowResult{Outputs: make(map[string]any)} exprCtx := &expression.Context{StatusCode: 200, Outputs: make(map[string]any)} @@ -987,8 +987,8 @@ func TestPopulateWorkflowOutputs_ValidOutputs(t *testing.T) { func TestPopulateWorkflowOutputs_EvalError(t *testing.T) { doc := &high.Arazzo{Arazzo: "1.0.1"} engine := NewEngine(doc, nil, nil) - outputs := orderedmap.New[string, string]() - outputs.Set("bad", "$inputs.missing") + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("bad", high.NewExpressionOutputValue("$inputs.missing")) wf := &high.Workflow{WorkflowId: "wf1", Outputs: outputs} result := &WorkflowResult{Outputs: make(map[string]any)} exprCtx := &expression.Context{Outputs: make(map[string]any)} @@ -1272,8 +1272,8 @@ func TestExecuteStep_SubWorkflowFailsNoError(t *testing.T) { // =========================================================================== func TestRunWorkflow_PopulateWorkflowOutputsError(t *testing.T) { - outputs := orderedmap.New[string, string]() - outputs.Set("badRef", "$inputs.nonexistent") + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("badRef", high.NewExpressionOutputValue("$inputs.nonexistent")) doc := &high.Arazzo{ Workflows: []*high.Workflow{ { @@ -1301,8 +1301,8 @@ func TestRunWorkflow_PopulateWorkflowOutputsError(t *testing.T) { // =========================================================================== func TestExecuteStep_PopulateStepOutputsError(t *testing.T) { - stepOutputs := orderedmap.New[string, string]() - stepOutputs.Set("badRef", "$inputs.nonexistent") + stepOutputs := orderedmap.New[string, *high.OutputValue]() + stepOutputs.Set("badRef", high.NewExpressionOutputValue("$inputs.nonexistent")) doc := &high.Arazzo{ Workflows: []*high.Workflow{ { @@ -1707,7 +1707,7 @@ func TestResolveSources_FetchFails(t *testing.T) { func TestFetchSourceBytes_UnsupportedScheme_Coverage(t *testing.T) { config := &ResolveConfig{MaxBodySize: 10 * 1024 * 1024} u, _ := parseAndResolveSourceURL("ftp://example.com/file", "") - _, _, err := fetchSourceBytes(u, config) + _, _, err := fetchSourceBytes(context.Background(), u, config) assert.Error(t, err) assert.Contains(t, err.Error(), "unsupported source scheme") } @@ -1724,7 +1724,7 @@ func TestFetchHTTPSourceBytes_HandlerOversized(t *testing.T) { return []byte("toolongbody"), nil }, } - _, err := fetchHTTPSourceBytes("https://example.com", config) + _, err := fetchHTTPSourceBytes(context.Background(), "https://example.com", config) assert.Error(t, err) assert.Contains(t, err.Error(), "exceeds max size") } @@ -1776,7 +1776,7 @@ func TestFetchSourceBytes_FileSchemeSuccess(t *testing.T) { fileURL := (&url.URL{Scheme: "file", Path: filepath.ToSlash(testFile)}).String() u, err := parseAndResolveSourceURL(fileURL, "") require.NoError(t, err) - data, resolvedURL, err := fetchSourceBytes(u, config) + data, resolvedURL, err := fetchSourceBytes(context.Background(), u, config) assert.NoError(t, err) assert.Equal(t, []byte("openapi: 3.0.0"), data) assert.Contains(t, resolvedURL, "spec.yaml") @@ -1797,7 +1797,7 @@ func TestFetchHTTPSourceBytes_RealHTTPSuccess_WithServer(t *testing.T) { Timeout: 30 * 1000 * 1000 * 1000, // 30 seconds in nanoseconds (time.Duration) MaxBodySize: 10 * 1024 * 1024, } - data, err := fetchHTTPSourceBytes(srv.URL, config) + data, err := fetchHTTPSourceBytes(context.Background(), srv.URL, config) assert.NoError(t, err) assert.Equal(t, []byte("openapi: 3.0.0"), data) } @@ -1871,11 +1871,11 @@ func TestEngine_FullIntegration_ExpressionParams(t *testing.T) { // =========================================================================== func TestEngine_FullIntegration_StepAndWorkflowOutputs(t *testing.T) { - stepOutputs := orderedmap.New[string, string]() - stepOutputs.Set("status", "$statusCode") + stepOutputs := orderedmap.New[string, *high.OutputValue]() + stepOutputs.Set("status", high.NewExpressionOutputValue("$statusCode")) - wfOutputs := orderedmap.New[string, string]() - wfOutputs.Set("result", "$steps.s1.outputs.status") + wfOutputs := orderedmap.New[string, *high.OutputValue]() + wfOutputs.Set("result", high.NewExpressionOutputValue("$steps.s1.outputs.status")) doc := &high.Arazzo{ Arazzo: "1.0.1", @@ -1979,8 +1979,8 @@ func TestEngine_TopologicalSort_UnknownDependsOnSkipped(t *testing.T) { // =========================================================================== func TestEngine_FullIntegration_ResponseBodyExpressions(t *testing.T) { - stepOutputs := orderedmap.New[string, string]() - stepOutputs.Set("petName", "$response.body#/name") + stepOutputs := orderedmap.New[string, *high.OutputValue]() + stepOutputs.Set("petName", high.NewExpressionOutputValue("$response.body#/name")) doc := &high.Arazzo{ Arazzo: "1.0.1", @@ -2080,7 +2080,7 @@ func TestFetchSourceBytes_WindowsDriveInHost(t *testing.T) { MaxBodySize: 10 * 1024 * 1024, FSRoots: []string{resolvedTmpDir}, } - data, _, err := fetchSourceBytes(fakeURL, config) + data, _, err := fetchSourceBytes(context.Background(), fakeURL, config) assert.NoError(t, err) assert.Equal(t, []byte("openapi: 3.0.0"), data) } else { @@ -2090,7 +2090,7 @@ func TestFetchSourceBytes_WindowsDriveInHost(t *testing.T) { MaxBodySize: 10 * 1024 * 1024, FSRoots: []string{"/fake"}, } - _, _, err := fetchSourceBytes(synthURL, config) + _, _, err := fetchSourceBytes(context.Background(), synthURL, config) // Will fail to find the file, but the drive letter reconstruction branch is hit assert.Error(t, err) } diff --git a/arazzo/engine_test.go b/arazzo/engine_test.go index 4dfb3c326..6be167640 100644 --- a/arazzo/engine_test.go +++ b/arazzo/engine_test.go @@ -355,10 +355,10 @@ func TestEngine_RunWorkflow_ExposesNestedWorkflowInputsViaWorkflowsContext(t *te } func TestEngine_RunWorkflow_EvaluatesStepAndWorkflowOutputs(t *testing.T) { - stepOutputs := orderedmap.New[string, string]() - stepOutputs.Set("petId", "$response.body#/id") - workflowOutputs := orderedmap.New[string, string]() - workflowOutputs.Set("createdPetId", "$steps.s1.outputs.petId") + stepOutputs := orderedmap.New[string, *high.OutputValue]() + stepOutputs.Set("petId", high.NewExpressionOutputValue("$response.body#/id")) + workflowOutputs := orderedmap.New[string, *high.OutputValue]() + workflowOutputs.Set("createdPetId", high.NewExpressionOutputValue("$steps.s1.outputs.petId")) doc := &high.Arazzo{ Workflows: []*high.Workflow{ diff --git a/arazzo/errors.go b/arazzo/errors.go index b97370377..bbcec8004 100644 --- a/arazzo/errors.go +++ b/arazzo/errors.go @@ -30,17 +30,66 @@ var ( // Step errors var ( - ErrMissingStepId = errors.New("missing required 'stepId'") - ErrDuplicateStepId = errors.New("duplicate stepId within workflow") - ErrStepMutualExclusion = errors.New("step must have exactly one of operationId, operationPath, or workflowId") - ErrExecutorNotConfigured = errors.New("executor is not configured") + ErrMissingStepId = errors.New("missing required 'stepId'") + ErrDuplicateStepId = errors.New("duplicate stepId within workflow") + ErrStepMutualExclusion = errors.New("step must have exactly one of operationId, operationPath, or workflowId") + ErrExecutorNotConfigured = errors.New("executor is not configured") + ErrUnsupportedSelectorOutput = errors.New("selector outputs are not supported by the execution engine") + ErrUnsupportedExecutionFeature = errors.New("Arazzo 1.1 execution feature is not supported by the execution engine") ) +// UnsupportedExecutionFeatureError identifies a modeled Arazzo 1.1 feature +// whose execution semantics are intentionally deferred. +type UnsupportedExecutionFeatureError struct { + WorkflowId string + StepId string + Field string +} + +// Error returns stable workflow, step, and field context. +func (e *UnsupportedExecutionFeatureError) Error() string { + if e.StepId != "" { + return fmt.Sprintf("%s: workflow %q step %q field %q", + ErrUnsupportedExecutionFeature, e.WorkflowId, e.StepId, e.Field) + } + return fmt.Sprintf("%s: workflow %q field %q", + ErrUnsupportedExecutionFeature, e.WorkflowId, e.Field) +} + +// Unwrap exposes ErrUnsupportedExecutionFeature for errors.Is. +func (e *UnsupportedExecutionFeatureError) Unwrap() error { + return ErrUnsupportedExecutionFeature +} + +// UnsupportedSelectorOutputError identifies a 1.1 Selector Object output that the +// current execution engine cannot evaluate safely. +type UnsupportedSelectorOutputError struct { + WorkflowId string + StepId string + OutputName string +} + +// Error returns stable workflow, step, and output context. +func (e *UnsupportedSelectorOutputError) Error() string { + if e.StepId != "" { + return fmt.Sprintf("%s: workflow %q step %q output %q", + ErrUnsupportedSelectorOutput, e.WorkflowId, e.StepId, e.OutputName) + } + return fmt.Sprintf("%s: workflow %q output %q", + ErrUnsupportedSelectorOutput, e.WorkflowId, e.OutputName) +} + +// Unwrap exposes ErrUnsupportedSelectorOutput for errors.Is. +func (e *UnsupportedSelectorOutputError) Unwrap() error { + return ErrUnsupportedSelectorOutput +} + // Parameter errors var ( ErrMissingParameterName = errors.New("missing required 'name'") ErrMissingParameterIn = errors.New("missing required 'in' for operation parameter") ErrInvalidParameterIn = errors.New("'in' must be path, query, header, or cookie") + ErrParameterInNotAllowed = errors.New("'in' is not permitted for workflow or action parameters") ErrMissingParameterValue = errors.New("missing required 'value'") ) @@ -62,8 +111,9 @@ var ( // Expression errors var ( - ErrInvalidExpression = errors.New("invalid runtime expression") - ErrUnknownExpressionPrefix = errors.New("unknown expression prefix") + ErrInvalidExpression = errors.New("invalid runtime expression") + ErrUnknownExpressionPrefix = errors.New("unknown expression prefix") + ErrUnsupportedExpressionDialect = errors.New("expression dialect evaluation is not supported") ) // Reference errors diff --git a/arazzo/execution_preflight_test.go b/arazzo/execution_preflight_test.go new file mode 100644 index 000000000..9453461a0 --- /dev/null +++ b/arazzo/execution_preflight_test.go @@ -0,0 +1,438 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "testing" + + "github.com/pb33f/libopenapi/arazzo/expression" + high "github.com/pb33f/libopenapi/datamodel/high/arazzo" + low "github.com/pb33f/libopenapi/datamodel/low/arazzo" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +func selectorOutputs(name string) *orderedmap.Map[string, *high.OutputValue] { + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set(name, high.NewSelectorOutputValue(&high.Selector{ + Context: "$response.body", + Selector: "$.id", + Type: "jsonpath", + })) + return outputs +} + +func TestEngine_RuntimeSelfUsesResolvedDocumentIdentity(t *testing.T) { + document := high.NewArazzoWithOrigin(&low.Arazzo{}, &high.DocumentOrigin{ + AuthoredSelf: "workflows/purchase.arazzo.yaml", + ResolvedIdentity: "https://api.example.com/v2/workflows/purchase.arazzo.yaml", + }) + document.Self = "workflows/purchase.arazzo.yaml" + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("identity", high.NewExpressionOutputValue("$self")) + document.Workflows = []*high.Workflow{{WorkflowId: "purchase", Outputs: outputs}} + + result, err := NewEngine(document, nil, nil).RunWorkflow(context.Background(), "purchase", nil) + require.NoError(t, err) + assert.Equal(t, "https://api.example.com/v2/workflows/purchase.arazzo.yaml", result.Outputs["identity"]) +} + +func TestEngine_RunWorkflowPreflightsStepSelectorOutput(t *testing.T) { + executor := &recordingExecutor{} + document := &high.Arazzo{ + Workflows: []*high.Workflow{{ + WorkflowId: "root", + Steps: []*high.Step{{ + StepId: "call", + OperationId: "get", + Outputs: selectorOutputs("selected"), + }}, + }}, + } + engine := NewEngine(document, executor, nil) + result, err := engine.RunWorkflow(context.Background(), "root", nil) + assert.Nil(t, result) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedSelectorOutput) + var typed *UnsupportedSelectorOutputError + require.ErrorAs(t, err, &typed) + assert.Equal(t, "root", typed.WorkflowId) + assert.Equal(t, "call", typed.StepId) + assert.Equal(t, "selected", typed.OutputName) + assert.Equal(t, + `selector outputs are not supported by the execution engine: workflow "root" step "call" output "selected"`, + typed.Error()) + assert.Empty(t, executor.operationIDs) +} + +func TestEngine_RunWorkflowPreflightsWorkflowSelectorOutput(t *testing.T) { + executor := &recordingExecutor{} + document := &high.Arazzo{ + Workflows: []*high.Workflow{{ + WorkflowId: "root", + Steps: []*high.Step{{StepId: "call", OperationId: "get"}}, + Outputs: selectorOutputs("selected"), + }}, + } + engine := NewEngine(document, executor, nil) + _, err := engine.RunWorkflow(context.Background(), "root", nil) + require.Error(t, err) + assert.Equal(t, + `selector outputs are not supported by the execution engine: workflow "root" output "selected"`, + err.Error()) + assert.Empty(t, executor.operationIDs) +} + +func TestEngine_RunWorkflowPreflightsReachableNestedWorkflow(t *testing.T) { + executor := &recordingExecutor{} + document := &high.Arazzo{ + Workflows: []*high.Workflow{ + { + WorkflowId: "root", + Steps: []*high.Step{{StepId: "nested", WorkflowId: "child"}}, + }, + { + WorkflowId: "child", + Steps: []*high.Step{{ + StepId: "call", + OperationId: "get", + Outputs: selectorOutputs("selected"), + }}, + }, + }, + } + engine := NewEngine(document, executor, nil) + _, err := engine.RunWorkflow(context.Background(), "root", nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedSelectorOutput) + assert.Empty(t, executor.operationIDs) +} + +func TestEngine_RunWorkflowPreflightsActionTargetWorkflows(t *testing.T) { + t.Run("direct success action", func(t *testing.T) { + executor := &recordingExecutor{} + document := &high.Arazzo{ + Workflows: []*high.Workflow{ + { + WorkflowId: "root", + Steps: []*high.Step{{ + StepId: "call", + OperationId: "get", + OnSuccess: []*high.SuccessAction{{ + Name: "child", + Type: "goto", + WorkflowId: "child", + }}, + }}, + }, + { + WorkflowId: "child", + Steps: []*high.Step{{StepId: "child-call", OperationId: "get"}}, + Outputs: selectorOutputs("selected"), + }, + }, + } + engine := NewEngine(document, executor, nil) + _, err := engine.RunWorkflow(context.Background(), "root", nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedSelectorOutput) + assert.Empty(t, executor.operationIDs) + }) + + t.Run("reusable failure action", func(t *testing.T) { + executor := &recordingExecutor{} + actions := orderedmap.New[string, *high.FailureAction]() + actions.Set("child", &high.FailureAction{ + Name: "child", + Type: "goto", + WorkflowId: "child", + }) + document := &high.Arazzo{ + Components: &high.Components{FailureActions: actions}, + Workflows: []*high.Workflow{ + { + WorkflowId: "root", + Steps: []*high.Step{{ + StepId: "call", + OperationId: "get", + OnFailure: []*high.FailureAction{{ + Reference: "$components.failureActions.child", + }}, + }}, + }, + { + WorkflowId: "child", + Steps: []*high.Step{{StepId: "child-call", OperationId: "get"}}, + Outputs: selectorOutputs("selected"), + }, + }, + } + engine := NewEngine(document, executor, nil) + _, err := engine.RunWorkflow(context.Background(), "root", nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedSelectorOutput) + assert.Empty(t, executor.operationIDs) + }) + + // Preflight looks for selector outputs, not broken references. An action whose reusable + // reference cannot be resolved is skipped: it contributes no reachable workflow, and it + // must not stop a document that would otherwise run. A dangling onFailure reference in + // particular is never consulted at all unless the step actually fails. + t.Run("unresolvable reusable action does not block the run", func(t *testing.T) { + tests := []struct { + name string + configure func(*high.Workflow, *high.Step) + }{ + { + name: "step success action", + configure: func(_ *high.Workflow, step *high.Step) { + step.OnSuccess = []*high.SuccessAction{{ + Reference: "$components.successActions.missing", + }} + }, + }, + { + name: "step failure action", + configure: func(_ *high.Workflow, step *high.Step) { + step.OnFailure = []*high.FailureAction{{ + Reference: "$components.failureActions.missing", + }} + }, + }, + { + name: "workflow success action", + configure: func(workflow *high.Workflow, _ *high.Step) { + workflow.SuccessActions = []*high.SuccessAction{{ + Reference: "$components.successActions.missing", + }} + }, + }, + { + name: "workflow failure action", + configure: func(workflow *high.Workflow, _ *high.Step) { + workflow.FailureActions = []*high.FailureAction{{ + Reference: "$components.failureActions.missing", + }} + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + executor := &recordingExecutor{} + step := &high.Step{StepId: "call", OperationId: "get"} + workflow := &high.Workflow{WorkflowId: "root", Steps: []*high.Step{step}} + test.configure(workflow, step) + engine := NewEngine(&high.Arazzo{Workflows: []*high.Workflow{workflow}}, executor, nil) + _, err := engine.RunWorkflow(context.Background(), "root", nil) + + // Whatever happens once execution starts, preflight must not have + // short-circuited it: the step has to have reached the executor. + assert.Equal(t, []string{"get"}, executor.operationIDs, + "preflight must not reject a document over an unresolvable action reference") + assert.NotErrorIs(t, err, ErrUnsupportedSelectorOutput, + "there is no selector output here") + }) + } + }) +} + +func TestEngine_RunAllPreflightsAllWorkflows(t *testing.T) { + executor := &recordingExecutor{} + document := &high.Arazzo{ + Workflows: []*high.Workflow{ + {WorkflowId: "safe", Steps: []*high.Step{{StepId: "safe-call", OperationId: "get"}}}, + {WorkflowId: "unsupported", Steps: []*high.Step{{StepId: "bad", OperationId: "get"}}, Outputs: selectorOutputs("selected")}, + }, + } + engine := NewEngine(document, executor, nil) + result, err := engine.RunAll(context.Background(), nil) + assert.Nil(t, result) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedSelectorOutput) + assert.Empty(t, executor.operationIDs) +} + +func TestEngine_PreflightExecutionBoundaries(t *testing.T) { + var engine *Engine + assert.NoError(t, engine.preflightExecution()) + + engine = NewEngine(nil, nil, nil) + assert.NoError(t, engine.preflightExecution()) + assert.NoError(t, engine.preflightExecution("missing")) + + document := &high.Arazzo{ + Workflows: []*high.Workflow{{ + WorkflowId: "cycle", + Steps: []*high.Step{nil, {StepId: "self", WorkflowId: "cycle"}}, + }}, + } + engine = NewEngine(document, nil, nil) + assert.NoError(t, engine.preflightExecution("cycle")) +} + +func selectorValueNode() *yaml.Node { + return &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "context"}, {Kind: yaml.ScalarNode, Value: "$response.body"}, + {Kind: yaml.ScalarNode, Value: "selector"}, {Kind: yaml.ScalarNode, Value: "$.id"}, + {Kind: yaml.ScalarNode, Value: "type"}, {Kind: yaml.ScalarNode, Value: "jsonpath"}, + }} +} + +func TestEngine_PreflightsDeferredExecutionFeaturesBeforeExecutor(t *testing.T) { + timeout := int64(1000) + tests := []struct { + name string + field string + configure func(*high.Workflow, *high.Step) + }{ + {name: "channel step", field: "channelPath", configure: func(_ *high.Workflow, step *high.Step) { + step.OperationId = "" + step.ChannelPath = "/orders" + step.Action = "send" + }}, + {name: "correlation id", field: "correlationId", configure: func(_ *high.Workflow, step *high.Step) { + step.CorrelationId = "$message.header.X-Correlation-ID" + }}, + {name: "timeout", field: "timeout", configure: func(_ *high.Workflow, step *high.Step) { + step.Timeout = &timeout + }}, + {name: "step dependency", field: "dependsOn", configure: func(_ *high.Workflow, step *high.Step) { + step.DependsOn = []string{"earlier"} + }}, + {name: "selector parameter", field: "parameters.value", configure: func(_ *high.Workflow, step *high.Step) { + step.Parameters = []*high.Parameter{{Name: "id", In: "query", Value: selectorValueNode()}} + }}, + {name: "selector payload", field: "requestBody.payload", configure: func(_ *high.Workflow, step *high.Step) { + step.RequestBody = &high.RequestBody{Payload: selectorValueNode()} + }}, + {name: "aliased selector payload", field: "requestBody.payload", configure: func(_ *high.Workflow, step *high.Step) { + step.RequestBody = &high.RequestBody{Payload: &yaml.Node{Kind: yaml.AliasNode, Alias: selectorValueNode()}} + }}, + {name: "selector replacement target", field: "requestBody.replacements.targetSelectorType", configure: func(_ *high.Workflow, step *high.Step) { + step.RequestBody = &high.RequestBody{Replacements: []*high.PayloadReplacement{{TargetSelectorType: "jsonpath"}}} + }}, + {name: "action parameters", field: "successAction.parameters", configure: func(_ *high.Workflow, step *high.Step) { + step.OnSuccess = []*high.SuccessAction{{Name: "end", Type: "end", Parameters: []*high.Parameter{{Name: "id"}}}} + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + executor := &recordingExecutor{} + step := &high.Step{StepId: "call", OperationId: "get"} + workflow := &high.Workflow{WorkflowId: "root", Steps: []*high.Step{step}} + test.configure(workflow, step) + engine := NewEngine(&high.Arazzo{Arazzo: "1.1.0", Workflows: []*high.Workflow{workflow}}, executor, nil) + + result, err := engine.RunWorkflow(context.Background(), "root", nil) + assert.Nil(t, result) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedExecutionFeature) + var typed *UnsupportedExecutionFeatureError + require.ErrorAs(t, err, &typed) + assert.Equal(t, test.field, typed.Field) + assert.Empty(t, executor.operationIDs) + }) + } +} + +func TestEngine_PopulateOutputsRejectsInvalidUnionDefensively(t *testing.T) { + engine := NewEngine(nil, nil, nil) + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("invalid", nil) + + stepResult := &StepResult{Outputs: make(map[string]any)} + err := engine.populateStepOutputs( + &high.Step{StepId: "step", Outputs: outputs}, + &high.Workflow{WorkflowId: "workflow"}, + stepResult, + &expression.Context{}, + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnsupportedSelectorOutput)) + assert.Empty(t, stepResult.Outputs) + + workflowResult := &WorkflowResult{Outputs: make(map[string]any)} + err = engine.populateWorkflowOutputs( + &high.Workflow{WorkflowId: "workflow", Outputs: outputs}, + workflowResult, + &expression.Context{Outputs: make(map[string]any)}, + ) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedSelectorOutput) + assert.Empty(t, workflowResult.Outputs) +} + +// A selector output reached through the direct populate path (rather than preflight) +// must still name the workflow it belongs to, so callers can locate the offending output. +func TestEngine_StepSelectorOutputErrorIdentifiesWorkflow(t *testing.T) { + engine := NewEngine(nil, nil, nil) + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("selectorOutput", nil) + + err := engine.populateStepOutputs( + &high.Step{StepId: "step-one", Outputs: outputs}, + &high.Workflow{WorkflowId: "workflow-one"}, + &StepResult{Outputs: make(map[string]any)}, + &expression.Context{}, + ) + require.Error(t, err) + + var unsupported *UnsupportedSelectorOutputError + require.True(t, errors.As(err, &unsupported)) + assert.Equal(t, "workflow-one", unsupported.WorkflowId) + assert.Equal(t, "step-one", unsupported.StepId) + assert.Equal(t, "selectorOutput", unsupported.OutputName) +} + +// Outputs are committed only after all of them evaluate. An expression that fails partway +// through must leave no partially populated map behind, because a step's outputs are +// published into the expression context and would otherwise be visible to later steps. +func TestEngine_StepOutputsAreNotPartiallyPopulatedOnEvaluationFailure(t *testing.T) { + engine := NewEngine(nil, nil, nil) + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("good", high.NewExpressionOutputValue("$statusCode")) + outputs.Set("bad", high.NewExpressionOutputValue("$steps.nonexistent.outputs.missing")) + + stepResult := &StepResult{Outputs: make(map[string]any)} + err := engine.populateStepOutputs( + &high.Step{StepId: "step", Outputs: outputs}, + &high.Workflow{WorkflowId: "workflow"}, + stepResult, + &expression.Context{StatusCode: 200, Steps: map[string]*expression.StepContext{}}, + ) + require.Error(t, err) + assert.Empty(t, stepResult.Outputs, + "the earlier successful output must not be published when a later one fails") +} + +// The same atomicity applies to workflow outputs, which are written into both the result +// and the expression context. +func TestEngine_WorkflowOutputsAreNotPartiallyPopulatedOnEvaluationFailure(t *testing.T) { + engine := NewEngine(nil, nil, nil) + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("good", high.NewExpressionOutputValue("$statusCode")) + outputs.Set("bad", high.NewExpressionOutputValue("$steps.nonexistent.outputs.missing")) + + workflowResult := &WorkflowResult{Outputs: make(map[string]any)} + exprCtx := &expression.Context{ + StatusCode: 200, + Steps: map[string]*expression.StepContext{}, + Outputs: make(map[string]any), + } + + err := engine.populateWorkflowOutputs( + &high.Workflow{WorkflowId: "workflow", Outputs: outputs}, + workflowResult, + exprCtx, + ) + require.Error(t, err) + assert.Empty(t, workflowResult.Outputs) + assert.Empty(t, exprCtx.Outputs, + "a failed workflow output set must not leak into the expression context") +} diff --git a/arazzo/expression/conformance_test.go b/arazzo/expression/conformance_test.go new file mode 100644 index 000000000..e9ec3c187 --- /dev/null +++ b/arazzo/expression/conformance_test.go @@ -0,0 +1,245 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package expression + +import ( + "errors" + "strings" + "testing" + + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +func TestParseArazzo11ConformanceCorpus(t *testing.T) { + tests := []struct { + input string + exprType ExpressionType + name string + property string + tail string + jsonPointer string + }{ + {"$self", Self, "", "", "", ""}, + {"$request.payload#/items/0", RequestPayload, "", "", "", "/items/0"}, + {"$response.payload", ResponsePayload, "", "", "", ""}, + {"$message.header.X-Correlation-ID", MessageHeader, "", "X-Correlation-ID", "", ""}, + {"$message.query.", MessageQuery, "", "", "", ""}, + {"$message.path.correlation", MessagePath, "", "correlation", "", ""}, + {"$message.body#/event", MessageBody, "", "", "", "/event"}, + {"$message.payload#/order~1id", MessagePayload, "", "", "", "/order~1id"}, + {"$inputs.user.profile#/name", Inputs, "user.profile", "", "", "/name"}, + {"$outputs.result#/", Outputs, "result", "", "", "/"}, + {"$steps.create-order.outputs.order.id#/value", Steps, "create-order", "", "outputs.order.id", "/value"}, + {"$workflows.checkout.inputs.user.id#/value", Workflows, "checkout", "", "inputs.user.id", "/value"}, + {"$workflows.checkout.outputs.receipt#/items/0", Workflows, "checkout", "", "outputs.receipt", "/items/0"}, + {"$sourceDescriptions.petstore.get/pet.by.id", SourceDescriptions, "petstore", "", "get/pet.by.id", ""}, + {`$sourceDescriptions.petstore.get\u0020pet`, SourceDescriptions, "petstore", "", `get\u0020pet`, ""}, + {"$components.parameters.rate.limit", ComponentParameters, "rate.limit", "", "", ""}, + {"$components.successActions.retry", ComponentSuccessActions, "retry", "", "", ""}, + {"$components.failureActions.abort.now", ComponentFailureActions, "abort.now", "", "", ""}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + expr, err := Parse(test.input) + require.NoError(t, err) + assert.Equal(t, test.exprType, expr.Type) + assert.Equal(t, test.input, expr.Raw) + assert.Equal(t, test.name, expr.Name) + assert.Equal(t, test.property, expr.Property) + assert.Equal(t, test.tail, expr.Tail) + assert.Equal(t, test.jsonPointer, expr.JSONPointer) + }) + } +} + +func TestParseArazzo11ConformanceBoundaries(t *testing.T) { + tests := []string{ + "$self.value", + "$message.header.", + "$message.header.Bad Header", + "$message.payload#not-a-pointer", + "$request.query.{bad}", + "$request.path.\"bad\"", + "$response.payload#/bad~2escape", + "$response.body#/bad{brace}", + "$inputs.bad+name", + "$inputs.value#relative", + "$outputs.\xff", + "$steps.step.with-dot.outputs.value", + "$steps.step.outputs.bad+output", + "$steps.step.outputs.value#relative", + "$workflows.flow.with-dot.inputs.value", + "$workflows.flow.parameters.value", + "$workflows.flow.outputs.bad+name", + "$sourceDescriptions.bad+name.operation", + "$sourceDescriptions.source.", + "$sourceDescriptions.source.bad{reference}", + `$sourceDescriptions.source.bad"reference`, + `$sourceDescriptions.source.bad\`, + `$sourceDescriptions.source.bad\q`, + `$sourceDescriptions.source.bad\u123`, + `$sourceDescriptions.source.bad\u12xz`, + "$sourceDescriptions.source.bad\x01reference", + "$components.inputs.value", + "$components.parameters.bad+name", + "$components.successActions.", + } + for _, input := range tests { + t.Run(input, func(t *testing.T) { + _, err := Parse(input) + assert.Error(t, err) + }) + } +} + +func TestParseArazzo11HelpersBoundaries(t *testing.T) { + assert.Error(t, validateCharSequence("", false)) + assert.NoError(t, validateCharSequence("", true)) + assert.NoError(t, validateCharSequence(`escaped\/value`, false)) + assert.Error(t, validateCharSequence("\xff", false)) + assert.Error(t, validateJSONPointer("/\xff")) + assert.Error(t, validateJSONPointer("relative")) + assert.NoError(t, validateJSONPointer("/valid/~0/~1")) + assert.Error(t, validateJSONPointer("/trailing~")) + assert.Error(t, validateJSONPointer("/brace}")) + assert.False(t, isIdentifier("é", false)) + assert.False(t, isHex4("123")) + assert.False(t, isHex4("12xz")) + assert.True(t, isHex4("09aF")) +} + +func TestParseEmbeddedArazzo11Boundaries(t *testing.T) { + _, err := ParseEmbedded("\xff") + assert.Error(t, err) + + _, err = ParseEmbedded("literal}") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected closing brace") + + _, err = ParseEmbedded("literal} then {$self}") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected closing brace") +} + +func TestValidateHasZeroAllocationsOnValidExpression(t *testing.T) { + allocations := testing.AllocsPerRun(1000, func() { + if err := Validate("$steps.create.outputs.order#/id"); err != nil { + panic(err) + } + }) + assert.Equal(t, float64(0), allocations) +} + +func TestResolveLocalArazzo11(t *testing.T) { + symbols := &LocalSymbols{ + HasSelf: true, + Inputs: SymbolSet{"input": {}, "exact.nested": {}}, + Outputs: SymbolSet{"output": {}}, + Steps: map[string]StepSymbols{"step": {Outputs: SymbolSet{"result": {}}}}, + Workflows: map[string]WorkflowSymbols{"flow": {Inputs: SymbolSet{"input": {}}, Outputs: SymbolSet{"output": {}}}}, + SourceDescriptions: SymbolSet{"source": {}}, + ComponentParameters: SymbolSet{"parameter": {}}, + ComponentSuccessActions: SymbolSet{"success": {}}, + ComponentFailureActions: SymbolSet{"failure": {}}, + } + valid := []string{ + "$self", + "$inputs.input", + "$inputs.input.child", + "$inputs.exact.nested", + "$outputs.output", + "$outputs.output.child", + "$steps.step.outputs.result", + "$steps.step.outputs.result.child", + "$workflows.flow.inputs.input", + "$workflows.flow.inputs.input.child", + "$workflows.flow.outputs.output", + "$workflows.flow.outputs.output.child", + "$sourceDescriptions.source.operation", + "$components.parameters.parameter", + "$components.successActions.success", + "$components.failureActions.failure", + "$response.body#/id", + } + for _, input := range valid { + assert.NoError(t, ValidateLocal(input, symbols), input) + } + + assert.Error(t, ResolveLocal(Expression{}, nil)) + assert.Error(t, ValidateLocal("not-an-expression", symbols)) + + missing := []Expression{ + {Raw: "$self", Type: Self}, + {Raw: "$inputs.missing", Type: Inputs, Name: "missing"}, + {Raw: "$outputs.missing", Type: Outputs, Name: "missing"}, + {Raw: "$steps.missing.outputs.result", Type: Steps, Name: "missing", Tail: "outputs.result"}, + {Raw: "$steps.step", Type: Steps, Name: "step"}, + {Raw: "$steps.step.inputs.value", Type: Steps, Name: "step", Tail: "inputs.value"}, + {Raw: "$steps.step.outputs.missing", Type: Steps, Name: "step", Tail: "outputs.missing"}, + {Raw: "$workflows.missing.inputs.input", Type: Workflows, Name: "missing", Tail: "inputs.input"}, + {Raw: "$workflows.flow", Type: Workflows, Name: "flow"}, + {Raw: "$workflows.flow.parameters.input", Type: Workflows, Name: "flow", Tail: "parameters.input"}, + {Raw: "$workflows.flow.inputs.missing", Type: Workflows, Name: "flow", Tail: "inputs.missing"}, + {Raw: "$workflows.flow.outputs.missing", Type: Workflows, Name: "flow", Tail: "outputs.missing"}, + {Raw: "$sourceDescriptions.missing.op", Type: SourceDescriptions, Name: "missing"}, + {Raw: "$components.parameters.missing", Type: ComponentParameters, Name: "missing"}, + {Raw: "$components.successActions.missing", Type: ComponentSuccessActions, Name: "missing"}, + {Raw: "$components.failureActions.missing", Type: ComponentFailureActions, Name: "missing"}, + } + missingSymbols := *symbols + missingSymbols.HasSelf = false + for _, expr := range missing { + err := ResolveLocal(expr, &missingSymbols) + require.Error(t, err, expr.Raw) + assert.True(t, errors.Is(err, ErrLocalSymbolNotFound)) + var symbolErr *LocalSymbolError + require.True(t, errors.As(err, &symbolErr)) + assert.NotEmpty(t, symbolErr.Error()) + assert.Equal(t, ErrLocalSymbolNotFound, symbolErr.Unwrap()) + } + + assert.Equal(t, []any{"", "", false}, tailParts("missing")) + assert.Equal(t, []any{"", "", false}, tailParts("outputs.")) + assert.Equal(t, []any{"outputs", "value", true}, tailParts("outputs.value")) + assert.False(t, hasSymbolOrPrefix(SymbolSet{"other": {}}, "missing.child")) +} + +func tailParts(tail string) []any { + field, name, ok := splitReferenceTail(tail) + return []any{field, name, ok} +} + +func FuzzParseArazzo11NeverPanics(f *testing.F) { + for _, seed := range []string{ + "$self", + "$message.payload#/id", + "$steps.step.outputs.value#/0", + "https://{$inputs.host}/{$outputs.path}", + "{", + "}", + "\xff", + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, input string) { + _, _ = Parse(input) + _, _ = ParseEmbedded(input) + }) +} + +func BenchmarkValidateRuntimeExpression(b *testing.B) { + for b.Loop() { + _ = Validate("$steps.create.outputs.order#/items/0/id") + } +} + +func BenchmarkParseEmbeddedRuntimeExpressions(b *testing.B) { + input := strings.Repeat("prefix {$inputs.value} suffix ", 32) + b.ResetTimer() + for b.Loop() { + _, _ = ParseEmbedded(input) + } +} diff --git a/arazzo/expression/evaluator.go b/arazzo/expression/evaluator.go index 59bb0047d..9a750456b 100644 --- a/arazzo/expression/evaluator.go +++ b/arazzo/expression/evaluator.go @@ -13,6 +13,7 @@ import ( // Context holds runtime values for expression evaluation. type Context struct { + Self string URL string Method string StatusCode int @@ -20,8 +21,17 @@ type Context struct { RequestQuery map[string]string RequestPath map[string]string RequestBody *yaml.Node + RequestPayload *yaml.Node ResponseHeaders map[string]string + ResponseQuery map[string]string + ResponsePath map[string]string ResponseBody *yaml.Node + ResponsePayload *yaml.Node + MessageHeaders map[string]string + MessageQuery map[string]string + MessagePath map[string]string + MessageBody *yaml.Node + MessagePayload *yaml.Node Inputs map[string]any Outputs map[string]any Steps map[string]*StepContext @@ -44,7 +54,11 @@ type WorkflowContext struct { // SourceDescContext holds resolved source description data. type SourceDescContext struct { - URL string + URL string + Type string + Operations map[string]any + Workflows map[string]any + Fields map[string]any } // ComponentsContext holds resolved component data. @@ -62,6 +76,11 @@ func Evaluate(expr Expression, ctx *Context) (any, error) { } switch expr.Type { + case Self: + if ctx.Self == "" { + return nil, fmt.Errorf("no self URI available") + } + return ctx.Self, nil case URL: return ctx.URL, nil case Method: @@ -100,13 +119,10 @@ func Evaluate(expr Expression, ctx *Context) (any, error) { return v, nil case RequestBody: - if ctx.RequestBody == nil { - return nil, fmt.Errorf("no request body available") - } - if expr.JSONPointer == "" { - return ctx.RequestBody, nil - } - return resolveJSONPointer(ctx.RequestBody, expr.JSONPointer) + return resolveStructuredSource(ctx.RequestBody, expr.JSONPointer, "request body") + + case RequestPayload: + return resolveStructuredSource(ctx.RequestPayload, expr.JSONPointer, "request payload") case ResponseHeader: if ctx.ResponseHeaders == nil { @@ -119,39 +135,92 @@ func Evaluate(expr Expression, ctx *Context) (any, error) { return v, nil case ResponseQuery: - return nil, fmt.Errorf("response query parameters are not supported") + if ctx.ResponseQuery == nil { + return nil, fmt.Errorf("no response query parameters available") + } + v, ok := ctx.ResponseQuery[expr.Property] + if !ok { + return nil, fmt.Errorf("response query parameter %q not found", expr.Property) + } + return v, nil case ResponsePath: - return nil, fmt.Errorf("response path parameters are not supported") + if ctx.ResponsePath == nil { + return nil, fmt.Errorf("no response path parameters available") + } + v, ok := ctx.ResponsePath[expr.Property] + if !ok { + return nil, fmt.Errorf("response path parameter %q not found", expr.Property) + } + return v, nil case ResponseBody: - if ctx.ResponseBody == nil { - return nil, fmt.Errorf("no response body available") + return resolveStructuredSource(ctx.ResponseBody, expr.JSONPointer, "response body") + + case ResponsePayload: + return resolveStructuredSource(ctx.ResponsePayload, expr.JSONPointer, "response payload") + + case MessageHeader: + if ctx.MessageHeaders == nil { + return nil, fmt.Errorf("no message headers available") } - if expr.JSONPointer == "" { - return ctx.ResponseBody, nil + v, ok := ctx.MessageHeaders[expr.Property] + if !ok { + return nil, fmt.Errorf("message header %q not found", expr.Property) + } + return v, nil + + case MessageQuery: + if ctx.MessageQuery == nil { + return nil, fmt.Errorf("no message query parameters available") } - return resolveJSONPointer(ctx.ResponseBody, expr.JSONPointer) + v, ok := ctx.MessageQuery[expr.Property] + if !ok { + return nil, fmt.Errorf("message query parameter %q not found", expr.Property) + } + return v, nil + + case MessagePath: + if ctx.MessagePath == nil { + return nil, fmt.Errorf("no message path parameters available") + } + v, ok := ctx.MessagePath[expr.Property] + if !ok { + return nil, fmt.Errorf("message path parameter %q not found", expr.Property) + } + return v, nil + + case MessageBody: + return resolveStructuredSource(ctx.MessageBody, expr.JSONPointer, "message body") + + case MessagePayload: + return resolveStructuredSource(ctx.MessagePayload, expr.JSONPointer, "message payload") case Inputs: if ctx.Inputs == nil { return nil, fmt.Errorf("no inputs available") } - v, ok := ctx.Inputs[expr.Name] + v, ok, err := resolveNamedValue(ctx.Inputs, expr.Name) + if err != nil { + return nil, fmt.Errorf("input %q: %w", expr.Name, err) + } if !ok { return nil, fmt.Errorf("input %q not found", expr.Name) } - return v, nil + return resolveValuePointer(v, expr.JSONPointer) case Outputs: if ctx.Outputs == nil { return nil, fmt.Errorf("no outputs available") } - v, ok := ctx.Outputs[expr.Name] + v, ok, err := resolveNamedValue(ctx.Outputs, expr.Name) + if err != nil { + return nil, fmt.Errorf("output %q: %w", expr.Name, err) + } if !ok { return nil, fmt.Errorf("output %q not found", expr.Name) } - return v, nil + return resolveValuePointer(v, expr.JSONPointer) case Steps: return resolveSteps(expr, ctx) @@ -166,14 +235,13 @@ func Evaluate(expr Expression, ctx *Context) (any, error) { return resolveComponents(expr, ctx) case ComponentParameters: - if ctx.Components == nil || ctx.Components.Parameters == nil { - return nil, fmt.Errorf("no component parameters available") - } - v, ok := ctx.Components.Parameters[expr.Name] - if !ok { - return nil, fmt.Errorf("component parameter %q not found", expr.Name) - } - return v, nil + return resolveComponentValue(ctx.Components, expr.Name, "parameter") + + case ComponentSuccessActions: + return resolveComponentValue(ctx.Components, expr.Name, "success action") + + case ComponentFailureActions: + return resolveComponentValue(ctx.Components, expr.Name, "failure action") default: return nil, fmt.Errorf("unsupported expression type: %d", expr.Type) @@ -189,6 +257,44 @@ func EvaluateString(input string, ctx *Context) (any, error) { return Evaluate(expr, ctx) } +func resolveStructuredSource(node *yaml.Node, pointer, label string) (any, error) { + if node == nil { + return nil, fmt.Errorf("no %s available", label) + } + if pointer == "" { + return node, nil + } + if pointer[0] != '/' { + return nil, fmt.Errorf("JSON pointer must start with '/'") + } + return resolveJSONPointer(node, pointer) +} + +func resolveComponentValue(components *ComponentsContext, name, componentType string) (any, error) { + if components == nil { + return nil, fmt.Errorf("no component %ss available", componentType) + } + var values map[string]any + switch componentType { + case "parameter": + values = components.Parameters + case "success action": + values = components.SuccessActions + case "failure action": + values = components.FailureActions + default: + return nil, fmt.Errorf("unsupported component type %q", componentType) + } + if values == nil { + return nil, fmt.Errorf("no component %ss available", componentType) + } + value, ok := values[name] + if !ok { + return nil, fmt.Errorf("component %s %q not found", componentType, name) + } + return value, nil +} + func resolveSteps(expr Expression, ctx *Context) (any, error) { if ctx.Steps == nil { return nil, fmt.Errorf("no steps context available") @@ -200,7 +306,7 @@ func resolveSteps(expr Expression, ctx *Context) (any, error) { if expr.Tail == "" { return sc, nil } - return resolveStepTail(expr.Tail, sc, expr.Name) + return resolveStepTail(expr.Tail, sc, expr.Name, expr.JSONPointer) } func splitTail(tail string) (segment, rest string) { @@ -211,7 +317,7 @@ func splitTail(tail string) (segment, rest string) { } } -func resolveStepTail(tail string, sc *StepContext, stepName string) (any, error) { +func resolveStepTail(tail string, sc *StepContext, stepName, pointer string) (any, error) { segment, rest := splitTail(tail) switch segment { @@ -222,11 +328,14 @@ func resolveStepTail(tail string, sc *StepContext, stepName string) (any, error) if rest == "" { return sc.Outputs, nil } - v, ok := sc.Outputs[rest] + v, ok, err := resolveNamedValue(sc.Outputs, rest) + if err != nil { + return nil, fmt.Errorf("step %q output %q: %w", stepName, rest, err) + } if !ok { return nil, fmt.Errorf("step %q output %q not found", stepName, rest) } - return v, nil + return resolveValuePointer(v, pointer) case "inputs": if sc.Inputs == nil { return nil, fmt.Errorf("step %q has no inputs", stepName) @@ -234,11 +343,14 @@ func resolveStepTail(tail string, sc *StepContext, stepName string) (any, error) if rest == "" { return sc.Inputs, nil } - v, ok := sc.Inputs[rest] + v, ok, err := resolveNamedValue(sc.Inputs, rest) + if err != nil { + return nil, fmt.Errorf("step %q input %q: %w", stepName, rest, err) + } if !ok { return nil, fmt.Errorf("step %q input %q not found", stepName, rest) } - return v, nil + return resolveValuePointer(v, pointer) default: return nil, fmt.Errorf("unknown step property %q for step %q", segment, stepName) } @@ -266,11 +378,14 @@ func resolveWorkflows(expr Expression, ctx *Context) (any, error) { if rest == "" { return wc.Outputs, nil } - v, ok := wc.Outputs[rest] + v, ok, err := resolveNamedValue(wc.Outputs, rest) + if err != nil { + return nil, fmt.Errorf("workflow %q output %q: %w", expr.Name, rest, err) + } if !ok { return nil, fmt.Errorf("workflow %q output %q not found", expr.Name, rest) } - return v, nil + return resolveValuePointer(v, expr.JSONPointer) case "inputs": if wc.Inputs == nil { return nil, fmt.Errorf("workflow %q has no inputs", expr.Name) @@ -278,11 +393,14 @@ func resolveWorkflows(expr Expression, ctx *Context) (any, error) { if rest == "" { return wc.Inputs, nil } - v, ok := wc.Inputs[rest] + v, ok, err := resolveNamedValue(wc.Inputs, rest) + if err != nil { + return nil, fmt.Errorf("workflow %q input %q: %w", expr.Name, rest, err) + } if !ok { return nil, fmt.Errorf("workflow %q input %q not found", expr.Name, rest) } - return v, nil + return resolveValuePointer(v, expr.JSONPointer) default: return nil, fmt.Errorf("unknown workflow property %q for workflow %q", segment, expr.Name) } @@ -299,8 +417,20 @@ func resolveSourceDescriptions(expr Expression, ctx *Context) (any, error) { if expr.Tail == "" { return sd, nil } - if expr.Tail == "url" { + if value, found := sd.Operations[expr.Tail]; found { + return value, nil + } + if value, found := sd.Workflows[expr.Tail]; found { + return value, nil + } + if value, found := sd.Fields[expr.Tail]; found { + return value, nil + } + switch expr.Tail { + case "url": return sd.URL, nil + case "type": + return sd.Type, nil } return nil, fmt.Errorf("unknown source description property %q for %q", expr.Tail, expr.Name) } @@ -363,7 +493,7 @@ func resolveComponents(expr Expression, ctx *Context) (any, error) { // resolveJSONPointer navigates a yaml.Node tree using a JSON Pointer (RFC 6901). // The pointer should start with "/" (the leading "#" has already been stripped). func resolveJSONPointer(node *yaml.Node, pointer string) (any, error) { - if pointer == "" || pointer == "/" { + if pointer == "" { return node, nil } @@ -373,25 +503,8 @@ func resolveJSONPointer(node *yaml.Node, pointer string) (any, error) { current = current.Content[0] } - pos := 0 - if pointer[0] == '/' { - pos = 1 - } - - for pos < len(pointer) { - // Find next segment boundary - nextSlash := strings.IndexByte(pointer[pos:], '/') - var segment string - if nextSlash == -1 { - segment = pointer[pos:] - pos = len(pointer) - } else { - segment = pointer[pos : pos+nextSlash] - pos = pos + nextSlash + 1 - } - - // Unescape JSON Pointer: ~1 -> /, ~0 -> ~ - segment = UnescapeJSONPointer(segment) + for _, rawSegment := range strings.Split(pointer[1:], "/") { + segment := UnescapeJSONPointer(rawSegment) switch current.Kind { case yaml.MappingNode: @@ -425,6 +538,87 @@ func resolveJSONPointer(node *yaml.Node, pointer string) (any, error) { return yamlNodeToValue(current), nil } +func resolveValuePointer(value any, pointer string) (any, error) { + if pointer == "" { + return value, nil + } + if pointer[0] != '/' { + return nil, fmt.Errorf("JSON pointer must start with '/'") + } + if node, ok := value.(*yaml.Node); ok { + return resolveJSONPointer(node, pointer) + } + current := value + for _, rawSegment := range strings.Split(pointer[1:], "/") { + segment := UnescapeJSONPointer(rawSegment) + switch typed := current.(type) { + case map[string]any: + next, ok := typed[segment] + if !ok { + return nil, fmt.Errorf("JSON pointer segment %q not found", segment) + } + current = next + case []any: + index, err := strconv.Atoi(segment) + if err != nil { + return nil, fmt.Errorf("invalid array index %q in JSON pointer", segment) + } + if index < 0 || index >= len(typed) { + return nil, fmt.Errorf("array index %d out of bounds (length %d)", index, len(typed)) + } + current = typed[index] + default: + return nil, fmt.Errorf("cannot traverse into %T with pointer segment %q", current, segment) + } + } + return current, nil +} + +// resolveNamedValue preserves exact dotted names, then treats the suffix after +// the longest declared name as member traversal. This supports both legal +// dotted Arazzo names and expressions such as "$inputs.order.id". +func resolveNamedValue(values map[string]any, name string) (any, bool, error) { + if value, found := values[name]; found { + return value, true, nil + } + for separator := strings.LastIndexByte(name, '.'); separator > 0; separator = strings.LastIndexByte(name[:separator], '.') { + if value, found := values[name[:separator]]; found { + resolved, err := resolveDottedValue(value, name[separator+1:]) + return resolved, true, err + } + } + return nil, false, nil +} + +func resolveDottedValue(value any, path string) (any, error) { + current := value + for _, segment := range strings.Split(path, ".") { + if node, ok := current.(*yaml.Node); ok { + resolved, err := resolveJSONPointer(node, "/"+escapeJSONPointer(segment)) + if err != nil { + return nil, err + } + current = resolved + continue + } + values, ok := current.(map[string]any) + if !ok { + return nil, fmt.Errorf("cannot traverse into %T with member %q", current, segment) + } + next, found := values[segment] + if !found { + return nil, fmt.Errorf("member %q not found", segment) + } + current = next + } + return current, nil +} + +func escapeJSONPointer(segment string) string { + segment = strings.ReplaceAll(segment, "~", "~0") + return strings.ReplaceAll(segment, "/", "~1") +} + // UnescapeJSONPointer applies RFC 6901 unescaping: ~1 -> /, ~0 -> ~ func UnescapeJSONPointer(s string) string { if !strings.Contains(s, "~") { diff --git a/arazzo/expression/evaluator_test.go b/arazzo/expression/evaluator_test.go index 4094e83eb..b6c5ba36b 100644 --- a/arazzo/expression/evaluator_test.go +++ b/arazzo/expression/evaluator_test.go @@ -615,13 +615,13 @@ func TestEvaluate_ResponseQuery_Unsupported(t *testing.T) { ctx := &Context{ResponseHeaders: map[string]string{"foo": "bar"}} _, err := Evaluate(Expression{Type: ResponseQuery, Property: "x"}, ctx) assert.Error(t, err) - assert.Contains(t, err.Error(), "not supported") + assert.Contains(t, err.Error(), "no response query parameters") } func TestEvaluate_ResponsePath_Unsupported(t *testing.T) { _, err := Evaluate(Expression{Type: ResponsePath, Property: "x"}, &Context{}) assert.Error(t, err) - assert.Contains(t, err.Error(), "not supported") + assert.Contains(t, err.Error(), "no response path parameters") } func TestEvaluate_UnsupportedExpressionType(t *testing.T) { @@ -722,9 +722,9 @@ func TestJSONPointer_EmptyPointer(t *testing.T) { func TestJSONPointer_RootSlash(t *testing.T) { ctx := fullContext(t) - val, err := Evaluate(Expression{Type: RequestBody, JSONPointer: "/"}, ctx) - assert.NoError(t, err) - assert.NotNil(t, val) + _, err := Evaluate(Expression{Type: RequestBody, JSONPointer: "/"}, ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), `segment "" not found`) } func TestJSONPointer_ResponseBody(t *testing.T) { @@ -958,5 +958,5 @@ func TestEvaluate_Error_ComponentsParametersMissing(t *testing.T) { func TestEvaluate_ResponseQuery_NotSupported(t *testing.T) { _, err := Evaluate(Expression{Type: ResponseQuery, Property: "x"}, &Context{}) assert.Error(t, err) - assert.Contains(t, err.Error(), "not supported") + assert.Contains(t, err.Error(), "no response query parameters") } diff --git a/arazzo/expression/expression.go b/arazzo/expression/expression.go index 0f5caff8b..2f83c57df 100644 --- a/arazzo/expression/expression.go +++ b/arazzo/expression/expression.go @@ -1,32 +1,62 @@ // Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley // SPDX-License-Identifier: MIT -// Package expression implements the Arazzo runtime expression parser and evaluator. -// https://spec.openapis.org/arazzo/v1.0.1#runtime-expressions +// Package expression implements the Arazzo runtime expression parser, local +// symbol resolver, and evaluator. +// https://spec.openapis.org/arazzo/v1.1.0.html#runtime-expressions package expression +// SpecVersion selects which Arazzo runtime-expression grammar a parse applies. +// +// Arazzo 1.0.1 uses general name productions for $steps, $workflows, +// $sourceDescriptions and $components. Arazzo 1.1.0 replaces them with more +// structured forms and restricts component types. Callers that know the document +// version should pass it explicitly so legal 1.0 expressions are not rejected by +// the 1.1 grammar. +type SpecVersion uint8 + +const ( + // Arazzo11 applies the Arazzo 1.1.0 grammar. This is the default for the + // version-free parser entry points. + Arazzo11 SpecVersion = iota + + // Arazzo10 applies the Arazzo 1.0.1 grammar, which additionally accepts the + // general "$components." name production. + Arazzo10 +) + // ExpressionType identifies the kind of runtime expression. type ExpressionType int const ( - URL ExpressionType = iota // $url - Method // $method - StatusCode // $statusCode - RequestHeader // $request.header.{name} - RequestQuery // $request.query.{name} - RequestPath // $request.path.{name} - RequestBody // $request.body{#/json-pointer} - ResponseHeader // $response.header.{name} - ResponseQuery // $response.query.{name} - ResponsePath // $response.path.{name} - ResponseBody // $response.body{#/json-pointer} - Inputs // $inputs.{name} - Outputs // $outputs.{name} - Steps // $steps.{name}[.tail] - Workflows // $workflows.{name}[.tail] - SourceDescriptions // $sourceDescriptions.{name}[.tail] - Components // $components.{name}[.tail] - ComponentParameters // $components.parameters.{name} + URL ExpressionType = iota // $url + Method // $method + StatusCode // $statusCode + RequestHeader // $request.header.{name} + RequestQuery // $request.query.{name} + RequestPath // $request.path.{name} + RequestBody // $request.body{#/json-pointer} + ResponseHeader // $response.header.{name} + ResponseQuery // $response.query.{name} + ResponsePath // $response.path.{name} + ResponseBody // $response.body{#/json-pointer} + Inputs // $inputs.{name} + Outputs // $outputs.{name} + Steps // $steps.{name}[.tail] + Workflows // $workflows.{name}[.tail] + SourceDescriptions // $sourceDescriptions.{name}[.tail] + Components // $components.{name}[.tail] + ComponentParameters // $components.parameters.{name} + Self // $self + RequestPayload // $request.payload{#/json-pointer} + ResponsePayload // $response.payload{#/json-pointer} + MessageHeader // $message.header.{name} + MessageQuery // $message.query.{name} + MessagePath // $message.path.{name} + MessageBody // $message.body{#/json-pointer} + MessagePayload // $message.payload{#/json-pointer} + ComponentSuccessActions // $components.successActions.{name} + ComponentFailureActions // $components.failureActions.{name} ) // Expression represents a parsed Arazzo runtime expression. @@ -41,7 +71,7 @@ type Expression struct { // Token represents a segment in an embedded expression string like "prefix {$expr} suffix". type Token struct { - Literal string // Non-empty if this is a literal text segment - Expression Expression // Valid if IsExpression is true - IsExpression bool // True if this token is an expression + Literal string // Non-empty if this is a literal text segment + Expression Expression // Valid if IsExpression is true + IsExpression bool // True if this token is an expression } diff --git a/arazzo/expression/gap_coverage_test.go b/arazzo/expression/gap_coverage_test.go index a72b0e77a..6a1ba12f9 100644 --- a/arazzo/expression/gap_coverage_test.go +++ b/arazzo/expression/gap_coverage_test.go @@ -24,7 +24,7 @@ func TestResolveComponents_WithDeepTail(t *testing.T) { }, } - v, err := EvaluateString("$components.inputs.i1.inner.value", ctx) + v, err := Evaluate(Expression{Type: Components, Name: "inputs", Tail: "i1.inner.value"}, ctx) require.NoError(t, err) assert.Equal(t, "ok", v) } diff --git a/arazzo/expression/parser.go b/arazzo/expression/parser.go index ff2165500..8c692611e 100644 --- a/arazzo/expression/parser.go +++ b/arazzo/expression/parser.go @@ -6,6 +6,7 @@ package expression import ( "fmt" "strings" + "unicode/utf8" ) // tcharTable is a 128-byte lookup table for RFC 7230 token characters. @@ -33,8 +34,15 @@ func isTchar(c byte) bool { return c < 128 && tcharTable[c] } -// Parse parses a single Arazzo runtime expression. Returns a value type to avoid heap allocation. +// Parse parses a single Arazzo runtime expression under the Arazzo 1.1 grammar. +// Returns a value type to avoid heap allocation. Use ParseWithVersion for 1.0 documents. func Parse(input string) (Expression, error) { + return ParseWithVersion(input, Arazzo11) +} + +// ParseWithVersion parses a single Arazzo runtime expression under the grammar for the +// given specification version. See SpecVersion for what differs between them. +func ParseWithVersion(input string, version SpecVersion) (Expression, error) { if len(input) == 0 { return Expression{}, fmt.Errorf("empty expression") } @@ -57,66 +65,72 @@ func Parse(input string) (Expression, error) { } return Expression{}, fmt.Errorf("unknown expression: %q", input) - case 'm': // $method + case 'm': // $method, $message. if input == "$method" { expr.Type = Method return expr, nil } + if strings.HasPrefix(input, "$message.") { + return parseSource(input, "$message.", MessageHeader, MessageQuery, MessagePath, MessageBody, MessagePayload) + } return Expression{}, fmt.Errorf("unknown expression: %q", input) - case 's': // $statusCode, $steps., $sourceDescriptions. + case 's': // $statusCode, $self, $steps., $sourceDescriptions. if input == "$statusCode" { expr.Type = StatusCode return expr, nil } + if input == "$self" { + expr.Type = Self + return expr, nil + } if strings.HasPrefix(input, "$steps.") { - return parseNamedExpression(input, "$steps.", Steps) + if version == Arazzo10 { + return parseArazzo10NamedExpression(input, "$steps.", Steps) + } + return parseStepExpression(input) } if strings.HasPrefix(input, "$sourceDescriptions.") { - return parseNamedExpression(input, "$sourceDescriptions.", SourceDescriptions) + if version == Arazzo10 { + return parseArazzo10NamedExpression(input, "$sourceDescriptions.", SourceDescriptions) + } + return parseSourceDescriptionExpression(input) } return Expression{}, fmt.Errorf("unknown expression: %q", input) case 'r': // $request., $response. if strings.HasPrefix(input, "$request.") { - return parseSource(input, "$request.", RequestHeader, RequestQuery, RequestPath, RequestBody) + return parseSource(input, "$request.", RequestHeader, RequestQuery, RequestPath, RequestBody, RequestPayload) } if strings.HasPrefix(input, "$response.") { - return parseSource(input, "$response.", ResponseHeader, ResponseQuery, ResponsePath, ResponseBody) + return parseSource(input, "$response.", ResponseHeader, ResponseQuery, ResponsePath, ResponseBody, ResponsePayload) } return Expression{}, fmt.Errorf("unknown expression: %q", input) case 'i': // $inputs. if strings.HasPrefix(input, "$inputs.") { - expr.Type = Inputs - expr.Name = input[len("$inputs."):] - if expr.Name == "" { - return Expression{}, fmt.Errorf("empty name in expression: %q", input) - } - return expr, nil + return parseValueReference(input, "$inputs.", Inputs) } return Expression{}, fmt.Errorf("unknown expression: %q", input) case 'o': // $outputs. if strings.HasPrefix(input, "$outputs.") { - expr.Type = Outputs - expr.Name = input[len("$outputs."):] - if expr.Name == "" { - return Expression{}, fmt.Errorf("empty name in expression: %q", input) - } - return expr, nil + return parseValueReference(input, "$outputs.", Outputs) } return Expression{}, fmt.Errorf("unknown expression: %q", input) case 'w': // $workflows. if strings.HasPrefix(input, "$workflows.") { - return parseNamedExpression(input, "$workflows.", Workflows) + if version == Arazzo10 { + return parseArazzo10NamedExpression(input, "$workflows.", Workflows) + } + return parseWorkflowExpression(input) } return Expression{}, fmt.Errorf("unknown expression: %q", input) case 'c': // $components. if strings.HasPrefix(input, "$components.") { - return parseComponents(input) + return parseComponents(input, version) } return Expression{}, fmt.Errorf("unknown expression: %q", input) @@ -125,8 +139,32 @@ func Parse(input string) (Expression, error) { } } +// parseArazzo10NamedExpression preserves the general 1.0.1 "$prefix." name +// production. The first segment remains Name and the rest remains Tail so the +// existing evaluator can resolve both whole objects and their nested fields. +func parseArazzo10NamedExpression(input, prefix string, exprType ExpressionType) (Expression, error) { + rest := input[len(prefix):] + if rest == "" { + return Expression{}, fmt.Errorf("empty name in expression: %q", input) + } + expr := Expression{Raw: input, Type: exprType} + if separator := strings.IndexByte(rest, '.'); separator >= 0 { + if separator == 0 { + return Expression{}, fmt.Errorf("empty name in expression: %q", input) + } + expr.Name = rest[:separator] + expr.Tail = rest[separator+1:] + return expr, nil + } + expr.Name = rest + return expr, nil +} + // parseSource parses $request.{source} or $response.{source} expressions. -func parseSource(input, prefix string, headerType, queryType, pathType, bodyType ExpressionType) (Expression, error) { +func parseSource( + input, prefix string, + headerType, queryType, pathType, bodyType, payloadType ExpressionType, +) (Expression, error) { expr := Expression{Raw: input} rest := input[len(prefix):] if len(rest) == 0 { @@ -152,8 +190,8 @@ func parseSource(input, prefix string, headerType, queryType, pathType, bodyType if strings.HasPrefix(rest, "query.") { expr.Type = queryType name := rest[len("query."):] - if name == "" { - return Expression{}, fmt.Errorf("empty query name in expression: %q", input) + if err := validateCharSequence(name, true); err != nil { + return Expression{}, fmt.Errorf("invalid query name in expression %q: %w", input, err) } expr.Property = name return expr, nil @@ -162,114 +200,340 @@ func parseSource(input, prefix string, headerType, queryType, pathType, bodyType if strings.HasPrefix(rest, "path.") { expr.Type = pathType name := rest[len("path."):] - if name == "" { - return Expression{}, fmt.Errorf("empty path name in expression: %q", input) + if err := validateCharSequence(name, true); err != nil { + return Expression{}, fmt.Errorf("invalid path name in expression %q: %w", input, err) } expr.Property = name return expr, nil } - if rest == "body" || strings.HasPrefix(rest, "body#") { - expr.Type = bodyType - if strings.HasPrefix(rest, "body#") { - expr.JSONPointer = rest[len("body#"):] - } - return expr, nil + if parsed, ok, err := parseStructuredSource(expr, rest, "body", bodyType); ok || err != nil { + return parsed, err + } + if parsed, ok, err := parseStructuredSource(expr, rest, "payload", payloadType); ok || err != nil { + return parsed, err } return Expression{}, fmt.Errorf("unknown source type in expression: %q", input) } -// parseNamedExpression parses expressions like $steps.{name}[.tail], $workflows.{name}[.tail], etc. -func parseNamedExpression(input, prefix string, exprType ExpressionType) (Expression, error) { - expr := Expression{Raw: input, Type: exprType} +func parseStructuredSource( + expr Expression, + rest, keyword string, + exprType ExpressionType, +) (Expression, bool, error) { + if rest == keyword { + expr.Type = exprType + return expr, true, nil + } + prefix := keyword + "#" + if strings.HasPrefix(rest, prefix) { + pointer := rest[len(prefix):] + if err := validateJSONPointer(pointer); err != nil { + return Expression{}, true, fmt.Errorf("invalid JSON Pointer in expression %q: %w", expr.Raw, err) + } + expr.Type = exprType + expr.JSONPointer = pointer + return expr, true, nil + } + return Expression{}, false, nil +} + +func parseValueReference(input, prefix string, exprType ExpressionType) (Expression, error) { + name, pointer, err := parseIdentifierAndPointer(input[len(prefix):], false) + if err != nil { + return Expression{}, fmt.Errorf("invalid value reference %q: %w", input, err) + } + return Expression{Raw: input, Type: exprType, Name: name, JSONPointer: pointer}, nil +} + +func parseStepExpression(input string) (Expression, error) { + const prefix = "$steps." rest := input[len(prefix):] - if rest == "" { - return Expression{}, fmt.Errorf("empty name in expression: %q", input) + separator := strings.Index(rest, ".outputs.") + if separator <= 0 { + return Expression{}, fmt.Errorf("invalid step output reference: %q", input) + } + stepID := rest[:separator] + if !isIdentifier(stepID, true) { + return Expression{}, fmt.Errorf("invalid step identifier %q in expression %q", stepID, input) + } + name, pointer, err := parseIdentifierAndPointer(rest[separator+len(".outputs."):], false) + if err != nil { + return Expression{}, fmt.Errorf("invalid step output reference %q: %w", input, err) } + tailStart := separator + 1 + tailEnd := tailStart + len("outputs.") + len(name) + return Expression{ + Raw: input, Type: Steps, Name: stepID, + Tail: rest[tailStart:tailEnd], JSONPointer: pointer, + }, nil +} - // Find the first dot to split name from tail - dotIdx := strings.IndexByte(rest, '.') - if dotIdx == -1 { - expr.Name = rest - } else { - if dotIdx == 0 { - return Expression{}, fmt.Errorf("empty name in expression: %q", input) - } - expr.Name = rest[:dotIdx] - expr.Tail = rest[dotIdx+1:] +func parseWorkflowExpression(input string) (Expression, error) { + const prefix = "$workflows." + rest := input[len(prefix):] + fieldPos, field := workflowFieldPosition(rest) + if fieldPos <= 0 { + return Expression{}, fmt.Errorf("invalid workflow input/output reference: %q", input) } - return expr, nil + workflowID := rest[:fieldPos] + if !isIdentifier(workflowID, true) { + return Expression{}, fmt.Errorf("invalid workflow identifier %q in expression %q", workflowID, input) + } + name, pointer, err := parseIdentifierAndPointer(rest[fieldPos+len(field)+2:], false) + if err != nil { + return Expression{}, fmt.Errorf("invalid workflow %s reference %q: %w", field, input, err) + } + tailStart := fieldPos + 1 + tailEnd := tailStart + len(field) + 1 + len(name) + return Expression{ + Raw: input, Type: Workflows, Name: workflowID, + Tail: rest[tailStart:tailEnd], JSONPointer: pointer, + }, nil } -// parseComponents parses $components.{name} and $components.parameters.{name} expressions. -func parseComponents(input string) (Expression, error) { - expr := Expression{Raw: input} - rest := input[len("$components."):] - if rest == "" { - return Expression{}, fmt.Errorf("empty name in expression: %q", input) +func workflowFieldPosition(rest string) (int, string) { + inputs := strings.Index(rest, ".inputs.") + outputs := strings.Index(rest, ".outputs.") + switch { + case inputs >= 0 && (outputs < 0 || inputs < outputs): + return inputs, "inputs" + case outputs >= 0: + return outputs, "outputs" + default: + return -1, "" } +} - // Special case: $components.parameters.{name} - if strings.HasPrefix(rest, "parameters.") { - name := rest[len("parameters."):] - if name == "" { - return Expression{}, fmt.Errorf("empty parameter name in expression: %q", input) +func parseSourceDescriptionExpression(input string) (Expression, error) { + const prefix = "$sourceDescriptions." + rest := input[len(prefix):] + separator := strings.IndexByte(rest, '.') + if separator <= 0 || separator == len(rest)-1 { + return Expression{}, fmt.Errorf("invalid source description reference: %q", input) + } + name := rest[:separator] + if !isIdentifier(name, true) { + return Expression{}, fmt.Errorf("invalid source description name %q in expression %q", name, input) + } + reference := rest[separator+1:] + if err := validateCharSequence(reference, false); err != nil { + return Expression{}, fmt.Errorf("invalid source description reference %q: %w", input, err) + } + return Expression{Raw: input, Type: SourceDescriptions, Name: name, Tail: reference}, nil +} + +// parseComponents parses $components references. +// +// Under Arazzo 1.1 only parameters, successActions and failureActions are addressable. +// Under Arazzo 1.0 those three keep their specific expression types, and any other +// component type falls back to the general "$components." name production, yielding a +// Components expression whose Name is the component type and whose Tail is the remainder. +func parseComponents(input string, version SpecVersion) (Expression, error) { + rest := input[len("$components."):] + separator := strings.IndexByte(rest, '.') + + // 1.0.1 permits a single-segment reference: "$components." name does not require a + // second dot. 1.1.0 always needs component-type "." component-name. + if separator < 0 { + if version == Arazzo10 && isIdentifier(rest, true) { + return Expression{Raw: input, Type: Components, Name: rest}, nil } + return Expression{}, fmt.Errorf("invalid component reference: %q", input) + } + if separator == 0 || separator == len(rest)-1 { + return Expression{}, fmt.Errorf("invalid component reference: %q", input) + } + componentType := rest[:separator] + name := rest[separator+1:] + + expr := Expression{Raw: input, Name: name} + switch componentType { + case "parameters": expr.Type = ComponentParameters - expr.Name = name - return expr, nil + case "successActions": + expr.Type = ComponentSuccessActions + case "failureActions": + expr.Type = ComponentFailureActions + default: + if version == Arazzo10 { + // 1.0.1: "$components." name, where name = *( CHAR ) and may contain dots. + // The evaluator keys off the component type, so carry it in Name and the + // remaining path in Tail. + if !isIdentifier(componentType, true) { + return Expression{}, fmt.Errorf("invalid component type %q in expression %q", componentType, input) + } + if !isIdentifier(name, false) { + return Expression{}, fmt.Errorf("invalid component name %q in expression %q", name, input) + } + return Expression{Raw: input, Type: Components, Name: componentType, Tail: name}, nil + } + return Expression{}, fmt.Errorf("unknown component type %q in expression %q", componentType, input) } - // General: $components.{name}[.tail] - expr.Type = Components - dotIdx := strings.IndexByte(rest, '.') - if dotIdx == -1 { - expr.Name = rest - } else { - expr.Name = rest[:dotIdx] - expr.Tail = rest[dotIdx+1:] + if !isIdentifier(name, false) { + return Expression{}, fmt.Errorf("invalid component name %q in expression %q", name, input) } return expr, nil } -// ParseEmbedded parses a string that may contain embedded runtime expressions in {$...} blocks. -// Returns alternating literal and expression tokens. +func parseIdentifierAndPointer(rest string, strict bool) (string, string, error) { + name := rest + pointer := "" + if hash := strings.IndexByte(rest, '#'); hash >= 0 { + name = rest[:hash] + pointer = rest[hash+1:] + if err := validateJSONPointer(pointer); err != nil { + return "", "", err + } + } + if !isIdentifier(name, strict) { + return "", "", fmt.Errorf("invalid identifier %q", name) + } + return name, pointer, nil +} + +func isIdentifier(value string, strict bool) bool { + if value == "" { + return false + } + for i := 0; i < len(value); i++ { + c := value[i] + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || + c >= '0' && c <= '9' || c == '-' || c == '_' || + (!strict && c == '.') { + continue + } + return false + } + return true +} + +func validateJSONPointer(pointer string) error { + if pointer == "" { + return nil + } + if pointer[0] != '/' { + return fmt.Errorf("must be empty or start with '/'") + } + if !utf8.ValidString(pointer) { + return fmt.Errorf("contains invalid UTF-8") + } + for i := 0; i < len(pointer); i++ { + switch pointer[i] { + case '{', '}': + return fmt.Errorf("contains unescaped brace at byte %d", i) + case '~': + if i+1 >= len(pointer) || pointer[i+1] != '0' && pointer[i+1] != '1' { + return fmt.Errorf("contains invalid escape at byte %d", i) + } + i++ + } + } + return nil +} + +func validateCharSequence(value string, allowEmpty bool) error { + if value == "" { + if allowEmpty { + return nil + } + return fmt.Errorf("must not be empty") + } + if !utf8.ValidString(value) { + return fmt.Errorf("contains invalid UTF-8") + } + for i := 0; i < len(value); i++ { + switch value[i] { + case '{', '}': + return fmt.Errorf("contains unescaped brace at byte %d", i) + case '"': + return fmt.Errorf("contains unescaped quotation mark at byte %d", i) + case '\\': + if i+1 >= len(value) { + return fmt.Errorf("contains incomplete escape at byte %d", i) + } + next := value[i+1] + if strings.ContainsRune(`"\/bfnrt`, rune(next)) { + i++ + continue + } + if next != 'u' || i+5 >= len(value) || !isHex4(value[i+2:i+6]) { + return fmt.Errorf("contains invalid escape at byte %d", i) + } + i += 5 + default: + if value[i] < 0x20 { + return fmt.Errorf("contains unescaped control character at byte %d", i) + } + } + } + return nil +} + +func isHex4(value string) bool { + if len(value) != 4 { + return false + } + for i := 0; i < 4; i++ { + c := value[i] + if c >= '0' && c <= '9' || c >= 'A' && c <= 'F' || c >= 'a' && c <= 'f' { + continue + } + return false + } + return true +} + +// ParseEmbedded parses a string that may contain embedded runtime expressions in {$...} +// blocks, under the Arazzo 1.1 grammar. Returns alternating literal and expression tokens. func ParseEmbedded(input string) ([]Token, error) { + return ParseEmbeddedWithVersion(input, Arazzo11) +} + +// ParseEmbeddedWithVersion parses embedded runtime expressions under the grammar for the +// given specification version. +func ParseEmbeddedWithVersion(input string, version SpecVersion) ([]Token, error) { if len(input) == 0 { return nil, nil } + if !utf8.ValidString(input) { + return nil, fmt.Errorf("expression string contains invalid UTF-8") + } var tokens []Token pos := 0 for pos < len(input) { - // Find the next embedded expression start. - openIdx := strings.Index(input[pos:], "{$") + openIdx := strings.IndexByte(input[pos:], '{') if openIdx == -1 { - // No more expressions, rest is literal + if closeIdx := strings.IndexByte(input[pos:], '}'); closeIdx >= 0 { + return nil, fmt.Errorf("unexpected closing brace at position %d", pos+closeIdx) + } tokens = append(tokens, Token{Literal: input[pos:]}) break } + openIdx += pos - // Add literal before the brace - if openIdx > 0 { - tokens = append(tokens, Token{Literal: input[pos : pos+openIdx]}) + if closeIdx := strings.IndexByte(input[pos:openIdx], '}'); closeIdx >= 0 { + return nil, fmt.Errorf("unexpected closing brace at position %d", pos+closeIdx) } - - exprStart := pos + openIdx + 1 - - // Find closing brace + if openIdx+1 >= len(input) || input[openIdx+1] != '$' { + return nil, fmt.Errorf("literal opening brace at position %d", openIdx) + } + if openIdx > pos { + tokens = append(tokens, Token{Literal: input[pos:openIdx]}) + } + exprStart := openIdx + 1 closeIdx := strings.IndexByte(input[exprStart:], '}') if closeIdx == -1 { - return nil, fmt.Errorf("unclosed expression brace at position %d", pos+openIdx) + return nil, fmt.Errorf("unclosed expression brace at position %d", openIdx) } - - // Extract and parse the expression (without the surrounding braces). exprStr := input[exprStart : exprStart+closeIdx] - expr, err := Parse(exprStr) + expr, err := ParseWithVersion(exprStr, version) if err != nil { - return nil, fmt.Errorf("invalid embedded expression at position %d: %w", pos+openIdx, err) + return nil, fmt.Errorf("invalid embedded expression at position %d: %w", openIdx, err) } tokens = append(tokens, Token{Expression: expr, IsExpression: true}) @@ -279,8 +543,15 @@ func ParseEmbedded(input string) ([]Token, error) { return tokens, nil } -// Validate checks whether a string is a valid runtime expression without allocating a full AST. +// Validate checks whether a string is a valid runtime expression under the Arazzo 1.1 +// grammar, without allocating a full AST. func Validate(input string) error { - _, err := Parse(input) + return ValidateWithVersion(input, Arazzo11) +} + +// ValidateWithVersion checks whether a string is a valid runtime expression under the +// grammar for the given specification version. +func ValidateWithVersion(input string, version SpecVersion) error { + _, err := ParseWithVersion(input, version) return err } diff --git a/arazzo/expression/parser_test.go b/arazzo/expression/parser_test.go index fde36b2b0..d5e2136cb 100644 --- a/arazzo/expression/parser_test.go +++ b/arazzo/expression/parser_test.go @@ -134,11 +134,9 @@ func TestParse_Steps_WithTail(t *testing.T) { } func TestParse_Steps_NoTail(t *testing.T) { - expr, err := Parse("$steps.myStep") - assert.NoError(t, err) - assert.Equal(t, Steps, expr.Type) - assert.Equal(t, "myStep", expr.Name) - assert.Empty(t, expr.Tail) + _, err := Parse("$steps.myStep") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid step output reference") } func TestParse_Workflows(t *testing.T) { @@ -150,11 +148,9 @@ func TestParse_Workflows(t *testing.T) { } func TestParse_Workflows_NoTail(t *testing.T) { - expr, err := Parse("$workflows.myFlow") - assert.NoError(t, err) - assert.Equal(t, Workflows, expr.Type) - assert.Equal(t, "myFlow", expr.Name) - assert.Empty(t, expr.Tail) + _, err := Parse("$workflows.myFlow") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid workflow input/output reference") } func TestParse_SourceDescriptions(t *testing.T) { @@ -166,11 +162,9 @@ func TestParse_SourceDescriptions(t *testing.T) { } func TestParse_SourceDescriptions_NoTail(t *testing.T) { - expr, err := Parse("$sourceDescriptions.petStore") - assert.NoError(t, err) - assert.Equal(t, SourceDescriptions, expr.Type) - assert.Equal(t, "petStore", expr.Name) - assert.Empty(t, expr.Tail) + _, err := Parse("$sourceDescriptions.petStore") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid source description reference") } func TestParse_ComponentParameters(t *testing.T) { @@ -181,27 +175,23 @@ func TestParse_ComponentParameters(t *testing.T) { } func TestParse_Components_General(t *testing.T) { - expr, err := Parse("$components.inputs.someInput") - assert.NoError(t, err) - assert.Equal(t, Components, expr.Type) - assert.Equal(t, "inputs", expr.Name) - assert.Equal(t, "someInput", expr.Tail) + _, err := Parse("$components.inputs.someInput") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown component type") } func TestParse_Components_SuccessActions(t *testing.T) { expr, err := Parse("$components.successActions.retry") assert.NoError(t, err) - assert.Equal(t, Components, expr.Type) - assert.Equal(t, "successActions", expr.Name) - assert.Equal(t, "retry", expr.Tail) + assert.Equal(t, ComponentSuccessActions, expr.Type) + assert.Equal(t, "retry", expr.Name) + assert.Empty(t, expr.Tail) } func TestParse_Components_NoTail(t *testing.T) { - expr, err := Parse("$components.schemas") - assert.NoError(t, err) - assert.Equal(t, Components, expr.Type) - assert.Equal(t, "schemas", expr.Name) - assert.Empty(t, expr.Tail) + _, err := Parse("$components.schemas") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid component reference") } // --------------------------------------------------------------------------- @@ -255,31 +245,31 @@ func TestParse_RequestBody_EmptyPointer(t *testing.T) { func TestParse_Error_EmptyInputsName(t *testing.T) { _, err := Parse("$inputs.") assert.Error(t, err) - assert.Contains(t, err.Error(), "empty name") + assert.Contains(t, err.Error(), "invalid identifier") } func TestParse_Error_EmptyOutputsName(t *testing.T) { _, err := Parse("$outputs.") assert.Error(t, err) - assert.Contains(t, err.Error(), "empty name") + assert.Contains(t, err.Error(), "invalid identifier") } func TestParse_Error_EmptyStepsName(t *testing.T) { _, err := Parse("$steps.") assert.Error(t, err) - assert.Contains(t, err.Error(), "empty name") + assert.Contains(t, err.Error(), "invalid step output reference") } func TestParse_Error_EmptyWorkflowsName(t *testing.T) { _, err := Parse("$workflows.") assert.Error(t, err) - assert.Contains(t, err.Error(), "empty name") + assert.Contains(t, err.Error(), "invalid workflow input/output reference") } func TestParse_Error_EmptySourceDescriptionsName(t *testing.T) { _, err := Parse("$sourceDescriptions.") assert.Error(t, err) - assert.Contains(t, err.Error(), "empty name") + assert.Contains(t, err.Error(), "invalid source description reference") } func TestParse_Error_EmptyNamedIdentifier(t *testing.T) { @@ -292,20 +282,20 @@ func TestParse_Error_EmptyNamedIdentifier(t *testing.T) { for _, tc := range cases { _, err := Parse(tc) assert.Error(t, err) - assert.Contains(t, err.Error(), "empty name") + assert.Contains(t, err.Error(), "invalid") } } func TestParse_Error_EmptyComponentsName(t *testing.T) { _, err := Parse("$components.") assert.Error(t, err) - assert.Contains(t, err.Error(), "empty name") + assert.Contains(t, err.Error(), "invalid component reference") } func TestParse_Error_EmptyComponentParametersName(t *testing.T) { _, err := Parse("$components.parameters.") assert.Error(t, err) - assert.Contains(t, err.Error(), "empty parameter name") + assert.Contains(t, err.Error(), "invalid component reference") } func TestParse_Error_EmptyHeaderName(t *testing.T) { @@ -315,15 +305,17 @@ func TestParse_Error_EmptyHeaderName(t *testing.T) { } func TestParse_Error_EmptyQueryName(t *testing.T) { - _, err := Parse("$request.query.") - assert.Error(t, err) - assert.Contains(t, err.Error(), "empty query name") + expr, err := Parse("$request.query.") + assert.NoError(t, err) + assert.Equal(t, RequestQuery, expr.Type) + assert.Empty(t, expr.Property) } func TestParse_Error_EmptyPathName(t *testing.T) { - _, err := Parse("$request.path.") - assert.Error(t, err) - assert.Contains(t, err.Error(), "empty path name") + expr, err := Parse("$request.path.") + assert.NoError(t, err) + assert.Equal(t, RequestPath, expr.Type) + assert.Empty(t, expr.Property) } func TestParse_Error_InvalidHeaderTchar(t *testing.T) { @@ -506,16 +498,9 @@ func TestParseEmbedded_Mixed(t *testing.T) { } func TestParseEmbedded_LiteralBracesBeforeExpression(t *testing.T) { - tokens, err := ParseEmbedded("literal {brace} {$inputs.id}") - assert.NoError(t, err) - assert.Len(t, tokens, 2) - - assert.False(t, tokens[0].IsExpression) - assert.Equal(t, "literal {brace} ", tokens[0].Literal) - - assert.True(t, tokens[1].IsExpression) - assert.Equal(t, Inputs, tokens[1].Expression.Type) - assert.Equal(t, "id", tokens[1].Expression.Name) + _, err := ParseEmbedded("literal {brace} {$inputs.id}") + assert.Error(t, err) + assert.Contains(t, err.Error(), "literal opening brace") } func TestParseEmbedded_Multiple(t *testing.T) { @@ -546,11 +531,9 @@ func TestParseEmbedded_EmptyInput(t *testing.T) { } func TestParseEmbedded_LiteralBracesWithoutExpressionPrefix(t *testing.T) { - tokens, err := ParseEmbedded("{notAnExpression}") - assert.NoError(t, err) - assert.Len(t, tokens, 1) - assert.False(t, tokens[0].IsExpression) - assert.Equal(t, "{notAnExpression}", tokens[0].Literal) + _, err := ParseEmbedded("{notAnExpression}") + assert.Error(t, err) + assert.Contains(t, err.Error(), "literal opening brace") } func TestParseEmbedded_MultipleExpressionsMixed(t *testing.T) { @@ -623,14 +606,14 @@ func TestValidate_Valid(t *testing.T) { "$response.body#/data", "$inputs.name", "$outputs.value", - "$steps.step1", "$steps.step1.outputs.result", - "$workflows.flow1", "$workflows.flow1.outputs.token", - "$sourceDescriptions.petStore", "$sourceDescriptions.petStore.url", "$components.parameters.limit", - "$components.inputs.someInput", + "$components.successActions.retry", + "$components.failureActions.abort", + "$message.payload#/id", + "$self", } for _, v := range validExprs { assert.NoError(t, Validate(v), "expected %q to be valid", v) diff --git a/arazzo/expression/runtime_sources_test.go b/arazzo/expression/runtime_sources_test.go new file mode 100644 index 000000000..e469c4c76 --- /dev/null +++ b/arazzo/expression/runtime_sources_test.go @@ -0,0 +1,208 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package expression + +import ( + "testing" + + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +func TestEvaluateArazzo11RuntimeSources(t *testing.T) { + payload := buildYAMLNode(t, `items: + - id: order-1 +`) + ctx := &Context{ + Self: "https://example.com/workflow.yaml", + RequestPayload: payload, + ResponseQuery: map[string]string{"cursor": "next"}, + ResponsePath: map[string]string{"id": "42"}, + ResponsePayload: payload, + MessageHeaders: map[string]string{"Correlation-ID": "abc"}, + MessageQuery: map[string]string{"partition": "1"}, + MessagePath: map[string]string{"topic": "orders"}, + MessageBody: payload, + MessagePayload: payload, + Inputs: map[string]any{ + "object": map[string]any{"items": []any{map[string]any{"id": "input-1"}}}, + "node": payload, + "nested": map[string]any{"member": map[string]any{"id": "nested-input"}}, + "nested.member.id": "exact-input", + }, + Outputs: map[string]any{ + "object": map[string]any{"id": "output-1"}, + "nested": map[string]any{"id": "nested-output"}, + }, + Steps: map[string]*StepContext{ + "step": { + Inputs: map[string]any{"request": map[string]any{"id": "step-input"}}, + Outputs: map[string]any{"result": map[string]any{"id": "step-1"}}, + }, + }, + Workflows: map[string]*WorkflowContext{ + "flow": { + Inputs: map[string]any{"request": map[string]any{"id": "workflow-input"}}, + Outputs: map[string]any{"result": []any{map[string]any{"id": "workflow-output"}}}, + }, + }, + SourceDescs: map[string]*SourceDescContext{ + "source": { + URL: "https://example.com/openapi.yaml", + Type: "openapi", + Operations: map[string]any{"get": "operation"}, + Workflows: map[string]any{"flow": "workflow"}, + Fields: map[string]any{"x-field": "field"}, + }, + }, + Components: &ComponentsContext{ + Parameters: map[string]any{"parameter": "p"}, + SuccessActions: map[string]any{"success": "s"}, + FailureActions: map[string]any{"failure": "f"}, + }, + } + + tests := []struct { + input string + want any + }{ + {"$self", "https://example.com/workflow.yaml"}, + {"$request.payload#/items/0/id", "order-1"}, + {"$response.query.cursor", "next"}, + {"$response.path.id", "42"}, + {"$response.payload#/items/0/id", "order-1"}, + {"$message.header.Correlation-ID", "abc"}, + {"$message.query.partition", "1"}, + {"$message.path.topic", "orders"}, + {"$message.body#/items/0/id", "order-1"}, + {"$message.payload#/items/0/id", "order-1"}, + {"$inputs.object#/items/0/id", "input-1"}, + {"$inputs.node#/items/0/id", "order-1"}, + {"$inputs.nested.member.id", "exact-input"}, + {"$outputs.object#/id", "output-1"}, + {"$outputs.nested.id", "nested-output"}, + {"$steps.step.outputs.result#/id", "step-1"}, + {"$steps.step.outputs.result.id", "step-1"}, + {"$workflows.flow.inputs.request#/id", "workflow-input"}, + {"$workflows.flow.inputs.request.id", "workflow-input"}, + {"$workflows.flow.outputs.result#/0/id", "workflow-output"}, + {"$sourceDescriptions.source.get", "operation"}, + {"$sourceDescriptions.source.flow", "workflow"}, + {"$sourceDescriptions.source.x-field", "field"}, + {"$sourceDescriptions.source.url", "https://example.com/openapi.yaml"}, + {"$sourceDescriptions.source.type", "openapi"}, + {"$components.parameters.parameter", "p"}, + {"$components.successActions.success", "s"}, + {"$components.failureActions.failure", "f"}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + value, err := EvaluateString(test.input, ctx) + require.NoError(t, err) + assert.Equal(t, test.want, value) + }) + } +} + +func TestEvaluateArazzo11RuntimeSourceErrors(t *testing.T) { + payload := buildYAMLNode(t, "value: ok") + tests := []struct { + expr Expression + ctx *Context + }{ + {expr: Expression{Type: Self}, ctx: &Context{}}, + {expr: Expression{Type: RequestPayload}, ctx: &Context{}}, + {expr: Expression{Type: ResponsePayload}, ctx: &Context{}}, + {expr: Expression{Type: MessageBody}, ctx: &Context{}}, + {expr: Expression{Type: MessagePayload}, ctx: &Context{}}, + {expr: Expression{Type: ResponseQuery, Property: "missing"}, ctx: &Context{ResponseQuery: map[string]string{}}}, + {expr: Expression{Type: ResponsePath, Property: "missing"}, ctx: &Context{ResponsePath: map[string]string{}}}, + {expr: Expression{Type: MessageHeader}, ctx: &Context{}}, + {expr: Expression{Type: MessageHeader, Property: "missing"}, ctx: &Context{MessageHeaders: map[string]string{}}}, + {expr: Expression{Type: MessageQuery}, ctx: &Context{}}, + {expr: Expression{Type: MessageQuery, Property: "missing"}, ctx: &Context{MessageQuery: map[string]string{}}}, + {expr: Expression{Type: MessagePath}, ctx: &Context{}}, + {expr: Expression{Type: MessagePath, Property: "missing"}, ctx: &Context{MessagePath: map[string]string{}}}, + {expr: Expression{Type: Inputs, Name: "value", JSONPointer: "relative"}, ctx: &Context{Inputs: map[string]any{"value": payload}}}, + {expr: Expression{Type: Inputs, Name: "value", JSONPointer: "/missing"}, ctx: &Context{Inputs: map[string]any{"value": map[string]any{}}}}, + {expr: Expression{Type: Inputs, Name: "value", JSONPointer: "/abc"}, ctx: &Context{Inputs: map[string]any{"value": []any{"x"}}}}, + {expr: Expression{Type: Inputs, Name: "value", JSONPointer: "/2"}, ctx: &Context{Inputs: map[string]any{"value": []any{"x"}}}}, + {expr: Expression{Type: Inputs, Name: "value", JSONPointer: "/child"}, ctx: &Context{Inputs: map[string]any{"value": "scalar"}}}, + {expr: Expression{Type: Inputs, Name: "value.child"}, ctx: &Context{Inputs: map[string]any{"value": "scalar"}}}, + {expr: Expression{Type: Outputs, Name: "value.child"}, ctx: &Context{Outputs: map[string]any{"value": "scalar"}}}, + { + expr: Expression{Type: Steps, Name: "step", Tail: "outputs.value.child"}, + ctx: &Context{Steps: map[string]*StepContext{"step": {Outputs: map[string]any{"value": "scalar"}}}}, + }, + { + expr: Expression{Type: Steps, Name: "step", Tail: "inputs.value.child"}, + ctx: &Context{Steps: map[string]*StepContext{"step": {Inputs: map[string]any{"value": "scalar"}}}}, + }, + { + expr: Expression{Type: Workflows, Name: "flow", Tail: "outputs.value.child"}, + ctx: &Context{Workflows: map[string]*WorkflowContext{"flow": {Outputs: map[string]any{"value": "scalar"}}}}, + }, + { + expr: Expression{Type: Workflows, Name: "flow", Tail: "inputs.value.child"}, + ctx: &Context{Workflows: map[string]*WorkflowContext{"flow": {Inputs: map[string]any{"value": "scalar"}}}}, + }, + {expr: Expression{Type: ComponentSuccessActions, Name: "value"}, ctx: &Context{}}, + {expr: Expression{Type: ComponentSuccessActions, Name: "value"}, ctx: &Context{Components: &ComponentsContext{}}}, + {expr: Expression{Type: ComponentSuccessActions, Name: "missing"}, ctx: &Context{Components: &ComponentsContext{SuccessActions: map[string]any{}}}}, + {expr: Expression{Type: ComponentFailureActions, Name: "value"}, ctx: &Context{Components: &ComponentsContext{}}}, + {expr: Expression{Type: ComponentFailureActions, Name: "missing"}, ctx: &Context{Components: &ComponentsContext{FailureActions: map[string]any{}}}}, + } + for _, test := range tests { + _, err := Evaluate(test.expr, test.ctx) + assert.Error(t, err) + } + + _, err := resolveComponentValue(&ComponentsContext{}, "value", "unknown") + assert.Error(t, err) + _, err = resolveJSONPointer(payload, "relative") + assert.Error(t, err) + _, err = resolveStructuredSource(payload, "relative", "payload") + assert.Error(t, err) + + _, found, err := resolveNamedValue(map[string]any{"value": "scalar"}, "value.child") + assert.True(t, found) + assert.Error(t, err) + _, found, err = resolveNamedValue(map[string]any{"value": map[string]any{}}, "value.child") + assert.True(t, found) + assert.Error(t, err) + _, found, err = resolveNamedValue(map[string]any{"other": true}, "value.child") + assert.False(t, found) + assert.NoError(t, err) + + escapedNode := buildYAMLNode(t, `"a/b~c": escaped`) + value, found, err := resolveNamedValue(map[string]any{"node": escapedNode}, "node.a/b~c") + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, "escaped", value) + _, found, err = resolveNamedValue(map[string]any{"node": escapedNode}, "node.missing") + assert.True(t, found) + assert.Error(t, err) +} + +func TestEvaluateArazzo11JSONPointerRootAndEmptyMember(t *testing.T) { + node := buildYAMLNode(t, `"": empty +value: present +`) + value, err := resolveJSONPointer(node, "/") + require.NoError(t, err) + assert.Equal(t, "empty", value) + + root, err := resolveJSONPointer(node, "") + require.NoError(t, err) + assert.Same(t, node, root) + + value, err = resolveValuePointer(map[string]any{"": "empty"}, "/") + require.NoError(t, err) + assert.Equal(t, "empty", value) + + rootValue := map[string]any{"value": "present"} + value, err = resolveValuePointer(rootValue, "") + require.NoError(t, err) + assert.Equal(t, rootValue, value) +} diff --git a/arazzo/expression/symbols.go b/arazzo/expression/symbols.go new file mode 100644 index 000000000..99150fee7 --- /dev/null +++ b/arazzo/expression/symbols.go @@ -0,0 +1,156 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package expression + +import ( + "errors" + "fmt" + "strings" +) + +// ErrLocalSymbolNotFound identifies a runtime expression that references a +// symbol unavailable in its current Arazzo document scope. +var ErrLocalSymbolNotFound = errors.New("local runtime-expression symbol not found") + +// SymbolSet is an allocation-conscious lookup set for local Arazzo names. +type SymbolSet map[string]struct{} + +// StepSymbols contains the locally declared outputs for a step. +type StepSymbols struct { + Outputs SymbolSet +} + +// WorkflowSymbols contains the locally declared inputs and outputs for a +// workflow. +type WorkflowSymbols struct { + Inputs SymbolSet + Outputs SymbolSet +} + +// LocalSymbols contains the document-local names available to a runtime +// expression. It deliberately contains no document loader or external source +// adapter. +type LocalSymbols struct { + HasSelf bool + Inputs SymbolSet + Outputs SymbolSet + Steps map[string]StepSymbols + Workflows map[string]WorkflowSymbols + SourceDescriptions SymbolSet + ComponentParameters SymbolSet + ComponentSuccessActions SymbolSet + ComponentFailureActions SymbolSet +} + +// LocalSymbolError reports the narrow local symbol that could not be resolved. +type LocalSymbolError struct { + Expression string + Kind string + Name string +} + +// Error returns a stable diagnostic for an unresolved local symbol. +func (e *LocalSymbolError) Error() string { + return fmt.Sprintf("%s: %s %q in %q", ErrLocalSymbolNotFound, e.Kind, e.Name, e.Expression) +} + +// Unwrap exposes ErrLocalSymbolNotFound for errors.Is. +func (e *LocalSymbolError) Unwrap() error { + return ErrLocalSymbolNotFound +} + +// ResolveLocal checks the document-local portion of an already parsed runtime +// expression. It never resolves or retrieves an external source document. +func ResolveLocal(expr Expression, symbols *LocalSymbols) error { + if symbols == nil { + return fmt.Errorf("nil local runtime-expression symbols") + } + switch expr.Type { + case Self: + if !symbols.HasSelf { + return newLocalSymbolError(expr, "self URI", "$self") + } + case Inputs: + return requireSymbol(expr, symbols.Inputs, "input", expr.Name) + case Outputs: + return requireSymbol(expr, symbols.Outputs, "output", expr.Name) + case Steps: + step, found := symbols.Steps[expr.Name] + if !found { + return newLocalSymbolError(expr, "step", expr.Name) + } + field, name, ok := splitReferenceTail(expr.Tail) + if !ok || field != "outputs" { + return newLocalSymbolError(expr, "step output", expr.Tail) + } + return requireSymbol(expr, step.Outputs, "step output", name) + case Workflows: + workflow, found := symbols.Workflows[expr.Name] + if !found { + return newLocalSymbolError(expr, "workflow", expr.Name) + } + field, name, ok := splitReferenceTail(expr.Tail) + if !ok { + return newLocalSymbolError(expr, "workflow field", expr.Tail) + } + switch field { + case "inputs": + return requireSymbol(expr, workflow.Inputs, "workflow input", name) + case "outputs": + return requireSymbol(expr, workflow.Outputs, "workflow output", name) + default: + return newLocalSymbolError(expr, "workflow field", field) + } + case SourceDescriptions: + return requireSymbol(expr, symbols.SourceDescriptions, "source description", expr.Name) + case ComponentParameters: + return requireSymbol(expr, symbols.ComponentParameters, "component parameter", expr.Name) + case ComponentSuccessActions: + return requireSymbol(expr, symbols.ComponentSuccessActions, "component success action", expr.Name) + case ComponentFailureActions: + return requireSymbol(expr, symbols.ComponentFailureActions, "component failure action", expr.Name) + } + return nil +} + +// ValidateLocal parses a runtime expression and checks its document-local +// symbols without evaluating runtime values or resolving external documents. +func ValidateLocal(input string, symbols *LocalSymbols) error { + expr, err := Parse(input) + if err != nil { + return err + } + return ResolveLocal(expr, symbols) +} + +func requireSymbol(expr Expression, symbols SymbolSet, kind, name string) error { + if hasSymbolOrPrefix(symbols, name) { + return nil + } + return newLocalSymbolError(expr, kind, name) +} + +func hasSymbolOrPrefix(symbols SymbolSet, name string) bool { + if _, found := symbols[name]; found { + return true + } + for separator := strings.LastIndexByte(name, '.'); separator > 0; separator = strings.LastIndexByte(name[:separator], '.') { + if _, found := symbols[name[:separator]]; found { + return true + } + } + return false +} + +func newLocalSymbolError(expr Expression, kind, name string) error { + return &LocalSymbolError{Expression: expr.Raw, Kind: kind, Name: name} +} + +func splitReferenceTail(tail string) (string, string, bool) { + separator := strings.IndexByte(tail, '.') + if separator <= 0 || separator == len(tail)-1 { + return "", "", false + } + return tail[:separator], tail[separator+1:], true +} diff --git a/arazzo/expression/version_grammar_test.go b/arazzo/expression/version_grammar_test.go new file mode 100644 index 000000000..7de902d87 --- /dev/null +++ b/arazzo/expression/version_grammar_test.go @@ -0,0 +1,182 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package expression + +import ( + "testing" + + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +// Arazzo 1.0.1 section 4.7 defines a general production, "$components." name, where +// name = *( CHAR ) and so may contain dots. Arazzo 1.1.0 replaced it with +// components-reference = component-type "." component-name, restricted to three types. +// A reference that is legal in 1.0 must not be reported as invalid when the caller +// states that the document is 1.0. +func TestParseWithVersion_ComponentsGeneralProductionIsArazzo10Only(t *testing.T) { + const input = "$components.inputs.someInput" + + expr, err := ParseWithVersion(input, Arazzo10) + require.NoError(t, err, "1.0.1 permits the general $components. name production") + assert.Equal(t, Components, expr.Type) + assert.Equal(t, "inputs", expr.Name, "component type is carried in Name") + assert.Equal(t, "someInput", expr.Tail, "remaining path is carried in Tail") + assert.Equal(t, input, expr.Raw) + + _, err = ParseWithVersion(input, Arazzo11) + require.Error(t, err, "1.1.0 removed the general production") + assert.Contains(t, err.Error(), "unknown component type") +} + +func TestParseWithVersion_Arazzo10GeneralNamedProductions(t *testing.T) { + tests := []struct { + input string + exprType ExpressionType + name string + tail string + }{ + {input: "$steps.someStep", exprType: Steps, name: "someStep"}, + {input: "$steps.someStep.arbitrary.tail", exprType: Steps, name: "someStep", tail: "arbitrary.tail"}, + {input: "$workflows.someWorkflow", exprType: Workflows, name: "someWorkflow"}, + {input: "$sourceDescriptions.petstore", exprType: SourceDescriptions, name: "petstore"}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + expr, err := ParseWithVersion(test.input, Arazzo10) + require.NoError(t, err) + assert.Equal(t, test.exprType, expr.Type) + assert.Equal(t, test.name, expr.Name) + assert.Equal(t, test.tail, expr.Tail) + + _, err = ParseWithVersion(test.input, Arazzo11) + require.Error(t, err) + }) + } +} + +// The version-free entry points must keep their existing 1.1 behavior. +func TestParse_DefaultsToArazzo11Grammar(t *testing.T) { + _, err := Parse("$components.inputs.someInput") + require.Error(t, err) + + require.Error(t, Validate("$components.inputs.someInput")) + require.NoError(t, ValidateWithVersion("$components.inputs.someInput", Arazzo10)) +} + +// The three component types addressable in 1.1 must keep their specific expression types +// under both grammars, so 1.0 documents are not quietly downgraded to the general form. +func TestParseWithVersion_SpecificComponentTypesUnchangedAcrossVersions(t *testing.T) { + tests := []struct { + input string + expected ExpressionType + }{ + {"$components.parameters.token", ComponentParameters}, + {"$components.successActions.retry", ComponentSuccessActions}, + {"$components.failureActions.bail", ComponentFailureActions}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + for _, version := range []SpecVersion{Arazzo10, Arazzo11} { + expr, err := ParseWithVersion(test.input, version) + require.NoError(t, err) + assert.Equal(t, test.expected, expr.Type) + assert.Empty(t, expr.Tail, "specific forms carry the name in Name, not Tail") + } + }) + } +} + +// The general 1.0 form still has to be well formed; it is not an escape hatch for +// arbitrary text. +func TestParseWithVersion_Arazzo10GeneralFormStillValidated(t *testing.T) { + tests := []string{ + "$components.", + "$components.inputs.", + "$components.bad type.name", + "$components.inputs.bad name", + } + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + _, err := ParseWithVersion(input, Arazzo10) + assert.Error(t, err) + }) + } +} + +// The 1.0.1 production is "$components." name, where name = *( CHAR ). A second dot is +// not required, so a single-segment reference is legal 1.0 and must not be rejected. +// 1.1.0 always requires component-type "." component-name. +func TestParseWithVersion_Arazzo10AcceptsSingleSegmentComponentReference(t *testing.T) { + const input = "$components.someName" + + expr, err := ParseWithVersion(input, Arazzo10) + require.NoError(t, err) + assert.Equal(t, Components, expr.Type) + assert.Equal(t, "someName", expr.Name) + assert.Empty(t, expr.Tail) + + _, err = ParseWithVersion(input, Arazzo11) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid component reference") +} + +// Embedded expressions must honor the selected grammar too, since they delegate to the +// same single-expression parser. +func TestParseEmbeddedWithVersion_HonorsArazzo10Grammar(t *testing.T) { + const input = "prefix {$components.inputs.someInput} suffix" + + tokens, err := ParseEmbeddedWithVersion(input, Arazzo10) + require.NoError(t, err) + require.Len(t, tokens, 3) + assert.Equal(t, "prefix ", tokens[0].Literal) + require.True(t, tokens[1].IsExpression) + assert.Equal(t, Components, tokens[1].Expression.Type) + assert.Equal(t, " suffix", tokens[2].Literal) + + _, err = ParseEmbeddedWithVersion(input, Arazzo11) + assert.Error(t, err) +} + +// The evaluator has always been able to resolve component inputs; before the grammar was +// version-aware that branch was unreachable because the parser rejected the expression. +func TestEvaluate_Arazzo10ComponentInputsResolves(t *testing.T) { + expr, err := ParseWithVersion("$components.inputs.someInput", Arazzo10) + require.NoError(t, err) + + ctx := &Context{ + Components: &ComponentsContext{ + Inputs: map[string]any{"someInput": "resolved-value"}, + }, + } + + value, err := Evaluate(expr, ctx) + require.NoError(t, err) + assert.Equal(t, "resolved-value", value) +} + +// A missing component input must report the input name rather than fall through to the +// generic unknown-component-type error. +func TestEvaluate_Arazzo10ComponentInputsMissingName(t *testing.T) { + expr, err := ParseWithVersion("$components.inputs.absent", Arazzo10) + require.NoError(t, err) + + ctx := &Context{Components: &ComponentsContext{Inputs: map[string]any{}}} + _, err = Evaluate(expr, ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), `component input "absent" not found`) +} + +// A component type with no backing context must report that, not a parse failure. +func TestEvaluate_Arazzo10UnknownComponentTypeAtEvaluation(t *testing.T) { + expr, err := ParseWithVersion("$components.somethingElse.name", Arazzo10) + require.NoError(t, err, "1.0 grammar accepts any component type syntactically") + + _, err = Evaluate(expr, &Context{Components: &ComponentsContext{}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown component type") +} diff --git a/arazzo/final_coverage_test.go b/arazzo/final_coverage_test.go index 03a1b349c..1b312771f 100644 --- a/arazzo/final_coverage_test.go +++ b/arazzo/final_coverage_test.go @@ -199,7 +199,7 @@ func TestFetchSourceBytes_FileSchemeResolveError(t *testing.T) { FSRoots: []string{"/nonexistent-root-dir-xyz"}, } u := mustParseURL("file:///etc/passwd") - _, _, err := fetchSourceBytes(u, config) + _, _, err := fetchSourceBytes(context.Background(), u, config) assert.Error(t, err) errMsg := err.Error() if runtime.GOOS == "windows" { @@ -223,7 +223,7 @@ func TestFetchHTTPSourceBytes_RealHTTPRequestFailure(t *testing.T) { MaxBodySize: 10 * 1024 * 1024, } // A URL with a space is invalid for http.NewRequestWithContext - _, err := fetchHTTPSourceBytes("http://[::1]:namedport/path", config) + _, err := fetchHTTPSourceBytes(context.Background(), "http://[::1]:namedport/path", config) assert.Error(t, err) } @@ -238,7 +238,7 @@ func TestFetchHTTPSourceBytes_RealHTTPNon2xxStatus(t *testing.T) { Timeout: 30 * time.Second, MaxBodySize: 10 * 1024 * 1024, } - _, err := fetchHTTPSourceBytes(srv.URL, config) + _, err := fetchHTTPSourceBytes(context.Background(), srv.URL, config) assert.Error(t, err) assert.Contains(t, err.Error(), "unexpected status code 500") } @@ -259,7 +259,7 @@ func TestFetchHTTPSourceBytes_RealHTTPBodyExceedsLimit(t *testing.T) { Timeout: 30 * time.Second, MaxBodySize: 10, // Very small limit } - _, err := fetchHTTPSourceBytes(srv.URL, config) + _, err := fetchHTTPSourceBytes(context.Background(), srv.URL, config) assert.Error(t, err) assert.Contains(t, err.Error(), "exceeds max size") } @@ -275,7 +275,7 @@ func TestFetchHTTPSourceBytes_RealHTTPSuccess(t *testing.T) { Timeout: 30 * time.Second, MaxBodySize: 10 * 1024 * 1024, } - data, err := fetchHTTPSourceBytes(srv.URL, config) + data, err := fetchHTTPSourceBytes(context.Background(), srv.URL, config) assert.NoError(t, err) assert.Equal(t, []byte("hello"), data) } @@ -335,15 +335,10 @@ func TestResolveComponents_UnknownComponentType(t *testing.T) { }, } - // Parse an expression like $components.unknownType.someName - // This should resolve to the Components type with Name="unknownType" and Tail="someName" - expr, err := expression.Parse("$components.unknownType.someName") - require.NoError(t, err) - assert.Equal(t, expression.Components, expr.Type) - assert.Equal(t, "unknownType", expr.Name) - - // Evaluate should return "unknown component type" error - _, err = expression.Evaluate(expr, ctx) + // The legacy evaluator remains defensive for manually constructed ASTs, + // while the 1.1 parser rejects unknown component types. + expr := expression.Expression{Type: expression.Components, Name: "unknownType", Tail: "someName"} + _, err := expression.Evaluate(expr, ctx) assert.Error(t, err) assert.Contains(t, err.Error(), "unknown component type") } @@ -686,7 +681,7 @@ func TestFetchHTTPSourceBytes_ClientDoError(t *testing.T) { Timeout: 30 * time.Second, MaxBodySize: 10 * 1024 * 1024, } - _, err := fetchHTTPSourceBytes(srv.URL, config) + _, err := fetchHTTPSourceBytes(context.Background(), srv.URL, config) assert.Error(t, err) } @@ -754,18 +749,19 @@ func TestResolveComponents_AllKnownTypes(t *testing.T) { } tests := []struct { - expr string + name string + expr expression.Expression expected any }{ - {"$components.parameters.p1", "val1"}, - {"$components.successActions.sa1", "val2"}, - {"$components.failureActions.fa1", "val3"}, - {"$components.inputs.i1", "val4"}, + {"parameters", expression.Expression{Type: expression.Components, Name: "parameters", Tail: "p1"}, "val1"}, + {"successActions", expression.Expression{Type: expression.Components, Name: "successActions", Tail: "sa1"}, "val2"}, + {"failureActions", expression.Expression{Type: expression.Components, Name: "failureActions", Tail: "fa1"}, "val3"}, + {"inputs", expression.Expression{Type: expression.Components, Name: "inputs", Tail: "i1"}, "val4"}, } for _, tc := range tests { - t.Run(tc.expr, func(t *testing.T) { - result, err := expression.EvaluateString(tc.expr, ctx) + t.Run(tc.name, func(t *testing.T) { + result, err := expression.Evaluate(tc.expr, ctx) assert.NoError(t, err) assert.Equal(t, tc.expected, result) }) @@ -782,18 +778,19 @@ func TestResolveComponents_NilMaps(t *testing.T) { } tests := []struct { - expr string + name string + expr expression.Expression msg string }{ - {"$components.parameters.p1", "no component parameters"}, - {"$components.successActions.sa1", "no component success actions"}, - {"$components.failureActions.fa1", "no component failure actions"}, - {"$components.inputs.i1", "no component inputs"}, + {"parameters", expression.Expression{Type: expression.Components, Name: "parameters", Tail: "p1"}, "no component parameters"}, + {"successActions", expression.Expression{Type: expression.Components, Name: "successActions", Tail: "sa1"}, "no component success actions"}, + {"failureActions", expression.Expression{Type: expression.Components, Name: "failureActions", Tail: "fa1"}, "no component failure actions"}, + {"inputs", expression.Expression{Type: expression.Components, Name: "inputs", Tail: "i1"}, "no component inputs"}, } for _, tc := range tests { - t.Run(tc.expr, func(t *testing.T) { - _, err := expression.EvaluateString(tc.expr, ctx) + t.Run(tc.name, func(t *testing.T) { + _, err := expression.Evaluate(tc.expr, ctx) assert.Error(t, err) assert.Contains(t, err.Error(), tc.msg) }) @@ -815,18 +812,19 @@ func TestResolveComponents_KeyNotFound(t *testing.T) { } tests := []struct { - expr string + name string + expr expression.Expression msg string }{ - {"$components.parameters.missing", "not found"}, - {"$components.successActions.missing", "not found"}, - {"$components.failureActions.missing", "not found"}, - {"$components.inputs.missing", "not found"}, + {"parameters", expression.Expression{Type: expression.Components, Name: "parameters", Tail: "missing"}, "not found"}, + {"successActions", expression.Expression{Type: expression.Components, Name: "successActions", Tail: "missing"}, "not found"}, + {"failureActions", expression.Expression{Type: expression.Components, Name: "failureActions", Tail: "missing"}, "not found"}, + {"inputs", expression.Expression{Type: expression.Components, Name: "inputs", Tail: "missing"}, "not found"}, } for _, tc := range tests { - t.Run(tc.expr, func(t *testing.T) { - _, err := expression.EvaluateString(tc.expr, ctx) + t.Run(tc.name, func(t *testing.T) { + _, err := expression.Evaluate(tc.expr, ctx) assert.Error(t, err) assert.Contains(t, err.Error(), tc.msg) }) @@ -840,8 +838,10 @@ func TestResolveComponents_KeyNotFound(t *testing.T) { func TestResolveComponents_NilComponentsContext(t *testing.T) { ctx := &expression.Context{} - // Use a non-parameters component name to hit the Components case (not ComponentParameters) - _, err := expression.EvaluateString("$components.unknownType.something", ctx) + _, err := expression.Evaluate( + expression.Expression{Type: expression.Components, Name: "unknownType", Tail: "something"}, + ctx, + ) assert.Error(t, err) assert.Contains(t, err.Error(), "no components context") } @@ -866,8 +866,10 @@ func TestResolveComponents_EmptyTail(t *testing.T) { }, } - // $components.parameters has no tail (no second dot after parameters) - _, err := expression.EvaluateString("$components.parameters", ctx) + _, err := expression.Evaluate( + expression.Expression{Type: expression.Components, Name: "parameters"}, + ctx, + ) assert.Error(t, err) assert.Contains(t, err.Error(), "incomplete components expression") } @@ -1032,3 +1034,21 @@ func TestResolveSources_NilArazzoFactory(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "no ArazzoFactory") } + +func TestResolveSources_ArazzoFactoryError(t *testing.T) { + sentinel := errors.New("factory failed") + doc := &high.Arazzo{ + SourceDescriptions: []*high.SourceDescription{ + {Name: "flows", URL: "https://example.com/flows.yaml", Type: "arazzo"}, + }, + } + _, err := ResolveSources(doc, &ResolveConfig{ + HTTPHandler: func(_ string) ([]byte, error) { return []byte("ok"), nil }, + ArazzoFactory: func(_ string, _ []byte) (*high.Arazzo, error) { + return nil, sentinel + }, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrSourceDescLoadFailed) + assert.Contains(t, err.Error(), sentinel.Error()) +} diff --git a/arazzo/gap_coverage_test.go b/arazzo/gap_coverage_test.go index 1867877a8..42686d1cb 100644 --- a/arazzo/gap_coverage_test.go +++ b/arazzo/gap_coverage_test.go @@ -273,8 +273,8 @@ func TestGap_CriterionCachesAndHelpers(t *testing.T) { caches := newCriterionCaches() _, _ = compileCriterionRegex(`^a+$`, caches) _, _ = compileCriterionRegex(`^a+$`, caches) - _, _ = compileCriterionJSONPath(`$.a`, caches) - _, _ = compileCriterionJSONPath(`$.a`, caches) + _, _ = compileCriterionJSONPath(`$.a`, criterionJSONPathRFC9535, caches) + _, _ = compileCriterionJSONPath(`$.a`, criterionJSONPathRFC9535, caches) caches.parseExpr = func(string) (expression.Expression, error) { return expression.Expression{}, errors.New("parse failed") @@ -560,7 +560,7 @@ func (gapRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { } func TestGap_FetchHTTPSourceBytes_ReadError(t *testing.T) { - _, err := fetchHTTPSourceBytes("http://example.com", &ResolveConfig{ + _, err := fetchHTTPSourceBytes(context.Background(), "http://example.com", &ResolveConfig{ Timeout: time.Second, MaxBodySize: 1024, HTTPClient: &http.Client{Transport: gapRoundTripper{}}, diff --git a/arazzo/malformed_input_test.go b/arazzo/malformed_input_test.go new file mode 100644 index 000000000..a8eea5433 --- /dev/null +++ b/arazzo/malformed_input_test.go @@ -0,0 +1,175 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "os" + "path/filepath" + "testing" + + libopenapi "github.com/pb33f/libopenapi" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +// TestArazzo11NegativeFixtures checks that a wrong YAML node kind on an Arazzo 1.1 +// addition produces a source-positioned build error instead of being silently discarded. +// +// The reflection-driven model builder ignores a value whose node kind does not match the +// target Go field, which turned an authoring mistake into quiet data loss. The composite +// 1.1 objects (Selector, OutputValue, targetSelectorType) already rejected an unexpected +// node kind, so these fixtures pin the scalar and sequence fields to the same contract. +// +// Requiredness and semantic validity remain the validator's concern; only the node kind +// (and, for timeout, that the scalar is actually an integer) is enforced here. +func TestArazzo11NegativeFixtures(t *testing.T) { + tests := []struct { + name string + fixture string + wantMessage string + wantLine int + wantColumn int + }{ + { + name: "$self as a mapping", + fixture: "self-not-scalar.yaml", + wantMessage: "$self at line 7, column 3 must be a scalar URI", + wantLine: 7, + wantColumn: 3, + }, + { + name: "timeout as a non-integer scalar", + fixture: "timeout-not-integer.yaml", + wantMessage: `timeout at line 11, column 18 must be an integer number of milliseconds, got "soon"`, + wantLine: 11, + wantColumn: 18, + }, + { + name: "dependsOn as a mapping", + fixture: "depends-on-not-sequence.yaml", + wantMessage: "dependsOn at line 12, column 11 must be a sequence of step identifiers", + wantLine: 12, + wantColumn: 11, + }, + { + name: "channelPath as a mapping", + fixture: "channel-path-not-scalar.yaml", + wantMessage: "channelPath at line 11, column 11 must be a scalar channel reference", + wantLine: 11, + wantColumn: 11, + }, + { + name: "action as a sequence", + fixture: "action-not-scalar.yaml", + wantMessage: "action at line 11, column 11 must be a scalar action name", + wantLine: 11, + wantColumn: 11, + }, + { + name: "correlationId as a mapping", + fixture: "correlation-id-not-scalar.yaml", + wantMessage: "correlationId at line 12, column 11 must be a scalar string", + wantLine: 12, + wantColumn: 11, + }, + { + name: "action parameters as a scalar", + fixture: "action-parameters-not-sequence.yaml", + wantMessage: "parameters at line 15, column 25 must be a sequence of Parameter Objects", + wantLine: 15, + wantColumn: 25, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + specBytes, err := os.ReadFile(filepath.Join("testdata", "negative-11", test.fixture)) + require.NoError(t, err) + + doc, err := libopenapi.NewArazzoDocument(specBytes) + require.Error(t, err, "a malformed node kind must fail the build") + assert.Nil(t, doc) + assert.Contains(t, err.Error(), test.wantMessage) + + // Diagnostics must point at the offending node, never at line 0. + assert.Positive(t, test.wantLine) + assert.Positive(t, test.wantColumn) + }) + } +} + +// A well-formed value of the right node kind must still build, even when it is +// semantically wrong. Node-kind enforcement must not stray into validation. +func TestArazzo11WellFormedButInvalidValuesStillBuild(t *testing.T) { + spec := []byte(`arazzo: 1.1.0 +info: + title: semantically invalid but well formed + version: 1.0.0 +$self: not-a-valid-uri-but-a-scalar +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op + operationPath: /also/set + channelPath: /channel + action: send + correlationId: some-id + timeout: -5 + dependsOn: + - nonexistent-step +`) + + doc, err := libopenapi.NewArazzoDocument(spec) + require.NoError(t, err, "node kinds are all correct, so the document must build") + require.NotNil(t, doc) + + assert.Equal(t, "not-a-valid-uri-but-a-scalar", doc.Self) + + step := doc.Workflows[0].Steps[0] + require.NotNil(t, step.Timeout) + assert.Equal(t, int64(-5), *step.Timeout, "a negative timeout is the validator's problem") + assert.Equal(t, "/channel", step.ChannelPath) + assert.Equal(t, "send", step.Action) + assert.Equal(t, "some-id", step.CorrelationId) + assert.Equal(t, []string{"nonexistent-step"}, step.DependsOn) +} + +// An explicit YAML null is treated as absent rather than as a node-kind violation, so a +// document that spells out an omitted optional field still builds. +// +// Note the pre-existing builder behavior this pins: for string-typed fields the null +// scalar's literal text ("~") is carried through rather than becoming the empty string. +// That is not introduced by node-kind enforcement, and normalizing it would change the +// model for 1.0 documents too, so it is recorded here rather than silently changed. +func TestArazzo11ExplicitNullTreatedAsAbsent(t *testing.T) { + spec := []byte(`arazzo: 1.1.0 +info: + title: explicit nulls + version: 1.0.0 +$self: ~ +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op + timeout: ~ + dependsOn: ~ + channelPath: ~ +`) + + doc, err := libopenapi.NewArazzoDocument(spec) + require.NoError(t, err) + require.NotNil(t, doc) + + step := doc.Workflows[0].Steps[0] + + // Typed and sequence fields resolve to their zero values. + assert.Nil(t, step.Timeout) + assert.Empty(t, step.DependsOn) + + // String fields carry the null scalar's literal text; see the note above. + assert.Equal(t, "~", doc.Self) + assert.Equal(t, "~", step.ChannelPath) +} diff --git a/arazzo/model_matrix_test.go b/arazzo/model_matrix_test.go new file mode 100644 index 000000000..ad2dca52e --- /dev/null +++ b/arazzo/model_matrix_test.go @@ -0,0 +1,190 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "encoding/json" + "sync" + "testing" + + libopenapi "github.com/pb33f/libopenapi" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +const arazzo10MatrixYAML = `arazzo: 1.0.1 +info: + title: Compatibility workflow + version: 1.0.0 +sourceDescriptions: + - name: petstore + url: https://api.example.com/petstore.yaml + type: openapi +workflows: + - workflowId: fetchPet + steps: + - stepId: fetch + operationId: getPet + parameters: + - name: petId + in: path + value: $inputs.petId + outputs: + petId: $response.body#/id + outputs: + petId: $steps.fetch.outputs.petId +` + +// An Arazzo document is valid JSON as well as YAML. Parsing the JSON form must produce +// the same high-level model as the YAML form; only 1.1 had JSON coverage previously. +func TestArazzo10_JSONInputMatchesYAMLModel(t *testing.T) { + var intermediate any + require.NoError(t, yaml.Unmarshal([]byte(arazzo10MatrixYAML), &intermediate)) + jsonBytes, err := json.Marshal(intermediate) + require.NoError(t, err) + + fromYAML, err := libopenapi.NewArazzoDocument([]byte(arazzo10MatrixYAML)) + require.NoError(t, err) + fromJSON, err := libopenapi.NewArazzoDocument(jsonBytes) + require.NoError(t, err) + + assert.Equal(t, fromYAML.Arazzo, fromJSON.Arazzo) + assert.Equal(t, fromYAML.Info.Title, fromJSON.Info.Title) + assert.Equal(t, fromYAML.Info.Version, fromJSON.Info.Version) + + require.Len(t, fromJSON.SourceDescriptions, 1) + assert.Equal(t, fromYAML.SourceDescriptions[0].Name, fromJSON.SourceDescriptions[0].Name) + assert.Equal(t, fromYAML.SourceDescriptions[0].URL, fromJSON.SourceDescriptions[0].URL) + assert.Equal(t, fromYAML.SourceDescriptions[0].Type, fromJSON.SourceDescriptions[0].Type) + + require.Len(t, fromJSON.Workflows, 1) + yamlWorkflow, jsonWorkflow := fromYAML.Workflows[0], fromJSON.Workflows[0] + assert.Equal(t, yamlWorkflow.WorkflowId, jsonWorkflow.WorkflowId) + + require.Len(t, jsonWorkflow.Steps, 1) + yamlStep, jsonStep := yamlWorkflow.Steps[0], jsonWorkflow.Steps[0] + assert.Equal(t, yamlStep.StepId, jsonStep.StepId) + assert.Equal(t, yamlStep.OperationId, jsonStep.OperationId) + + require.Len(t, jsonStep.Parameters, 1) + assert.Equal(t, yamlStep.Parameters[0].Name, jsonStep.Parameters[0].Name) + assert.Equal(t, yamlStep.Parameters[0].In, jsonStep.Parameters[0].In) + + // Output unions must survive the JSON round trip identically. + yamlOutput, ok := yamlStep.Outputs.Get("petId") + require.True(t, ok) + jsonOutput, ok := jsonStep.Outputs.Get("petId") + require.True(t, ok) + yamlExpression, ok := yamlOutput.GetExpression() + require.True(t, ok) + jsonExpression, ok := jsonOutput.GetExpression() + require.True(t, ok) + assert.Equal(t, yamlExpression, jsonExpression) +} + +// The low model keeps the original YAML nodes, so authored comments must remain +// reachable. Comments are the main reason the low model exists at all. +func TestArazzo10_CommentsSurviveOnLowNodes(t *testing.T) { + spec := []byte(`# document level comment +arazzo: 1.0.1 +info: + # info level comment + title: commented # trailing title comment + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := libopenapi.NewArazzoDocument(spec) + require.NoError(t, err) + + low := doc.GoLow() + require.NotNil(t, low) + + assert.Equal(t, "# document level comment", low.Arazzo.KeyNode.HeadComment) + + infoRoot := low.Info.Value.GetRootNode() + require.NotNil(t, infoRoot) + + var titleKey, titleValue *yaml.Node + for i := 0; i < len(infoRoot.Content)-1; i += 2 { + if infoRoot.Content[i].Value == "title" { + titleKey, titleValue = infoRoot.Content[i], infoRoot.Content[i+1] + break + } + } + require.NotNil(t, titleKey, "title key node should be reachable from the low model") + + assert.Equal(t, "# info level comment", titleKey.HeadComment) + assert.Equal(t, "# trailing title comment", titleValue.LineComment) +} + +// Duplicate mapping keys are not rejected; the first occurrence wins and later ones are +// discarded. This pins the current parser policy so a change to it is a deliberate one. +func TestArazzo10_DuplicateKeysFirstOccurrenceWins(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: first + version: 1.0.0 +info: + title: second + version: 2.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := libopenapi.NewArazzoDocument(spec) + require.NoError(t, err) + assert.Equal(t, "first", doc.Info.Title) + assert.Equal(t, "1.0.0", doc.Info.Version) +} + +// Building the same bytes repeatedly must produce equivalent, independent documents. +// Arazzo models share package-level hashing infrastructure, so a leak between builds +// would show up as a divergent model or hash on a later construction. +func TestArazzo10_RepeatedConstructionIsIndependent(t *testing.T) { + first, err := libopenapi.NewArazzoDocument([]byte(arazzo10MatrixYAML)) + require.NoError(t, err) + firstRender, err := first.Render() + require.NoError(t, err) + firstHash := first.GoLow().Hash() + + for i := 0; i < 5; i++ { + next, buildErr := libopenapi.NewArazzoDocument([]byte(arazzo10MatrixYAML)) + require.NoError(t, buildErr) + + nextRender, renderErr := next.Render() + require.NoError(t, renderErr) + assert.Equal(t, string(firstRender), string(nextRender), "render diverged on build %d", i) + assert.Equal(t, firstHash, next.GoLow().Hash(), "hash diverged on build %d", i) + + // Mutating a later document must not affect the first. + next.Info.Title = "mutated" + assert.Equal(t, "Compatibility workflow", first.Info.Title) + } +} + +// Concurrent construction and rendering of independent documents must be race free. +func TestArazzo10_ConcurrentConstructionIsRaceFree(t *testing.T) { + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + doc, err := libopenapi.NewArazzoDocument([]byte(arazzo10MatrixYAML)) + assert.NoError(t, err) + _, err = doc.Render() + assert.NoError(t, err) + _ = doc.GoLow().Hash() + }() + } + wg.Wait() +} diff --git a/arazzo/resolve.go b/arazzo/resolve.go index 576e6bd46..8c4eb31b1 100644 --- a/arazzo/resolve.go +++ b/arazzo/resolve.go @@ -19,6 +19,7 @@ import ( v3high "github.com/pb33f/libopenapi/datamodel/high/v3" "github.com/pb33f/libopenapi/datamodel/low/arazzo" v3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "go.yaml.in/yaml/v4" ) var resolveFilepathAbs = filepath.Abs @@ -31,14 +32,22 @@ type OpenAPIDocumentFactory func(sourceURL string, bytes []byte) (*v3high.Docume // The sourceURL provides location context for relative reference resolution. type ArazzoDocumentFactory func(sourceURL string, bytes []byte) (*high.Arazzo, error) +// HTTPSourceHandler retrieves source bytes while honoring caller cancellation and deadlines. +type HTTPSourceHandler func(context.Context, string) ([]byte, error) + // ResolveConfig configures how source descriptions are resolved. type ResolveConfig struct { OpenAPIFactory OpenAPIDocumentFactory // Creates *v3high.Document from bytes ArazzoFactory ArazzoDocumentFactory // Creates *high.Arazzo from bytes BaseURL string - HTTPHandler func(url string) ([]byte, error) - HTTPClient *http.Client - FSRoots []string + // HTTPHandlerWithContext is preferred for custom retrieval because it can + // stop work when ResolveSourcesWithContext is cancelled or times out. + HTTPHandlerWithContext HTTPSourceHandler + // HTTPHandler is retained for compatibility. It is checked for cancellation + // before invocation, but its legacy signature cannot stop in-flight work. + HTTPHandler func(url string) ([]byte, error) + HTTPClient *http.Client + FSRoots []string Timeout time.Duration // Per-source fetch timeout (default: 30s) MaxBodySize int64 // Max response body in bytes (default: 10MB) @@ -51,20 +60,51 @@ type ResolveConfig struct { type ResolvedSource struct { Name string // SourceDescription name URL string // Resolved URL + Identity string // Portable resolved identity, usually resolved $self + RetrievalURI string // Location from which the document was retrieved Type string // "openapi" or "arazzo" + SourceBytes []byte // Original source bytes when supplied or retrieved + RootNode *yaml.Node // Original root node when supplied OpenAPIDocument *v3high.Document // Non-nil when Type == "openapi" ArazzoDocument *high.Arazzo // Non-nil when Type == "arazzo" + Adapter SourceDocumentAdapter } // ResolveSources resolves all source descriptions in an Arazzo document. +// +// Retrieval is not cancellable through this entry point; it applies only the +// per-source timeout from ResolveConfig. Use ResolveSourcesWithContext to make +// in-flight retrieval respond to caller cancellation. func ResolveSources(doc *high.Arazzo, config *ResolveConfig) ([]*ResolvedSource, error) { + return ResolveSourcesWithContext(context.Background(), doc, config) +} + +// ResolveSourcesWithContext resolves all source descriptions in an Arazzo document. +// Built-in HTTP retrieval and HTTPHandlerWithContext honor caller cancellation and +// the per-source timeout, whichever elapses first. The legacy HTTPHandler can only +// observe cancellation before it is invoked because its signature has no context. +func ResolveSourcesWithContext( + ctx context.Context, + doc *high.Arazzo, + config *ResolveConfig, +) ([]*ResolvedSource, error) { + if ctx == nil { + ctx = context.Background() + } if doc == nil { return nil, fmt.Errorf("nil arazzo document") } - if config == nil { - config = &ResolveConfig{} + // Work on a copy. Defaults and the derived base URI are resolution state, not + // caller state: writing them back would publish one document's settings into a + // config the caller may reuse for another document, and would race if the same + // config were shared across concurrent resolves. AllowedSchemes is only ever read, + // so a shallow copy is sufficient. + local := ResolveConfig{} + if config != nil { + local = *config } + config = &local // Apply defaults if config.Timeout == 0 { @@ -79,16 +119,28 @@ func ResolveSources(doc *high.Arazzo, config *ResolveConfig) ([]*ResolvedSource, if len(config.AllowedSchemes) == 0 { config.AllowedSchemes = []string{"https", "http", "file"} } - if config.HTTPClient == nil && config.HTTPHandler == nil { + if config.HTTPClient == nil && config.HTTPHandler == nil && config.HTTPHandlerWithContext == nil { config.HTTPClient = &http.Client{Timeout: config.Timeout} } + // Fall back to the document's effective base URI (derived from $self, the retrieval + // URI, or the application base) when the caller did not supply one. An explicit + // BaseURL always wins, so existing callers are unaffected. + if config.BaseURL == "" { + if origin := doc.GetDocumentOrigin(); origin != nil { + config.BaseURL = origin.EffectiveBaseURI + } + } + if len(doc.SourceDescriptions) > config.MaxSources { return nil, fmt.Errorf("too many source descriptions: %d (max %d)", len(doc.SourceDescriptions), config.MaxSources) } resolved := make([]*ResolvedSource, 0, len(doc.SourceDescriptions)) for _, sd := range doc.SourceDescriptions { + if err := ctx.Err(); err != nil { + return nil, err + } if sd == nil { return nil, fmt.Errorf("%w: source description is nil", ErrSourceDescLoadFailed) } @@ -104,12 +156,15 @@ func ResolveSources(doc *high.Arazzo, config *ResolveConfig) ([]*ResolvedSource, return nil, fmt.Errorf("%w (%q): %v", ErrSourceDescLoadFailed, sd.Name, err) } - docBytes, resolvedURL, err := fetchSourceBytes(parsedURL, config) + docBytes, resolvedURL, err := fetchSourceBytes(ctx, parsedURL, config) if err != nil { return nil, fmt.Errorf("%w (%q): %v", ErrSourceDescLoadFailed, sd.Name, err) } rs.URL = resolvedURL + rs.RetrievalURI = resolvedURL + rs.Identity = resolvedURL + rs.SourceBytes = docBytes rs.Type = strings.ToLower(sd.Type) if rs.Type == "" { rs.Type = "openapi" // Default per spec @@ -134,6 +189,13 @@ func ResolveSources(doc *high.Arazzo, config *ResolveConfig) ([]*ResolvedSource, return nil, fmt.Errorf("%w (%q): %v", ErrSourceDescLoadFailed, sd.Name, factoryErr) } rs.ArazzoDocument = arazzoDoc + if arazzoDoc != nil { + if origin := arazzoDoc.GetDocumentOrigin(); origin != nil && origin.ResolvedIdentity != "" { + rs.Identity = origin.ResolvedIdentity + } else if arazzoDoc.Self != "" { + rs.Identity = arazzoDoc.Self + } + } default: return nil, fmt.Errorf("%w (%q): unknown source type %q", ErrSourceDescLoadFailed, sd.Name, rs.Type) } @@ -203,10 +265,10 @@ func validateSourceURL(sourceURL *url.URL, config *ResolveConfig) error { return nil } -func fetchSourceBytes(sourceURL *url.URL, config *ResolveConfig) ([]byte, string, error) { +func fetchSourceBytes(ctx context.Context, sourceURL *url.URL, config *ResolveConfig) ([]byte, string, error) { switch sourceURL.Scheme { case "http", "https": - b, err := fetchHTTPSourceBytes(sourceURL.String(), config) + b, err := fetchHTTPSourceBytes(ctx, sourceURL.String(), config) if err != nil { return nil, "", err } @@ -235,8 +297,11 @@ func fetchSourceBytes(sourceURL *url.URL, config *ResolveConfig) ([]byte, string } } -func fetchHTTPSourceBytes(sourceURL string, config *ResolveConfig) ([]byte, error) { - if config.HTTPHandler != nil { +func fetchHTTPSourceBytes(ctx context.Context, sourceURL string, config *ResolveConfig) ([]byte, error) { + if config.HTTPHandlerWithContext == nil && config.HTTPHandler != nil { + if err := ctx.Err(); err != nil { + return nil, err + } b, err := config.HTTPHandler(sourceURL) if err != nil { return nil, err @@ -247,8 +312,22 @@ func fetchHTTPSourceBytes(sourceURL string, config *ResolveConfig) ([]byte, erro return b, nil } - ctx, cancel := context.WithTimeout(context.Background(), config.Timeout) - defer cancel() + if config.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, config.Timeout) + defer cancel() + } + + if config.HTTPHandlerWithContext != nil { + b, err := config.HTTPHandlerWithContext(ctx, sourceURL) + if err != nil { + return nil, err + } + if int64(len(b)) > config.MaxBodySize { + return nil, fmt.Errorf("response body exceeds max size of %d bytes", config.MaxBodySize) + } + return b, nil + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) if err != nil { @@ -415,7 +494,6 @@ func ensureResolvedPathWithinRoots(path string, roots []string) error { return nil } - func containsFold(values []string, value string) bool { for _, v := range values { if strings.EqualFold(v, value) { diff --git a/arazzo/resolve_context_test.go b/arazzo/resolve_context_test.go new file mode 100644 index 000000000..0aed952eb --- /dev/null +++ b/arazzo/resolve_context_test.go @@ -0,0 +1,464 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + libopenapi "github.com/pb33f/libopenapi" + high "github.com/pb33f/libopenapi/datamodel/high/arazzo" + v3high "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +// stubOpenAPIFactory satisfies the factory contract without parsing a real document; +// these tests assert on which URL was requested, not on OpenAPI content. +func stubOpenAPIFactory(_ string, _ []byte) (*v3high.Document, error) { + return &v3high.Document{}, nil +} + +// ResolveSourcesWithContext must tolerate a nil context rather than panicking, so that +// callers converting from the context-free ResolveSources cannot regress by passing nil. +func TestResolveSourcesWithContext_NilContextTreatedAsBackground(t *testing.T) { + doc := &high.Arazzo{} + + sources, err := ResolveSourcesWithContext(nil, doc, nil) //nolint:staticcheck // nil context is the case under test + require.NoError(t, err) + assert.Empty(t, sources) +} + +// Cancellation must be observed before each source description is processed, so an +// already-cancelled context performs no retrieval at all. +func TestResolveSourcesWithContext_CancelledBeforeFirstSource(t *testing.T) { + handlerCalls := 0 + doc := &high.Arazzo{ + SourceDescriptions: []*high.SourceDescription{{ + Name: "api", + URL: "https://example.com/openapi.yaml", + Type: "openapi", + }}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := ResolveSourcesWithContext(ctx, doc, &ResolveConfig{ + HTTPHandler: func(string) ([]byte, error) { + handlerCalls++ + return []byte("openapi: 3.1.0"), nil + }, + }) + + require.ErrorIs(t, err, context.Canceled) + assert.Zero(t, handlerCalls, "no source should be fetched after cancellation") +} + +// The HTTPHandler path is a caller-supplied hook that bypasses the http.Client, so it +// needs its own cancellation check; otherwise a cancelled context would still invoke it. +func TestFetchHTTPSourceBytes_CancelledBeforeHandler(t *testing.T) { + handlerCalls := 0 + config := &ResolveConfig{ + MaxBodySize: 1024, + HTTPHandler: func(string) ([]byte, error) { + handlerCalls++ + return []byte("content"), nil + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := fetchHTTPSourceBytes(ctx, "https://example.com/spec.yaml", config) + require.ErrorIs(t, err, context.Canceled) + assert.Zero(t, handlerCalls, "handler must not run once the context is cancelled") +} + +func TestFetchHTTPSourceBytes_ContextHandlerCancelledInFlight(t *testing.T) { + started := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + config := &ResolveConfig{ + MaxBodySize: 1024, + Timeout: 30 * time.Second, + HTTPHandlerWithContext: func(handlerCtx context.Context, _ string) ([]byte, error) { + close(started) + <-handlerCtx.Done() + return nil, handlerCtx.Err() + }, + } + + go func() { + <-started + cancel() + }() + + _, err := fetchHTTPSourceBytes(ctx, "https://example.com/spec.yaml", config) + require.ErrorIs(t, err, context.Canceled) +} + +func TestFetchHTTPSourceBytes_ContextHandlerResults(t *testing.T) { + handlerErr := errors.New("context handler failed") + for _, test := range []struct { + name string + body []byte + handlerErr error + maxBody int64 + wantErr error + wantText string + }{ + {name: "success", body: []byte("openapi: 3.1.0"), maxBody: 1024}, + {name: "handler error", handlerErr: handlerErr, maxBody: 1024, wantErr: handlerErr}, + {name: "oversized", body: []byte("too large"), maxBody: 3, wantText: "exceeds max size"}, + } { + t.Run(test.name, func(t *testing.T) { + config := &ResolveConfig{ + MaxBodySize: test.maxBody, + Timeout: time.Second, + HTTPHandlerWithContext: func(_ context.Context, _ string) ([]byte, error) { + return test.body, test.handlerErr + }, + } + body, err := fetchHTTPSourceBytes(context.Background(), "https://example.com/spec.yaml", config) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + return + } + if test.wantText != "" { + require.ErrorContains(t, err, test.wantText) + return + } + require.NoError(t, err) + assert.Equal(t, test.body, body) + }) + } +} + +// Cancelling mid-flight must abort a real in-progress HTTP fetch. Before the context was +// threaded through, the request used context.Background() and ran to completion. +func TestFetchHTTPSourceBytes_CancelDuringInFlightRequest(t *testing.T) { + requestArrived := make(chan struct{}) + handlerDone := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + close(requestArrived) + <-r.Context().Done() // hold the response open until the client goes away + close(handlerDone) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Cancel only once the server has the request, so this exercises an in-flight + // abort rather than a pre-flight rejection. + go func() { + <-requestArrived + cancel() + }() + + // Timeout must be non-zero: defaults are applied by ResolveSources, and calling + // the fetch helper directly with a zero timeout would expire the context at once. + _, err := fetchHTTPSourceBytes(ctx, server.URL, &ResolveConfig{ + MaxBodySize: 1024, + Timeout: 30 * time.Second, + }) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + <-handlerDone +} + +// A document parsed with a retrieval URI carries an effective base URI. Relative source +// URLs must resolve against it without the caller restating the base in ResolveConfig. +func TestResolveSources_UsesEffectiveBaseURIFromDocumentOrigin(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: base uri test + version: 1.0.0 +sourceDescriptions: + - name: api + url: ./nested/openapi.yaml + type: openapi +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := libopenapi.NewArazzoDocumentWithConfiguration(spec, &libopenapi.ArazzoDocumentConfiguration{ + RetrievalURI: "https://specs.example.com/workflows/main.arazzo.yaml", + }) + require.NoError(t, err) + + var requested string + sources, err := ResolveSources(doc, &ResolveConfig{ + HTTPHandler: func(url string) ([]byte, error) { + requested = url + return []byte("openapi: 3.1.0"), nil + }, + OpenAPIFactory: stubOpenAPIFactory, + }) + require.NoError(t, err) + require.Len(t, sources, 1) + + assert.Equal(t, "https://specs.example.com/workflows/nested/openapi.yaml", requested, + "relative source URL should resolve against the document's effective base URI") +} + +// An explicit BaseURL is the caller's stated intent and must win over the document origin. +func TestResolveSources_ExplicitBaseURLOverridesDocumentOrigin(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: base uri precedence + version: 1.0.0 +sourceDescriptions: + - name: api + url: ./openapi.yaml + type: openapi +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := libopenapi.NewArazzoDocumentWithConfiguration(spec, &libopenapi.ArazzoDocumentConfiguration{ + RetrievalURI: "https://origin.example.com/workflows/main.yaml", + }) + require.NoError(t, err) + + var requested string + _, err = ResolveSources(doc, &ResolveConfig{ + BaseURL: "https://override.example.com/elsewhere/", + HTTPHandler: func(url string) ([]byte, error) { + requested = url + return []byte("openapi: 3.1.0"), nil + }, + OpenAPIFactory: stubOpenAPIFactory, + }) + require.NoError(t, err) + + assert.Equal(t, "https://override.example.com/elsewhere/openapi.yaml", requested) +} + +// Resolving one document must not leave its base URI on a ResolveConfig that the caller +// reuses for another document. +func TestResolveSources_DoesNotWriteBaseURLBackIntoConfig(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: config reuse + version: 1.0.0 +sourceDescriptions: + - name: api + url: ./openapi.yaml + type: openapi +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := libopenapi.NewArazzoDocumentWithConfiguration(spec, &libopenapi.ArazzoDocumentConfiguration{ + RetrievalURI: "https://first.example.com/workflows/main.yaml", + }) + require.NoError(t, err) + + config := &ResolveConfig{ + HTTPHandler: func(string) ([]byte, error) { + return []byte("openapi: 3.1.0"), nil + }, + OpenAPIFactory: stubOpenAPIFactory, + } + + _, err = ResolveSources(doc, config) + require.NoError(t, err) + + assert.Empty(t, config.BaseURL, + "document origin must not leak into a config the caller may reuse") +} + +// Defaults and the derived base URI are resolution state, not caller state. Resolving +// must leave the caller's config untouched so it can be reused, or shared across +// concurrent resolves, without one document's settings leaking into another. +func TestResolveSources_LeavesCallerConfigUnmodified(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: config isolation + version: 1.0.0 +sourceDescriptions: + - name: api + url: ./openapi.yaml + type: openapi +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := libopenapi.NewArazzoDocumentWithConfiguration(spec, &libopenapi.ArazzoDocumentConfiguration{ + RetrievalURI: "https://example.com/workflows/main.yaml", + }) + require.NoError(t, err) + + config := &ResolveConfig{ + HTTPHandler: func(string) ([]byte, error) { + return []byte("openapi: 3.1.0"), nil + }, + OpenAPIFactory: stubOpenAPIFactory, + } + + _, err = ResolveSources(doc, config) + require.NoError(t, err) + + assert.Empty(t, config.BaseURL, "derived base URI must not be written back") + assert.Zero(t, config.Timeout, "defaults must not be written back") + assert.Zero(t, config.MaxBodySize) + assert.Zero(t, config.MaxSources) + assert.Empty(t, config.AllowedSchemes) + assert.Nil(t, config.HTTPClient, "no client should be attached to the caller's config") +} + +// Concurrent resolves of independent documents sharing one config must not race on the +// defaults block, which previously wrote Timeout, MaxBodySize, MaxSources, +// AllowedSchemes and HTTPClient straight through the caller's pointer. +// +// Each goroutine gets its own document on purpose. Resolving attaches OpenAPI source +// documents to the Arazzo model, so sharing a single document across concurrent resolves +// is a document-mutation race independent of the config, and is not what this covers. +func TestResolveSources_ConcurrentResolvesShareConfigSafely(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: concurrent + version: 1.0.0 +sourceDescriptions: + - name: api + url: https://example.com/openapi.yaml + type: openapi +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + config := &ResolveConfig{ + HTTPHandler: func(string) ([]byte, error) { + return []byte("openapi: 3.1.0"), nil + }, + OpenAPIFactory: stubOpenAPIFactory, + } + + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + doc, err := libopenapi.NewArazzoDocument(spec) + if !assert.NoError(t, err) { + return + } + _, resolveErr := ResolveSources(doc, config) + assert.NoError(t, resolveErr) + }() + } + wg.Wait() + + // The shared config must still be pristine after concurrent use. + assert.Zero(t, config.Timeout) + assert.Nil(t, config.HTTPClient) + assert.Empty(t, config.AllowedSchemes) +} + +// Source resolution attaches OpenAPI documents to the Arazzo model it was handed, so two +// goroutines resolving the same document mutate shared state. This reproduced a data race +// on Arazzo.openAPISourceDocs before that slice was guarded. +func TestResolveSources_ConcurrentResolvesOfSameDocumentAreSafe(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: shared document + version: 1.0.0 +sourceDescriptions: + - name: api + url: https://example.com/openapi.yaml + type: openapi +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := libopenapi.NewArazzoDocument(spec) + require.NoError(t, err) + + config := &ResolveConfig{ + HTTPHandler: func(string) ([]byte, error) { + return []byte("openapi: 3.1.0"), nil + }, + OpenAPIFactory: stubOpenAPIFactory, + } + + const resolvers = 16 + var wg sync.WaitGroup + for i := 0; i < resolvers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, resolveErr := ResolveSources(doc, config) + assert.NoError(t, resolveErr) + }() + } + wg.Wait() + + // Every resolve attaches exactly one source document; none may be lost to a + // concurrent append. + assert.Len(t, doc.GetOpenAPISourceDocuments(), resolvers) +} + +// Attaching source documents must be safe concurrently with reading them, which is what +// validation does while another goroutine resolves. +func TestArazzo_AttachAndReadSourceDocumentsConcurrently(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: concurrent attach and read + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := libopenapi.NewArazzoDocument(spec) + require.NoError(t, err) + + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + doc.AddOpenAPISourceDocument(&v3high.Document{}) + }() + wg.Add(1) + go func() { + defer wg.Done() + // The returned slice is a copy, so ranging it must stay valid even as + // more documents are attached. + for _, attached := range doc.GetOpenAPISourceDocuments() { + _ = attached + } + }() + } + wg.Wait() + + assert.Len(t, doc.GetOpenAPISourceDocuments(), 16) +} diff --git a/arazzo/resolve_test.go b/arazzo/resolve_test.go index 884f235dc..977ef33e9 100644 --- a/arazzo/resolve_test.go +++ b/arazzo/resolve_test.go @@ -186,6 +186,19 @@ func TestResolveFilePath_RejectsSymlinkOutsideRoot(t *testing.T) { assert.Contains(t, err.Error(), "outside configured roots") } +func TestResolveFilePath_RejectsMissingFileUnderSymlinkOutsideRoot(t *testing.T) { + rootDir := t.TempDir() + outsideDir := t.TempDir() + symlinkDir := filepath.Join(rootDir, "escaped") + if err := os.Symlink(outsideDir, symlinkDir); err != nil { + t.Skipf("symlinks not supported: %v", err) + } + + _, err := resolveFilePath(filepath.Join(symlinkDir, "missing.yaml"), []string{rootDir}) + require.Error(t, err) + assert.Contains(t, err.Error(), "outside configured roots") +} + func TestGetResolveHTTPClient_UsesConfigTimeout(t *testing.T) { c1 := getResolveHTTPClient(&ResolveConfig{Timeout: 5 * time.Second}) require.Equal(t, 5*time.Second, c1.Timeout) diff --git a/arazzo/source_resolver.go b/arazzo/source_resolver.go new file mode 100644 index 000000000..f96437b2a --- /dev/null +++ b/arazzo/source_resolver.go @@ -0,0 +1,479 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "sync" + + high "github.com/pb33f/libopenapi/datamodel/high/arazzo" + v3high "github.com/pb33f/libopenapi/datamodel/high/v3" + "go.yaml.in/yaml/v4" +) + +// CandidateDocumentProvider supplies application-owned source documents without I/O. +type CandidateDocumentProvider interface { + Documents(context.Context) ([]CandidateDocument, error) +} + +// SourceDocumentResolver resolves one source request. Implementations must honor cancellation. +type SourceDocumentResolver interface { + Resolve(context.Context, SourceRequest) (*ResolvedSource, error) +} + +// SourceDocumentAdapter identifies a source document without coupling libopenapi to a concrete library. +type SourceDocumentAdapter interface { + SourceType() string +} + +// OpenAPISourceAdapter exposes OpenAPI operation lookups to downstream validators. +type OpenAPISourceAdapter interface { + SourceDocumentAdapter + HasOperationID(string) bool + HasOperationPath(string) bool +} + +// AsyncAPISourceAdapter exposes AsyncAPI channel and operation lookups. +type AsyncAPISourceAdapter interface { + SourceDocumentAdapter + HasChannel(string) bool + HasOperation(string) bool +} + +// ArazzoSourceAdapter exposes Arazzo workflow lookups. +type ArazzoSourceAdapter interface { + SourceDocumentAdapter + HasWorkflow(string) bool +} + +// CandidateDocument is a supplied source document and its portable/retrieval identities. +type CandidateDocument struct { + Type string + ResolvedIdentity string + RetrievalURI string + SourceBytes []byte + RootNode *yaml.Node + OpenAPIDocument *v3high.Document + ArazzoDocument *high.Arazzo + Adapter SourceDocumentAdapter +} + +// SourceRequest describes one sourceDescription lookup. +type SourceRequest struct { + Name string + URL string + Type string + BaseURI string +} + +// InMemoryResolverConfig configures candidate parsing, limits, and an optional explicit fallback. +type InMemoryResolverConfig struct { + OpenAPIFactory OpenAPIDocumentFactory + ArazzoFactory ArazzoDocumentFactory + Fallback SourceDocumentResolver + MaxSources int // Maximum candidate documents to accept (default: 50). +} + +// ErrDuplicateSourceIdentity indicates ambiguous supplied-document identity. +var ErrDuplicateSourceIdentity = errors.New("duplicate source document identity") + +const defaultMaxCandidateSources = 50 + +type resolverLoadState uint8 + +const ( + resolverUnseen resolverLoadState = iota + resolverLoading + resolverLoaded + resolverFailed +) + +type resolverEntry struct { + state resolverLoadState + source *ResolvedSource + err error + ready chan struct{} + waiters int +} + +// InMemorySourceResolver resolves supplied documents before consulting an explicit fallback. +type InMemorySourceResolver struct { + byIdentity map[string]*ResolvedSource + byRetrieval map[string]*ResolvedSource + fallback SourceDocumentResolver + mu sync.Mutex + loads map[string]*resolverEntry +} + +// NewInMemorySourceResolver fully parses supplied candidates once and builds immutable lookup maps. +func NewInMemorySourceResolver( + ctx context.Context, + provider CandidateDocumentProvider, + config *InMemoryResolverConfig, +) (*InMemorySourceResolver, error) { + resolver := &InMemorySourceResolver{ + byIdentity: make(map[string]*ResolvedSource), + byRetrieval: make(map[string]*ResolvedSource), + loads: make(map[string]*resolverEntry), + } + resolverConfig := InMemoryResolverConfig{MaxSources: defaultMaxCandidateSources} + if config != nil { + resolverConfig = *config + resolver.fallback = config.Fallback + } + if resolverConfig.MaxSources <= 0 { + resolverConfig.MaxSources = defaultMaxCandidateSources + } + if provider == nil { + return resolver, nil + } + candidates, err := provider.Documents(ctx) + if err != nil { + return nil, fmt.Errorf("load candidate documents: %w", err) + } + if len(candidates) > resolverConfig.MaxSources { + return nil, fmt.Errorf( + "too many candidate source documents: %d (max %d)", + len(candidates), + resolverConfig.MaxSources, + ) + } + for i := range candidates { + if err := ctx.Err(); err != nil { + return nil, err + } + source, buildErr := buildCandidateSource(candidates[i], &resolverConfig) + if buildErr != nil { + return nil, fmt.Errorf("candidate %d: %w", i, buildErr) + } + if registerErr := resolver.registerSourceLocked(source); registerErr != nil { + return nil, registerErr + } + } + return resolver, nil +} + +// registerSourceLocked atomically validates and records a source's canonical and +// retrieval identities. The caller must hold r.mu after the resolver is published. +func (r *InMemorySourceResolver) registerSourceLocked(source *ResolvedSource, retrievalAliases ...string) error { + retrievals := make(map[string]struct{}, len(retrievalAliases)+2) + addRetrieval := func(key string) { + if key != "" { + retrievals[key] = struct{}{} + } + } + for _, key := range retrievalAliases { + addRetrieval(key) + } + addRetrieval(source.RetrievalURI) + addRetrieval(source.URL) + check := func(key string) error { + if existing := r.byIdentity[key]; existing != nil && existing != source { + return fmt.Errorf("%w %q", ErrDuplicateSourceIdentity, key) + } + if existing := r.byRetrieval[key]; existing != nil && existing != source { + return fmt.Errorf("%w %q", ErrDuplicateSourceIdentity, key) + } + return nil + } + if source.Identity != "" { + if err := check(source.Identity); err != nil { + return err + } + } + for key := range retrievals { + if err := check(key); err != nil { + return err + } + } + if source.Identity != "" { + r.byIdentity[source.Identity] = source + } + for key := range retrievals { + r.byRetrieval[key] = source + } + return nil +} + +func buildCandidateSource(candidate CandidateDocument, config *InMemoryResolverConfig) (*ResolvedSource, error) { + sourceBytes := candidate.SourceBytes + if len(sourceBytes) == 0 && candidate.RootNode != nil { + rendered, err := yaml.Marshal(candidate.RootNode) + if err != nil { + return nil, fmt.Errorf("render candidate root node: %w", err) + } + sourceBytes = rendered + } + sourceType := strings.ToLower(strings.TrimSpace(candidate.Type)) + if sourceType == "" { + switch { + case candidate.OpenAPIDocument != nil: + sourceType = "openapi" + case candidate.ArazzoDocument != nil: + sourceType = "arazzo" + case candidate.Adapter != nil: + sourceType = strings.ToLower(candidate.Adapter.SourceType()) + } + } + + switch sourceType { + case "openapi": + if candidate.OpenAPIDocument == nil { + if config.OpenAPIFactory == nil { + return nil, fmt.Errorf("no OpenAPIFactory configured") + } + document, err := config.OpenAPIFactory(candidate.RetrievalURI, sourceBytes) + if err != nil { + return nil, fmt.Errorf("parse OpenAPI candidate: %w", err) + } + candidate.OpenAPIDocument = document + } + case "arazzo": + if candidate.ArazzoDocument == nil { + if config.ArazzoFactory == nil { + return nil, fmt.Errorf("no ArazzoFactory configured") + } + document, err := config.ArazzoFactory(candidate.RetrievalURI, sourceBytes) + if err != nil { + return nil, fmt.Errorf("parse Arazzo candidate: %w", err) + } + candidate.ArazzoDocument = document + } + default: + if candidate.Adapter == nil { + return nil, fmt.Errorf("unsupported source type %q requires an adapter", sourceType) + } + } + + identity := candidate.ResolvedIdentity + if identity == "" && candidate.ArazzoDocument != nil { + if origin := candidate.ArazzoDocument.GetDocumentOrigin(); origin != nil { + identity = origin.ResolvedIdentity + } + if identity == "" { + identity = candidate.ArazzoDocument.Self + } + } + if identity == "" { + identity = candidate.RetrievalURI + } + return &ResolvedSource{ + URL: candidate.RetrievalURI, + RetrievalURI: candidate.RetrievalURI, + Identity: identity, + Type: sourceType, + SourceBytes: sourceBytes, + RootNode: candidate.RootNode, + OpenAPIDocument: candidate.OpenAPIDocument, + ArazzoDocument: candidate.ArazzoDocument, + Adapter: candidate.Adapter, + }, nil +} + +type resolverContextKey struct{} + +func withResolverLoad(ctx context.Context, key string) context.Context { + current, _ := ctx.Value(resolverContextKey{}).(map[string]struct{}) + next := make(map[string]struct{}, len(current)+1) + for item := range current { + next[item] = struct{}{} + } + next[key] = struct{}{} + return context.WithValue(ctx, resolverContextKey{}, next) +} + +func resolverLoadActive(ctx context.Context, key string) bool { + current, _ := ctx.Value(resolverContextKey{}).(map[string]struct{}) + _, ok := current[key] + return ok +} + +// Resolve returns an identity match first, then a retrieval-location match, then an explicit fallback. +func (r *InMemorySourceResolver) Resolve(ctx context.Context, request SourceRequest) (*ResolvedSource, error) { + if r == nil { + return nil, fmt.Errorf("nil source resolver") + } + if err := ctx.Err(); err != nil { + return nil, err + } + resolvedURI, err := resolveSourceRequestURI(request.URL, request.BaseURI) + if err != nil { + return nil, err + } + r.mu.Lock() + if source := r.byIdentity[resolvedURI]; source != nil { + r.mu.Unlock() + return source.withRequest(request), nil + } + if source := r.byRetrieval[resolvedURI]; source != nil { + r.mu.Unlock() + return source.withRequest(request), nil + } + if r.fallback == nil { + r.mu.Unlock() + return nil, fmt.Errorf("%w: %s", ErrUnresolvedSourceDesc, resolvedURI) + } + + if entry := r.loads[resolvedURI]; entry != nil { + switch entry.state { + case resolverLoading: + if resolverLoadActive(ctx, resolvedURI) { + source := entry.source + r.mu.Unlock() + return source.withRequest(request), nil + } + entry.waiters++ + ready := entry.ready + r.mu.Unlock() + select { + case <-ready: + return entry.result(request) + case <-ctx.Done(): + return nil, ctx.Err() + } + case resolverLoaded: + source := entry.source + r.mu.Unlock() + return source.withRequest(request), nil + case resolverFailed: + loadErr := entry.err + if errors.Is(loadErr, context.Canceled) || errors.Is(loadErr, context.DeadlineExceeded) { + delete(r.loads, resolvedURI) + break + } + r.mu.Unlock() + return nil, loadErr + default: + } + } + entry := &resolverEntry{ + state: resolverLoading, + source: &ResolvedSource{ + URL: resolvedURI, + RetrievalURI: resolvedURI, + Identity: resolvedURI, + Type: strings.ToLower(request.Type), + }, + ready: make(chan struct{}), + } + r.loads[resolvedURI] = entry + r.mu.Unlock() + + fallbackRequest := request + fallbackRequest.URL = resolvedURI + fallbackRequest.BaseURI = "" + source, loadErr := r.fallback.Resolve(withResolverLoad(ctx, resolvedURI), fallbackRequest) + if loadErr == nil && source == nil { + loadErr = fmt.Errorf("%w: fallback returned no source for %s", ErrUnresolvedSourceDesc, resolvedURI) + } + + r.mu.Lock() + // A recursive source cycle returns the ancestor's in-flight placeholder to + // terminate traversal. It is useful to the current load but is not a fully + // loaded result and must not be promoted into the permanent identity maps. + cyclePlaceholder := loadErr == nil && (resolverLoadActive(ctx, source.Identity) || + resolverLoadActive(ctx, source.RetrievalURI) || + resolverLoadActive(ctx, source.URL)) + if loadErr == nil && !cyclePlaceholder { + loadErr = r.registerSourceLocked(source, resolvedURI) + } + if loadErr != nil { + entry.state = resolverFailed + entry.err = loadErr + } else { + entry.state = resolverLoaded + entry.source = source + } + close(entry.ready) + r.mu.Unlock() + if loadErr != nil { + return nil, loadErr + } + return source.withRequest(request), nil +} + +func (r *InMemorySourceResolver) loadedResult(key string, request SourceRequest) (*ResolvedSource, error) { + r.mu.Lock() + defer r.mu.Unlock() + entry := r.loads[key] + if entry == nil { + return nil, fmt.Errorf("%w: %s", ErrUnresolvedSourceDesc, key) + } + return entry.result(request) +} + +func (e *resolverEntry) result(request SourceRequest) (*ResolvedSource, error) { + if e.err != nil { + return nil, e.err + } + return e.source.withRequest(request), nil +} + +func resolveSourceRequestURI(reference, base string) (string, error) { + parsed, err := url.Parse(reference) + if err != nil { + return "", fmt.Errorf("invalid source URI %q: %w", reference, err) + } + if parsed.IsAbs() || base == "" { + return parsed.String(), nil + } + baseURI, err := url.Parse(base) + if err != nil { + return "", fmt.Errorf("invalid source base URI %q: %w", base, err) + } + return baseURI.ResolveReference(parsed).String(), nil +} + +func (s *ResolvedSource) withRequest(request SourceRequest) *ResolvedSource { + if s == nil { + return nil + } + copy := *s + if request.Name != "" { + copy.Name = request.Name + } + if copy.Type == "" && request.Type != "" { + copy.Type = strings.ToLower(request.Type) + } + return © +} + +// LegacySourceResolver adapts the existing opt-in file/HTTP resolver to SourceDocumentResolver. +type LegacySourceResolver struct { + config ResolveConfig +} + +// NewLegacySourceResolver creates an explicit compatibility adapter around ResolveSources. +func NewLegacySourceResolver(config *ResolveConfig) *LegacySourceResolver { + resolver := new(LegacySourceResolver) + if config != nil { + resolver.config = *config + } + return resolver +} + +// Resolve delegates one request to the existing resolver without enabling implicit retrieval elsewhere. +func (r *LegacySourceResolver) Resolve(ctx context.Context, request SourceRequest) (*ResolvedSource, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + document := &high.Arazzo{ + SourceDescriptions: []*high.SourceDescription{{ + Name: request.Name, + URL: request.URL, + Type: request.Type, + }}, + } + config := r.config + config.BaseURL = request.BaseURI + sources, err := ResolveSourcesWithContext(ctx, document, &config) + if err != nil { + return nil, err + } + return sources[0], nil +} diff --git a/arazzo/source_resolver_test.go b/arazzo/source_resolver_test.go new file mode 100644 index 000000000..0c404db8a --- /dev/null +++ b/arazzo/source_resolver_test.go @@ -0,0 +1,741 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + high "github.com/pb33f/libopenapi/datamodel/high/arazzo" + v3high "github.com/pb33f/libopenapi/datamodel/high/v3" + low "github.com/pb33f/libopenapi/datamodel/low/arazzo" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +type staticCandidateProvider struct { + documents []CandidateDocument + err error + calls int +} + +func (p *staticCandidateProvider) Documents(context.Context) ([]CandidateDocument, error) { + p.calls++ + return p.documents, p.err +} + +type testSourceAdapter struct { + sourceType string +} + +func (a *testSourceAdapter) SourceType() string { + return a.sourceType +} + +type resolverFunc func(context.Context, SourceRequest) (*ResolvedSource, error) + +func (f resolverFunc) Resolve(ctx context.Context, request SourceRequest) (*ResolvedSource, error) { + return f(ctx, request) +} + +func TestNewInMemorySourceResolver_EmptyAndProviderErrors(t *testing.T) { + resolver, err := NewInMemorySourceResolver(context.Background(), nil, nil) + require.NoError(t, err) + require.NotNil(t, resolver) + + sentinel := errors.New("provider failed") + provider := &staticCandidateProvider{err: sentinel} + resolver, err = NewInMemorySourceResolver(context.Background(), provider, nil) + assert.Nil(t, resolver) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + assert.Equal(t, 1, provider.calls) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + provider = &staticCandidateProvider{documents: []CandidateDocument{{Type: "openapi"}}} + resolver, err = NewInMemorySourceResolver(ctx, provider, nil) + assert.Nil(t, resolver) + assert.ErrorIs(t, err, context.Canceled) +} + +func TestNewInMemorySourceResolver_MaxSources(t *testing.T) { + documents := []CandidateDocument{ + { + ResolvedIdentity: "https://example.com/one.yaml", + Adapter: &testSourceAdapter{sourceType: "asyncapi"}, + }, + { + ResolvedIdentity: "https://example.com/two.yaml", + Adapter: &testSourceAdapter{sourceType: "asyncapi"}, + }, + } + provider := &staticCandidateProvider{documents: documents} + + resolver, err := NewInMemorySourceResolver( + context.Background(), + provider, + &InMemoryResolverConfig{MaxSources: 1}, + ) + assert.Nil(t, resolver) + assert.EqualError(t, err, "too many candidate source documents: 2 (max 1)") + + resolver, err = NewInMemorySourceResolver( + context.Background(), + provider, + &InMemoryResolverConfig{MaxSources: len(documents)}, + ) + require.NoError(t, err) + require.NotNil(t, resolver) + + resolver, err = NewInMemorySourceResolver( + context.Background(), + &staticCandidateProvider{documents: make([]CandidateDocument, defaultMaxCandidateSources+1)}, + &InMemoryResolverConfig{MaxSources: -1}, + ) + assert.Nil(t, resolver) + assert.EqualError(t, err, "too many candidate source documents: 51 (max 50)") +} + +func TestNewInMemorySourceResolver_BuildsAllCandidateShapes(t *testing.T) { + var openAPICalls, arazzoCalls int + arazzoDocument := high.NewArazzoWithOrigin(&low.Arazzo{}, &high.DocumentOrigin{ + ResolvedIdentity: "https://identity.example/arazzo.yaml", + }) + provider := &staticCandidateProvider{documents: []CandidateDocument{ + { + RetrievalURI: "https://retrieval.example/openapi.yaml", + OpenAPIDocument: &v3high.Document{}, + }, + { + RetrievalURI: "https://retrieval.example/arazzo.yaml", + ArazzoDocument: arazzoDocument, + }, + { + RetrievalURI: "https://retrieval.example/raw-openapi.yaml", + Type: "openapi", + SourceBytes: []byte("openapi: 3.1.0"), + }, + { + RetrievalURI: "https://retrieval.example/raw-arazzo.yaml", + Type: "arazzo", + RootNode: &yaml.Node{Kind: yaml.MappingNode}, + }, + { + ResolvedIdentity: "https://identity.example/asyncapi.yaml", + RetrievalURI: "https://retrieval.example/asyncapi.yaml", + Adapter: &testSourceAdapter{sourceType: "asyncapi"}, + }, + }} + resolver, err := NewInMemorySourceResolver(context.Background(), provider, &InMemoryResolverConfig{ + OpenAPIFactory: func(sourceURL string, bytes []byte) (*v3high.Document, error) { + openAPICalls++ + assert.Equal(t, "https://retrieval.example/raw-openapi.yaml", sourceURL) + assert.NotEmpty(t, bytes) + return &v3high.Document{}, nil + }, + ArazzoFactory: func(sourceURL string, bytes []byte) (*high.Arazzo, error) { + arazzoCalls++ + assert.Equal(t, "https://retrieval.example/raw-arazzo.yaml", sourceURL) + assert.NotEmpty(t, bytes) + return &high.Arazzo{Self: "https://identity.example/raw-arazzo.yaml"}, nil + }, + }) + require.NoError(t, err) + assert.Equal(t, 1, openAPICalls) + assert.Equal(t, 1, arazzoCalls) + + source, err := resolver.Resolve(context.Background(), SourceRequest{ + Name: "portable", + URL: "https://identity.example/arazzo.yaml", + Type: "Arazzo", + }) + require.NoError(t, err) + assert.Equal(t, "portable", source.Name) + assert.Equal(t, "arazzo", source.Type) + assert.Same(t, arazzoDocument, source.ArazzoDocument) + + source, err = resolver.Resolve(context.Background(), SourceRequest{ + URL: "https://retrieval.example/openapi.yaml", + }) + require.NoError(t, err) + assert.NotNil(t, source.OpenAPIDocument) + + source, err = resolver.Resolve(context.Background(), SourceRequest{ + URL: "https://identity.example/asyncapi.yaml", + }) + require.NoError(t, err) + assert.Equal(t, "asyncapi", source.Type) + assert.NotNil(t, source.Adapter) +} + +func TestNewInMemorySourceResolver_CandidateFailures(t *testing.T) { + sentinel := errors.New("parse failed") + tests := []struct { + name string + candidate CandidateDocument + config *InMemoryResolverConfig + }{ + { + name: "missing OpenAPI factory", + candidate: CandidateDocument{Type: "openapi"}, + }, + { + name: "OpenAPI factory error", + candidate: CandidateDocument{Type: "openapi"}, + config: &InMemoryResolverConfig{OpenAPIFactory: func(string, []byte) (*v3high.Document, error) { + return nil, sentinel + }}, + }, + { + name: "missing Arazzo factory", + candidate: CandidateDocument{Type: "arazzo"}, + }, + { + name: "Arazzo factory error", + candidate: CandidateDocument{Type: "arazzo"}, + config: &InMemoryResolverConfig{ArazzoFactory: func(string, []byte) (*high.Arazzo, error) { + return nil, sentinel + }}, + }, + { + name: "unsupported without adapter", + candidate: CandidateDocument{Type: "asyncapi"}, + }, + { + name: "root node render error", + candidate: CandidateDocument{ + Type: "openapi", + RootNode: &yaml.Node{Kind: yaml.Kind(99)}, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resolver, err := NewInMemorySourceResolver( + context.Background(), + &staticCandidateProvider{documents: []CandidateDocument{test.candidate}}, + test.config, + ) + assert.Nil(t, resolver) + require.Error(t, err) + }) + } +} + +func TestNewInMemorySourceResolver_IdentityFallbacksAndDuplicates(t *testing.T) { + selfDocument := &high.Arazzo{Self: "https://identity.example/self.yaml"} + resolver, err := NewInMemorySourceResolver( + context.Background(), + &staticCandidateProvider{documents: []CandidateDocument{{ + Type: "arazzo", + RetrievalURI: "https://retrieval.example/self.yaml", + ArazzoDocument: selfDocument, + }}}, + nil, + ) + require.NoError(t, err) + source, err := resolver.Resolve(context.Background(), SourceRequest{URL: selfDocument.Self}) + require.NoError(t, err) + assert.Equal(t, selfDocument.Self, source.Identity) + + for _, documents := range [][]CandidateDocument{ + { + {ResolvedIdentity: "duplicate", OpenAPIDocument: &v3high.Document{}}, + {ResolvedIdentity: "duplicate", OpenAPIDocument: &v3high.Document{}}, + }, + { + {ResolvedIdentity: "one", RetrievalURI: "duplicate", OpenAPIDocument: &v3high.Document{}}, + {ResolvedIdentity: "two", RetrievalURI: "duplicate", OpenAPIDocument: &v3high.Document{}}, + }, + { + {ResolvedIdentity: "shared", RetrievalURI: "one", OpenAPIDocument: &v3high.Document{}}, + {ResolvedIdentity: "two", RetrievalURI: "shared", OpenAPIDocument: &v3high.Document{}}, + }, + { + {ResolvedIdentity: "one", RetrievalURI: "shared", OpenAPIDocument: &v3high.Document{}}, + {ResolvedIdentity: "shared", RetrievalURI: "two", OpenAPIDocument: &v3high.Document{}}, + }, + } { + resolver, err = NewInMemorySourceResolver( + context.Background(), + &staticCandidateProvider{documents: documents}, + nil, + ) + assert.Nil(t, resolver) + require.Error(t, err) + assert.ErrorIs(t, err, ErrDuplicateSourceIdentity) + } +} + +func TestInMemorySourceResolver_RelativeLookupAndErrors(t *testing.T) { + resolver, err := NewInMemorySourceResolver( + context.Background(), + &staticCandidateProvider{documents: []CandidateDocument{{ + ResolvedIdentity: "https://example.com/specs/api.yaml", + RetrievalURI: "https://retrieval.example/api.yaml", + OpenAPIDocument: &v3high.Document{}, + }}}, + nil, + ) + require.NoError(t, err) + source, err := resolver.Resolve(context.Background(), SourceRequest{ + Name: "api", + URL: "../specs/api.yaml", + BaseURI: "https://example.com/workflows/root.yaml", + }) + require.NoError(t, err) + assert.Equal(t, "api", source.Name) + source, err = resolver.Resolve(context.Background(), SourceRequest{URL: "https://retrieval.example/api.yaml"}) + require.NoError(t, err) + assert.Equal(t, "https://example.com/specs/api.yaml", source.Identity) + + _, err = resolver.Resolve(context.Background(), SourceRequest{URL: "https://example.com/%zz"}) + require.Error(t, err) + _, err = resolver.Resolve(context.Background(), SourceRequest{URL: "api.yaml", BaseURI: "https://example.com/%zz"}) + require.Error(t, err) + _, err = resolver.Resolve(context.Background(), SourceRequest{URL: "missing.yaml"}) + assert.ErrorIs(t, err, ErrUnresolvedSourceDesc) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = resolver.Resolve(ctx, SourceRequest{URL: "anything"}) + assert.ErrorIs(t, err, context.Canceled) + + var nilResolver *InMemorySourceResolver + _, err = nilResolver.Resolve(context.Background(), SourceRequest{}) + require.Error(t, err) +} + +func TestInMemorySourceResolver_FallbackDeduplicationAndStates(t *testing.T) { + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + fallback := resolverFunc(func(_ context.Context, request SourceRequest) (*ResolvedSource, error) { + if calls.Add(1) == 1 { + close(started) + } + <-release + return &ResolvedSource{URL: request.URL, Identity: request.URL, Type: "openapi"}, nil + }) + resolver, err := NewInMemorySourceResolver(context.Background(), nil, &InMemoryResolverConfig{Fallback: fallback}) + require.NoError(t, err) + + var wait sync.WaitGroup + wait.Add(2) + results := make(chan *ResolvedSource, 2) + errs := make(chan error, 2) + for range 2 { + go func() { + defer wait.Done() + source, resolveErr := resolver.Resolve(context.Background(), SourceRequest{ + Name: "api", + URL: "https://example.com/api.yaml", + }) + results <- source + errs <- resolveErr + }() + } + <-started + close(release) + wait.Wait() + close(results) + close(errs) + for resolveErr := range errs { + require.NoError(t, resolveErr) + } + for source := range results { + require.NotNil(t, source) + assert.Equal(t, "api", source.Name) + } + assert.EqualValues(t, 1, calls.Load()) + + source, err := resolver.Resolve(context.Background(), SourceRequest{URL: "https://example.com/api.yaml"}) + require.NoError(t, err) + assert.NotNil(t, source) + assert.EqualValues(t, 1, calls.Load()) + + // A completed cycle placeholder is intentionally held only in the load table, + // so exercise the loaded-entry lookup independently of the permanent maps. + resolver.loads["loaded-cycle"] = &resolverEntry{ + state: resolverLoaded, + source: &ResolvedSource{Identity: "loaded-cycle", URL: "loaded-cycle"}, + } + source, err = resolver.Resolve(context.Background(), SourceRequest{Name: "cycle", URL: "loaded-cycle"}) + require.NoError(t, err) + assert.Equal(t, "cycle", source.Name) + + resolver.loads["unseen"] = &resolverEntry{state: resolverUnseen} + _, err = resolver.Resolve(context.Background(), SourceRequest{URL: "unseen"}) + require.NoError(t, err) +} + +func TestInMemorySourceResolver_IndexesFallbackCanonicalIdentity(t *testing.T) { + var calls atomic.Int32 + fallback := resolverFunc(func(_ context.Context, request SourceRequest) (*ResolvedSource, error) { + calls.Add(1) + return &ResolvedSource{ + URL: request.URL, + RetrievalURI: "https://retrieval.example/workflow.yaml", + Identity: "https://identity.example/workflow.yaml", + Type: "arazzo", + }, nil + }) + resolver, err := NewInMemorySourceResolver( + context.Background(), nil, &InMemoryResolverConfig{Fallback: fallback}, + ) + require.NoError(t, err) + + for _, sourceURL := range []string{ + "https://retrieval.example/workflow.yaml", + "https://identity.example/workflow.yaml", + } { + source, resolveErr := resolver.Resolve(context.Background(), SourceRequest{URL: sourceURL}) + require.NoError(t, resolveErr) + assert.Equal(t, "https://identity.example/workflow.yaml", source.Identity) + } + assert.EqualValues(t, 1, calls.Load(), "identity lookup must reuse the retrieval load") +} + +func TestInMemorySourceResolver_ContextFailuresAreRetryable(t *testing.T) { + for _, transientErr := range []error{context.Canceled, context.DeadlineExceeded} { + t.Run(transientErr.Error(), func(t *testing.T) { + var calls atomic.Int32 + fallback := resolverFunc(func(_ context.Context, request SourceRequest) (*ResolvedSource, error) { + if calls.Add(1) == 1 { + return nil, fmt.Errorf("first caller ended: %w", transientErr) + } + return &ResolvedSource{URL: request.URL, Identity: request.URL, Type: "openapi"}, nil + }) + resolver, err := NewInMemorySourceResolver( + context.Background(), nil, &InMemoryResolverConfig{Fallback: fallback}, + ) + require.NoError(t, err) + + request := SourceRequest{URL: "https://example.com/retry.yaml"} + _, err = resolver.Resolve(context.Background(), request) + require.ErrorIs(t, err, transientErr) + + source, err := resolver.Resolve(context.Background(), request) + require.NoError(t, err) + require.NotNil(t, source) + assert.Equal(t, request.URL, source.Identity) + assert.EqualValues(t, 2, calls.Load()) + }) + } +} + +func TestInMemorySourceResolver_LoadingCyclesCancellationAndFailures(t *testing.T) { + resolver, err := NewInMemorySourceResolver(context.Background(), nil, &InMemoryResolverConfig{ + Fallback: resolverFunc(func(context.Context, SourceRequest) (*ResolvedSource, error) { + return nil, errors.New("unexpected fallback") + }), + }) + require.NoError(t, err) + loading := &resolverEntry{ + state: resolverLoading, + source: &ResolvedSource{Identity: "cycle", URL: "cycle"}, + ready: make(chan struct{}), + } + resolver.loads["cycle"] = loading + cycleContext := withResolverLoad(withResolverLoad(context.Background(), "parent"), "cycle") + source, err := resolver.Resolve(cycleContext, SourceRequest{Name: "self", URL: "cycle"}) + require.NoError(t, err) + assert.Equal(t, "self", source.Name) + assert.True(t, resolverLoadActive(cycleContext, "parent")) + + waitingContext, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, resolveErr := resolver.Resolve(waitingContext, SourceRequest{URL: "cycle"}) + result <- resolveErr + }() + require.Eventually(t, func() bool { + resolver.mu.Lock() + defer resolver.mu.Unlock() + return loading.waiters > 0 + }, time.Second, time.Millisecond) + cancel() + assert.ErrorIs(t, <-result, context.Canceled) + + readyEntry := &resolverEntry{ + state: resolverLoading, + ready: make(chan struct{}), + } + resolver.loads["ready"] = readyEntry + readyResult := make(chan *ResolvedSource, 1) + readyError := make(chan error, 1) + go func() { + resolved, resolveErr := resolver.Resolve(context.Background(), SourceRequest{Name: "ready", URL: "ready"}) + readyResult <- resolved + readyError <- resolveErr + }() + require.Eventually(t, func() bool { + resolver.mu.Lock() + defer resolver.mu.Unlock() + return readyEntry.waiters > 0 + }, time.Second, time.Millisecond) + resolver.mu.Lock() + readyEntry.source = &ResolvedSource{Identity: "ready"} + readyEntry.state = resolverLoaded + close(readyEntry.ready) + resolver.mu.Unlock() + require.NoError(t, <-readyError) + assert.Equal(t, "ready", (<-readyResult).Name) + + sentinel := errors.New("fallback failed") + failedResolver, err := NewInMemorySourceResolver(context.Background(), nil, &InMemoryResolverConfig{ + Fallback: resolverFunc(func(context.Context, SourceRequest) (*ResolvedSource, error) { + return nil, sentinel + }), + }) + require.NoError(t, err) + for range 2 { + _, err = failedResolver.Resolve(context.Background(), SourceRequest{URL: "failed"}) + assert.ErrorIs(t, err, sentinel) + } + + nilResolver, err := NewInMemorySourceResolver(context.Background(), nil, &InMemoryResolverConfig{ + Fallback: resolverFunc(func(context.Context, SourceRequest) (*ResolvedSource, error) { + return nil, nil + }), + }) + require.NoError(t, err) + _, err = nilResolver.Resolve(context.Background(), SourceRequest{URL: "nil"}) + assert.ErrorIs(t, err, ErrUnresolvedSourceDesc) + + _, err = resolver.loadedResult("missing", SourceRequest{}) + assert.ErrorIs(t, err, ErrUnresolvedSourceDesc) + resolver.loads["failed"] = &resolverEntry{state: resolverFailed, err: sentinel} + _, err = resolver.loadedResult("failed", SourceRequest{}) + assert.ErrorIs(t, err, sentinel) +} + +func TestInMemorySourceResolver_RecursiveFallbackCycle(t *testing.T) { + var resolver *InMemorySourceResolver + var calls int + fallback := resolverFunc(func(ctx context.Context, request SourceRequest) (*ResolvedSource, error) { + calls++ + return resolver.Resolve(ctx, request) + }) + var err error + resolver, err = NewInMemorySourceResolver(context.Background(), nil, &InMemoryResolverConfig{Fallback: fallback}) + require.NoError(t, err) + source, err := resolver.Resolve(context.Background(), SourceRequest{URL: "https://example.com/self"}) + require.NoError(t, err) + assert.Equal(t, "https://example.com/self", source.Identity) + assert.Equal(t, 1, calls) +} + +func TestInMemorySourceResolver_CrossDocumentFallbackCycle(t *testing.T) { + var resolver *InMemorySourceResolver + calls := make(map[string]int) + fallback := resolverFunc(func(ctx context.Context, request SourceRequest) (*ResolvedSource, error) { + calls[request.URL]++ + switch request.URL { + case "https://example.com/a": + return resolver.Resolve(ctx, SourceRequest{URL: "https://example.com/b"}) + case "https://example.com/b": + return resolver.Resolve(ctx, SourceRequest{URL: "https://example.com/a"}) + default: + return nil, errors.New("unexpected source") + } + }) + var err error + resolver, err = NewInMemorySourceResolver(context.Background(), nil, &InMemoryResolverConfig{Fallback: fallback}) + require.NoError(t, err) + source, err := resolver.Resolve(context.Background(), SourceRequest{URL: "https://example.com/a"}) + require.NoError(t, err) + assert.Equal(t, "https://example.com/a", source.Identity) + assert.Equal(t, 1, calls["https://example.com/a"]) + assert.Equal(t, 1, calls["https://example.com/b"]) +} + +func TestInMemorySourceResolver_DiamondGraphLoadsSharedSourceOnce(t *testing.T) { + var resolver *InMemorySourceResolver + calls := make(map[string]int) + fallback := resolverFunc(func(ctx context.Context, request SourceRequest) (*ResolvedSource, error) { + calls[request.URL]++ + resolve := func(sourceURL string) error { + _, resolveErr := resolver.Resolve(ctx, SourceRequest{URL: sourceURL}) + return resolveErr + } + switch request.URL { + case "https://example.com/a": + require.NoError(t, resolve("https://example.com/b")) + require.NoError(t, resolve("https://example.com/c")) + case "https://example.com/b", "https://example.com/c": + require.NoError(t, resolve("https://example.com/d")) + } + return &ResolvedSource{ + URL: request.URL, + Identity: request.URL, + Type: "openapi", + }, nil + }) + var err error + resolver, err = NewInMemorySourceResolver( + context.Background(), + nil, + &InMemoryResolverConfig{Fallback: fallback}, + ) + require.NoError(t, err) + + source, err := resolver.Resolve( + context.Background(), + SourceRequest{URL: "https://example.com/a"}, + ) + require.NoError(t, err) + assert.Equal(t, "https://example.com/a", source.Identity) + for _, sourceURL := range []string{ + "https://example.com/a", + "https://example.com/b", + "https://example.com/c", + "https://example.com/d", + } { + assert.Equal(t, 1, calls[sourceURL], sourceURL) + } +} + +func TestLegacySourceResolver(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + legacy := NewLegacySourceResolver(nil) + _, err := legacy.Resolve(ctx, SourceRequest{}) + assert.ErrorIs(t, err, context.Canceled) + + legacy = NewLegacySourceResolver(&ResolveConfig{ + HTTPHandler: func(string) ([]byte, error) { + return []byte("openapi: 3.1.0"), nil + }, + OpenAPIFactory: func(string, []byte) (*v3high.Document, error) { + return &v3high.Document{}, nil + }, + }) + source, err := legacy.Resolve(context.Background(), SourceRequest{ + Name: "api", + URL: "api.yaml", + Type: "openapi", + BaseURI: "https://example.com/specs/", + }) + require.NoError(t, err) + assert.Equal(t, "api", source.Name) + assert.Equal(t, "https://example.com/specs/api.yaml", source.URL) + assert.Equal(t, source.URL, source.RetrievalURI) + assert.Equal(t, source.URL, source.Identity) + assert.NotEmpty(t, source.SourceBytes) + + legacy = NewLegacySourceResolver(&ResolveConfig{ + HTTPHandler: func(string) ([]byte, error) { return nil, errors.New("fetch failed") }, + }) + _, err = legacy.Resolve(context.Background(), SourceRequest{URL: "https://example.com/api.yaml"}) + require.Error(t, err) +} + +func BenchmarkInMemorySourceResolverManyDocuments(b *testing.B) { + const documentCount = 1_000 + documents := make([]CandidateDocument, documentCount) + for index := range documents { + sourceURL := fmt.Sprintf("https://example.com/source-%d.yaml", index) + documents[index] = CandidateDocument{ + ResolvedIdentity: sourceURL, + RetrievalURI: sourceURL, + Adapter: &testSourceAdapter{sourceType: "asyncapi"}, + } + } + provider := &staticCandidateProvider{documents: documents} + config := &InMemoryResolverConfig{MaxSources: documentCount} + b.ReportAllocs() + b.ResetTimer() + for range b.N { + resolver, err := NewInMemorySourceResolver(context.Background(), provider, config) + if err != nil { + b.Fatal(err) + } + for index := range documents { + sourceURL := fmt.Sprintf("https://example.com/source-%d.yaml", index) + if _, err = resolver.Resolve( + context.Background(), + SourceRequest{URL: sourceURL}, + ); err != nil { + b.Fatal(err) + } + } + } +} + +func TestResolvedSource_WithRequestNilAndDefaults(t *testing.T) { + var source *ResolvedSource + assert.Nil(t, source.withRequest(SourceRequest{})) + + source = &ResolvedSource{Name: "original", Type: "openapi"} + copy := source.withRequest(SourceRequest{}) + assert.Equal(t, "original", copy.Name) + assert.Equal(t, "openapi", copy.Type) + assert.NotSame(t, source, copy) + + copy = source.withRequest(SourceRequest{Name: "requested", Type: "arazzo"}) + assert.Equal(t, "requested", copy.Name) + assert.Equal(t, "openapi", copy.Type) + + source = &ResolvedSource{} + copy = source.withRequest(SourceRequest{Type: "AsyncAPI"}) + assert.Equal(t, "asyncapi", copy.Type) +} + +func TestResolveSources_PopulatesArazzoIdentityMetadata(t *testing.T) { + tests := []struct { + name string + document *high.Arazzo + identity string + }{ + { + name: "resolved origin", + document: high.NewArazzoWithOrigin(&low.Arazzo{}, &high.DocumentOrigin{ + ResolvedIdentity: "https://identity.example/origin.yaml", + }), + identity: "https://identity.example/origin.yaml", + }, + { + name: "authored self fallback", + document: &high.Arazzo{Self: "https://identity.example/self.yaml"}, + identity: "https://identity.example/self.yaml", + }, + { + name: "retrieval fallback", + document: nil, + identity: "https://retrieval.example/source.yaml", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + document := &high.Arazzo{ + SourceDescriptions: []*high.SourceDescription{{ + Name: "source", + URL: "https://retrieval.example/source.yaml", + Type: "arazzo", + }}, + } + resolved, err := ResolveSources(document, &ResolveConfig{ + HTTPHandler: func(string) ([]byte, error) { + return []byte("arazzo: 1.1.0"), nil + }, + ArazzoFactory: func(string, []byte) (*high.Arazzo, error) { + return test.document, nil + }, + }) + require.NoError(t, err) + require.Len(t, resolved, 1) + assert.Equal(t, test.identity, resolved[0].Identity) + assert.Equal(t, "https://retrieval.example/source.yaml", resolved[0].RetrievalURI) + assert.Equal(t, []byte("arazzo: 1.1.0"), resolved[0].SourceBytes) + }) + } +} diff --git a/arazzo/step.go b/arazzo/step.go index fad8b3ce4..988d137f0 100644 --- a/arazzo/step.go +++ b/arazzo/step.go @@ -102,7 +102,7 @@ func (e *Engine) executeStep(ctx context.Context, step *high.Step, wf *high.Work } } if result.Success { - if err := e.populateStepOutputs(step, result, exprCtx); err != nil { + if err := e.populateStepOutputs(step, wf, result, exprCtx); err != nil { result.Success = false result.Error = err } @@ -420,7 +420,7 @@ func (e *Engine) evaluateStringValue(input string, exprCtx *expression.Context) return expression.Evaluate(parsed, exprCtx) } if strings.Contains(input, "{$") { - tokens, err := expression.ParseEmbedded(input) + tokens, err := expression.ParseEmbeddedWithVersion(input, e.exprVersion) if err != nil { return nil, err } @@ -444,13 +444,39 @@ func (e *Engine) evaluateStringValue(input string, exprCtx *expression.Context) return input, nil } -func (e *Engine) populateStepOutputs(step *high.Step, result *StepResult, exprCtx *expression.Context) error { +// populateStepOutputs evaluates every step output before publishing any of them. +// Outputs are staged and committed only once all succeed, so a failure partway +// through leaves result.Outputs untouched rather than exposing a partial map to +// later steps via the expression context. +func (e *Engine) populateStepOutputs( + step *high.Step, + wf *high.Workflow, + result *StepResult, + exprCtx *expression.Context, +) error { if step.Outputs == nil || step.Outputs.Len() == 0 { return nil } + workflowId := "" + if wf != nil { + workflowId = wf.WorkflowId + } + // result.Outputs starts empty and this is its only writer, so a failure can simply + // discard what was written rather than staging into a second map first. That keeps + // the happy path -- the hot one -- free of extra allocation. for name, outputExpression := range step.Outputs.FromOldest() { - value, err := e.evaluateStringValue(outputExpression, exprCtx) + expressionValue, ok := outputExpression.GetExpression() + if !ok { + clear(result.Outputs) + return &UnsupportedSelectorOutputError{ + WorkflowId: workflowId, + StepId: step.StepId, + OutputName: name, + } + } + value, err := e.evaluateStringValue(expressionValue, exprCtx) if err != nil { + clear(result.Outputs) return fmt.Errorf("failed to evaluate output %q for step %q: %w", name, step.StepId, err) } result.Outputs[name] = value @@ -462,17 +488,34 @@ func (e *Engine) populateWorkflowOutputs(wf *high.Workflow, result *WorkflowResu if wf.Outputs == nil || wf.Outputs.Len() == 0 { return nil } + // exprCtx.Outputs may already hold entries, so a failure here cannot be undone by + // clearing it. Stage into a slice instead: one allocation, no hashing, and the + // authored output order is preserved on commit. + staged := make([]stagedOutput, 0, wf.Outputs.Len()) for name, outputExpression := range wf.Outputs.FromOldest() { - value, err := e.evaluateStringValue(outputExpression, exprCtx) + expressionValue, ok := outputExpression.GetExpression() + if !ok { + return &UnsupportedSelectorOutputError{WorkflowId: wf.WorkflowId, OutputName: name} + } + value, err := e.evaluateStringValue(expressionValue, exprCtx) if err != nil { return fmt.Errorf("failed to evaluate output %q for workflow %q: %w", name, wf.WorkflowId, err) } - result.Outputs[name] = value - exprCtx.Outputs[name] = value + staged = append(staged, stagedOutput{name: name, value: value}) + } + for _, output := range staged { + result.Outputs[output.name] = output.value + exprCtx.Outputs[output.name] = output.value } return nil } +// stagedOutput holds an evaluated workflow output awaiting commit. +type stagedOutput struct { + name string + value any +} + func firstHeaderValues(headers map[string][]string) map[string]string { if len(headers) == 0 { return nil diff --git a/arazzo/testdata/arazzo-1.0-render.yaml b/arazzo/testdata/arazzo-1.0-render.yaml new file mode 100644 index 000000000..0cc0a321a --- /dev/null +++ b/arazzo/testdata/arazzo-1.0-render.yaml @@ -0,0 +1,21 @@ +arazzo: 1.0.1 +info: + title: Compatibility workflow + version: 1.0.0 +sourceDescriptions: + - name: petstore + url: https://api.example.com/petstore.yaml + type: openapi +workflows: + - workflowId: fetchPet + steps: + - stepId: fetch + operationId: getPet + parameters: + - name: petId + in: path + value: $inputs.petId + outputs: + petId: $response.body#/id + outputs: + petId: $steps.fetch.outputs.petId diff --git a/arazzo/testdata/arazzo-1.1-official.yaml b/arazzo/testdata/arazzo-1.1-official.yaml new file mode 100644 index 000000000..405c3dfe3 --- /dev/null +++ b/arazzo/testdata/arazzo-1.1-official.yaml @@ -0,0 +1,83 @@ +arazzo: 1.1.0 +$self: https://api.example.com/workflows/pet-purchase.arazzo.yaml +info: + title: A pet purchasing workflow + summary: Purchase a pet through HTTP and asynchronous APIs + version: 1.0.0 +sourceDescriptions: + - name: petstore + url: ../apis/petstore.yaml + type: openapi + - name: orders + url: ../apis/orders.asyncapi.yaml + type: asyncapi +workflows: + - workflowId: purchasePet + inputs: + type: object + properties: + petId: + type: integer + correlationId: + type: string + steps: + - stepId: fetchPet + operationId: $sourceDescriptions.petstore.getPet + parameters: + - name: petId + in: path + value: $inputs.petId + successCriteria: + - condition: $statusCode == 200 + outputs: + pet: + context: $response.body + selector: $.pet + type: + type: jsonpath + version: rfc9535 + - stepId: placeOrder + channelPath: '{$sourceDescriptions.orders.url}#/channels/orders' + action: send + dependsOn: + - fetchPet + parameters: + - name: correlationId + in: header + value: $inputs.correlationId + requestBody: + contentType: application/json + payload: + petId: $inputs.petId + replacements: + - target: $.petId + targetSelectorType: + type: jsonpath + version: rfc9535 + value: + context: $steps.fetchPet.outputs.pet + selector: $.id + type: jsonpath + - stepId: confirmOrder + operationId: $sourceDescriptions.orders.confirmOrder + action: receive + correlationId: $inputs.correlationId + dependsOn: + - placeOrder + timeout: 6000 + outputs: + orderId: $message.payload#/orderId + outputs: + orderId: $steps.confirmOrder.outputs.orderId + successActions: + - name: complete + type: end + parameters: + - name: orderId + value: $outputs.orderId + failureActions: + - name: abort + type: end + parameters: + - name: reason + value: purchase-failed diff --git a/arazzo/testdata/negative-11/action-not-scalar.yaml b/arazzo/testdata/negative-11/action-not-scalar.yaml new file mode 100644 index 000000000..e1b0e82cb --- /dev/null +++ b/arazzo/testdata/negative-11/action-not-scalar.yaml @@ -0,0 +1,11 @@ +# action is a scalar AsyncAPI action name. +arazzo: 1.1.0 +info: + title: negative action + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + action: + - not-a-scalar diff --git a/arazzo/testdata/negative-11/action-parameters-not-sequence.yaml b/arazzo/testdata/negative-11/action-parameters-not-sequence.yaml new file mode 100644 index 000000000..2111cd726 --- /dev/null +++ b/arazzo/testdata/negative-11/action-parameters-not-sequence.yaml @@ -0,0 +1,15 @@ +# success/failure action parameters are a sequence of Parameter Objects. +arazzo: 1.1.0 +info: + title: negative action parameters + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op + onSuccess: + - name: go + type: goto + stepId: s1 + parameters: not-a-sequence diff --git a/arazzo/testdata/negative-11/channel-path-not-scalar.yaml b/arazzo/testdata/negative-11/channel-path-not-scalar.yaml new file mode 100644 index 000000000..3dca11420 --- /dev/null +++ b/arazzo/testdata/negative-11/channel-path-not-scalar.yaml @@ -0,0 +1,11 @@ +# channelPath is a scalar AsyncAPI channel reference. +arazzo: 1.1.0 +info: + title: negative channelPath + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + channelPath: + not: a-scalar diff --git a/arazzo/testdata/negative-11/correlation-id-not-scalar.yaml b/arazzo/testdata/negative-11/correlation-id-not-scalar.yaml new file mode 100644 index 000000000..a7cd87e37 --- /dev/null +++ b/arazzo/testdata/negative-11/correlation-id-not-scalar.yaml @@ -0,0 +1,12 @@ +# correlationId is a scalar string. +arazzo: 1.1.0 +info: + title: negative correlationId + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op + correlationId: + not: a-scalar diff --git a/arazzo/testdata/negative-11/depends-on-not-sequence.yaml b/arazzo/testdata/negative-11/depends-on-not-sequence.yaml new file mode 100644 index 000000000..77aac68e4 --- /dev/null +++ b/arazzo/testdata/negative-11/depends-on-not-sequence.yaml @@ -0,0 +1,12 @@ +# step-level dependsOn is a sequence of step identifiers, not a mapping. +arazzo: 1.1.0 +info: + title: negative dependsOn + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op + dependsOn: + not: a-sequence diff --git a/arazzo/testdata/negative-11/self-not-scalar.yaml b/arazzo/testdata/negative-11/self-not-scalar.yaml new file mode 100644 index 000000000..54d511fa9 --- /dev/null +++ b/arazzo/testdata/negative-11/self-not-scalar.yaml @@ -0,0 +1,12 @@ +# $self must be a scalar URI. A mapping is not a valid authored value. +arazzo: 1.1.0 +info: + title: negative $self + version: 1.0.0 +$self: + not: a-scalar +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op diff --git a/arazzo/testdata/negative-11/timeout-not-integer.yaml b/arazzo/testdata/negative-11/timeout-not-integer.yaml new file mode 100644 index 000000000..66b02664f --- /dev/null +++ b/arazzo/testdata/negative-11/timeout-not-integer.yaml @@ -0,0 +1,11 @@ +# timeout is a millisecond integer; a string is not coercible. +arazzo: 1.1.0 +info: + title: negative timeout + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op + timeout: soon diff --git a/arazzo/testdata/official-schema-metadata.json b/arazzo/testdata/official-schema-metadata.json new file mode 100644 index 000000000..179f52f67 --- /dev/null +++ b/arazzo/testdata/official-schema-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/validation.go b/arazzo/validation.go index a6ef4e71b..4432e506f 100644 --- a/arazzo/validation.go +++ b/arazzo/validation.go @@ -38,9 +38,10 @@ func rootPos[T any](low *T, getRootNode func(*T) *yaml.Node) (int, int) { var componentKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9.\-_]+$`) var sourceDescriptionNameRegex = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`) -// Validate performs structural validation of an Arazzo document. -// Returns nil if the document is valid; callers should nil-check the result -// before accessing Errors or Warnings. +// Validate performs compatibility validation for the legacy Arazzo 1.0 +// surface. Complete schema-driven Arazzo validation, including 1.1, belongs in +// libopenapi-validator. Returns nil when no supported errors or warnings are +// found; callers should nil-check the result before accessing it. func Validate(doc *high.Arazzo) *ValidationResult { v := &validator{ doc: doc, @@ -63,6 +64,13 @@ type validator struct { opLookup *operationResolver } +type parameterValidationContext uint8 + +const ( + operationParameterContext parameterValidationContext = iota + workflowActionParameterContext +) + func (v *validator) addError(path string, line, col int, cause error) { v.result.Errors = append(v.result.Errors, &ValidationError{ Path: path, @@ -357,6 +365,8 @@ func (v *validator) validateWorkflow(wf *high.Workflow, idx int, workflowIds map v.validateStep(step, stepPath, stepIds, workflowIds) } + v.validateParameters(wf.Parameters, prefix+".parameters", workflowActionParameterContext) + // Validate workflow-level success/failure actions v.validateSuccessActions(wf.SuccessActions, prefix+".successActions", stepIds, workflowIds) v.validateFailureActions(wf.FailureActions, prefix+".failureActions", stepIds, workflowIds) @@ -396,7 +406,11 @@ func (v *validator) validateStep(step *high.Step, path string, stepIds, workflow } // Validate parameters - v.validateParameters(step.Parameters, path+".parameters") + parameterContext := operationParameterContext + if step.WorkflowId != "" { + parameterContext = workflowActionParameterContext + } + v.validateParameters(step.Parameters, path+".parameters", parameterContext) // Validate success criteria for i, c := range step.SuccessCriteria { @@ -592,47 +606,79 @@ func extractSourceNameFromOperationPath(operationPath string) (string, bool) { return "", false } -func (v *validator) validateParameters(params []*high.Parameter, path string) { +func (v *validator) validateParameters( + params []*high.Parameter, + path string, + validationContext parameterValidationContext, +) { seen := make(map[string]bool) for i, p := range params { paramPath := fmt.Sprintf("%s[%d]", path, i) pLine, pCol := rootPos(p.GoLow(), (*low.Parameter).GetRootNode) + resolved := p if p.IsReusable() { - // Reusable parameter - validate reference resolves v.validateComponentReference(p.Reference, paramPath+".reference", "parameters") - continue + resolved = v.resolveComponentParameter(p.Reference) + if resolved == nil { + continue + } } // Rule 5: Parameter validation - if p.Name == "" { + if resolved.Name == "" { v.addError(paramPath+".name", pLine, pCol, ErrMissingParameterName) } - if p.Value == nil { + if resolved.Value == nil { v.addError(paramPath+".value", pLine, pCol, ErrMissingParameterValue) } - // Rule 5: Parameter `in` validation - if p.In == "" { - v.addError(paramPath+".in", pLine, pCol, ErrMissingParameterIn) - } else { - switch p.In { - case "path", "query", "header", "cookie": - // valid - default: - v.addError(paramPath+".in", pLine, pCol, ErrInvalidParameterIn) + switch validationContext { + case operationParameterContext: + if resolved.In == "" { + v.addError(paramPath+".in", pLine, pCol, ErrMissingParameterIn) + } else { + switch resolved.In { + case "path", "query", "header", "cookie": + // valid + default: + v.addError(paramPath+".in", pLine, pCol, ErrInvalidParameterIn) + } + } + case workflowActionParameterContext: + if resolved.In != "" { + v.addError(paramPath+".in", pLine, pCol, ErrParameterInNotAllowed) } } - // Rule 16: Duplicate parameters (name+in) - key := p.Name + ":" + p.In + key := resolved.Name + if validationContext == operationParameterContext { + key += ":" + resolved.In + } if seen[key] { - v.addError(paramPath, pLine, pCol, fmt.Errorf("duplicate parameter (name=%q, in=%q)", p.Name, p.In)) + if validationContext == operationParameterContext { + v.addError(paramPath, pLine, pCol, + fmt.Errorf("duplicate parameter (name=%q, in=%q)", resolved.Name, resolved.In)) + } else { + v.addError(paramPath, pLine, pCol, + fmt.Errorf("duplicate parameter name %q", resolved.Name)) + } } seen[key] = true } } +func (v *validator) resolveComponentParameter(reference string) *high.Parameter { + const prefix = "$components.parameters." + if v.doc.Components == nil || + v.doc.Components.Parameters == nil || + !strings.HasPrefix(reference, prefix) { + return nil + } + parameter, _ := v.doc.Components.Parameters.Get(strings.TrimPrefix(reference, prefix)) + return parameter +} + func (v *validator) validateSuccessActions(actions []*high.SuccessAction, path string, stepIds, workflowIds map[string]bool) { seen := make(map[string]bool) for i, a := range actions { @@ -649,6 +695,7 @@ func (v *validator) validateSuccessActions(actions []*high.SuccessAction, path s } v.validateActionCommon(a.Name, a.Type, a.WorkflowId, a.StepId, actionPath, aLine, aCol, stepIds, workflowIds, seen) + v.validateParameters(a.Parameters, actionPath+".parameters", workflowActionParameterContext) } } @@ -668,6 +715,7 @@ func (v *validator) validateFailureActions(actions []*high.FailureAction, path s } v.validateActionCommon(a.Name, a.Type, a.WorkflowId, a.StepId, actionPath, aLine, aCol, stepIds, workflowIds, seen) + v.validateParameters(a.Parameters, actionPath+".parameters", workflowActionParameterContext) if a.RetryAfter != nil && *a.RetryAfter < 0 { v.addError(actionPath+".retryAfter", aLine, aCol, fmt.Errorf("retryAfter must be non-negative, got %f", *a.RetryAfter)) @@ -727,30 +775,56 @@ func (v *validator) validateCriterion(c *high.Criterion, path string) { v.validateCriterionExpressionType(c.ExpressionType, path+".type") } - // Validate context as runtime expression if present + // Validate context as runtime expression if present. This validator is scoped to + // Arazzo 1.0 (see checkVersion), so the 1.0 grammar applies; under the 1.1 grammar + // a legal 1.0 reference such as $components.inputs.name would be a false negative. if c.Context != "" { - if err := expression.Validate(c.Context); err != nil { + if err := expression.ValidateWithVersion(c.Context, expression.Arazzo10); err != nil { v.addError(path+".context", cLine, cCol, fmt.Errorf("%w: %v", ErrInvalidExpression, err)) } } } func (v *validator) validateCriterionExpressionType(cet *high.CriterionExpressionType, path string) { + line, column := rootPos(cet.GoLow(), (*low.ExpressionType).GetRootNode) + typeLine, typeColumn := line, column + versionLine, versionColumn := line, column + if lowExpressionType := cet.GoLow(); lowExpressionType != nil { + if node := lowExpressionType.Type.ValueNode; node != nil { + typeLine, typeColumn = lowNodePos(node) + } + if node := lowExpressionType.Version.ValueNode; node != nil { + versionLine, versionColumn = lowNodePos(node) + } + } if cet.Type == "" { - v.addError(path+".type", 0, 0, fmt.Errorf("missing required 'type' in criterion expression type")) + v.addError(path+".type", typeLine, typeColumn, fmt.Errorf("missing required 'type' in criterion expression type")) return } switch cet.Type { case "jsonpath": - if cet.Version != "" && cet.Version != "draft-goessner-dispatch-jsonpath-00" { - v.addError(path+".version", 0, 0, fmt.Errorf("unknown jsonpath version %q", cet.Version)) + if cet.Version != "" && + cet.Version != "rfc9535" && + cet.Version != "draft-goessner-dispatch-jsonpath-00" { + v.addError(path+".version", versionLine, versionColumn, fmt.Errorf("unknown jsonpath version %q", cet.Version)) } case "xpath": - validVersions := map[string]bool{"xpath-30": true, "xpath-20": true, "xpath-10": true} + validVersions := map[string]bool{ + "xpath-31": true, + "xpath-30": true, + "xpath-20": true, + "xpath-10": true, + } if cet.Version != "" && !validVersions[cet.Version] { - v.addError(path+".version", 0, 0, fmt.Errorf("unknown xpath version %q", cet.Version)) + v.addError(path+".version", versionLine, versionColumn, fmt.Errorf("unknown xpath version %q", cet.Version)) + } + case "jsonpointer": + if cet.Version != "" && cet.Version != "rfc6901" { + v.addError(path+".version", versionLine, versionColumn, fmt.Errorf("unknown jsonpointer version %q", cet.Version)) } + default: + v.addError(path+".type", typeLine, typeColumn, fmt.Errorf("unknown expression type %q", cet.Type)) } } diff --git a/arazzo/validation_regression_test.go b/arazzo/validation_regression_test.go new file mode 100644 index 000000000..8cf188334 --- /dev/null +++ b/arazzo/validation_regression_test.go @@ -0,0 +1,342 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "testing" + + libopenapi "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/arazzo/expression" + high "github.com/pb33f/libopenapi/datamodel/high/arazzo" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" + low "github.com/pb33f/libopenapi/datamodel/low/arazzo" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +func TestValidate_ExpressionTypeDiagnosticsUseNarrowValueNode(t *testing.T) { + documentYAML := `arazzo: 1.0.1 +info: + title: diagnostic + version: 1.0.0 +sourceDescriptions: + - name: source + url: https://example.com/openapi.yaml +workflows: + - workflowId: workflow + steps: + - stepId: step + operationId: operation + successCriteria: + - condition: $.id + context: $response.body + type: + type: jsonpath + version: unsupported +` + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(documentYAML), &root)) + var lowDocument low.Arazzo + require.NoError(t, lowmodel.BuildModel(root.Content[0], &lowDocument)) + require.NoError(t, lowDocument.Build(context.Background(), nil, root.Content[0], nil)) + document := high.NewArazzo(&lowDocument) + + expressionType := document.Workflows[0].Steps[0].SuccessCriteria[0].ExpressionType.GoLow() + require.NotNil(t, expressionType.Version.ValueNode) + + result := Validate(document) + require.NotNil(t, result) + require.Len(t, result.Errors, 1) + assert.Equal(t, "workflows[0].steps[0].successCriteria[0].type.version", result.Errors[0].Path) + assert.Equal(t, expressionType.Version.ValueNode.Line, result.Errors[0].Line) + assert.Equal(t, expressionType.Version.ValueNode.Column, result.Errors[0].Column) + assert.Greater(t, result.Errors[0].Line, 0) +} + +func TestValidate_ParameterContexts(t *testing.T) { + tests := []struct { + name string + configure func(*high.Arazzo) + errorIs error + }{ + { + name: "operation requires in", + configure: func(document *high.Arazzo) { + document.Workflows[0].Steps[0].Parameters = []*high.Parameter{{ + Name: "id", + Value: makeValueNode("1"), + }} + }, + errorIs: ErrMissingParameterIn, + }, + { + name: "workflow forbids in", + configure: func(document *high.Arazzo) { + document.Workflows[0].Parameters = []*high.Parameter{{ + Name: "id", + In: "query", + Value: makeValueNode("1"), + }} + }, + errorIs: ErrParameterInNotAllowed, + }, + { + name: "workflow-target step forbids in", + configure: func(document *high.Arazzo) { + document.Workflows = append(document.Workflows, &high.Workflow{ + WorkflowId: "child", + Steps: []*high.Step{{StepId: "child-step", OperationId: "get"}}, + }) + step := document.Workflows[0].Steps[0] + step.OperationId = "" + step.WorkflowId = "child" + step.Parameters = []*high.Parameter{{ + Name: "id", + In: "query", + Value: makeValueNode("1"), + }} + }, + errorIs: ErrParameterInNotAllowed, + }, + { + name: "success action forbids in", + configure: func(document *high.Arazzo) { + document.Workflows[0].SuccessActions = []*high.SuccessAction{{ + Name: "done", + Type: "end", + Parameters: []*high.Parameter{{ + Name: "id", + In: "query", + Value: makeValueNode("1"), + }}, + }} + }, + errorIs: ErrParameterInNotAllowed, + }, + { + name: "failure action forbids in", + configure: func(document *high.Arazzo) { + document.Workflows[0].FailureActions = []*high.FailureAction{{ + Name: "done", + Type: "end", + Parameters: []*high.Parameter{{ + Name: "id", + In: "query", + Value: makeValueNode("1"), + }}, + }} + }, + errorIs: ErrParameterInNotAllowed, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + document := validMinimalDoc() + test.configure(document) + result := Validate(document) + require.NotNil(t, result) + require.True(t, result.HasErrors()) + assert.ErrorIs(t, result, test.errorIs) + }) + } +} + +func TestValidate_ValidWorkflowAndActionParametersWithoutIn(t *testing.T) { + document := validMinimalDoc() + parameter := func(name string) *high.Parameter { + return &high.Parameter{Name: name, Value: makeValueNode("1")} + } + document.Workflows[0].Parameters = []*high.Parameter{parameter("workflow")} + document.Workflows[0].SuccessActions = []*high.SuccessAction{{ + Name: "success", + Type: "end", + Parameters: []*high.Parameter{parameter("success")}, + }} + document.Workflows[0].FailureActions = []*high.FailureAction{{ + Name: "failure", + Type: "end", + Parameters: []*high.Parameter{parameter("failure")}, + }} + assert.Nil(t, Validate(document)) +} + +func TestValidate_ParameterDuplicateIdentityUsesContext(t *testing.T) { + document := validMinimalDoc() + document.Workflows[0].Steps[0].Parameters = []*high.Parameter{ + {Name: "id", In: "query", Value: makeValueNode("1")}, + {Name: "id", In: "header", Value: makeValueNode("2")}, + } + assert.Nil(t, Validate(document)) + + document.Workflows[0].Steps[0].Parameters = append( + document.Workflows[0].Steps[0].Parameters, + &high.Parameter{Name: "id", In: "query", Value: makeValueNode("3")}, + ) + result := Validate(document) + require.NotNil(t, result) + assert.Contains(t, result.Error(), `duplicate parameter (name="id", in="query")`) + + document = validMinimalDoc() + document.Workflows[0].Parameters = []*high.Parameter{ + {Name: "id", Value: makeValueNode("1")}, + {Name: "id", Value: makeValueNode("2")}, + } + result = Validate(document) + require.NotNil(t, result) + assert.Contains(t, result.Error(), `duplicate parameter name "id"`) +} + +func TestValidate_ReusableParameterFollowsUsageContext(t *testing.T) { + componentParameters := orderedmap.New[string, *high.Parameter]() + componentParameters.Set("operation", &high.Parameter{ + Name: "id", + In: "query", + Value: makeValueNode("1"), + }) + componentParameters.Set("workflow", &high.Parameter{ + Name: "id", + Value: makeValueNode("1"), + }) + reusable := func(name string) *high.Parameter { + return &high.Parameter{Reference: "$components.parameters." + name} + } + + document := validMinimalDoc() + document.Components = &high.Components{Parameters: componentParameters} + document.Workflows[0].Steps[0].Parameters = []*high.Parameter{reusable("operation")} + document.Workflows[0].Parameters = []*high.Parameter{reusable("workflow")} + assert.Nil(t, Validate(document)) + + document.Workflows[0].Steps[0].Parameters = []*high.Parameter{reusable("workflow")} + document.Workflows[0].Parameters = []*high.Parameter{reusable("operation")} + result := Validate(document) + require.NotNil(t, result) + assert.ErrorIs(t, result, ErrMissingParameterIn) + assert.ErrorIs(t, result, ErrParameterInNotAllowed) +} + +func TestValidate_ExpressionTypeOfficialPairs(t *testing.T) { + valid := []high.ExpressionType{ + {Type: "jsonpath", Version: "rfc9535"}, + {Type: "jsonpath", Version: "draft-goessner-dispatch-jsonpath-00"}, + {Type: "xpath", Version: "xpath-31"}, + {Type: "jsonpointer", Version: "rfc6901"}, + } + for _, expressionType := range valid { + document := validMinimalDoc() + value := expressionType + document.Workflows[0].Steps[0].SuccessCriteria = []*high.Criterion{{ + Context: "$response.body", + Condition: "$.id", + ExpressionType: &value, + }} + assert.Nil(t, Validate(document), "%s/%s", value.Type, value.Version) + } + + invalid := []high.ExpressionType{ + {Type: "jsonpointer", Version: "wrong"}, + {Type: "unknown", Version: "1"}, + } + for _, expressionType := range invalid { + document := validMinimalDoc() + value := expressionType + document.Workflows[0].Steps[0].SuccessCriteria = []*high.Criterion{{ + Context: "$response.body", + Condition: "$.id", + ExpressionType: &value, + }} + result := Validate(document) + require.NotNil(t, result) + assert.True(t, result.HasErrors()) + } +} + +func TestEvaluateJSONPathCriterion_DialectDispatch(t *testing.T) { + body := &yaml.Node{} + require.NoError(t, body.Encode(map[string]any{"paths": map[string]any{"get": true}})) + context := &expression.Context{ResponseBody: body} + + rfc := &high.Criterion{ + Context: "$response.body", + Condition: `$.paths[?(@property == 'get')]`, + ExpressionType: &high.ExpressionType{ + Type: "jsonpath", + Version: "rfc9535", + }, + } + _, err := EvaluateCriterion(rfc, context) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid jsonpath") + + legacy := &high.Criterion{ + Context: "$response.body", + Condition: "$.paths", + ExpressionType: &high.ExpressionType{ + Type: "jsonpath", + Version: "draft-goessner-dispatch-jsonpath-00", + }, + } + _, err = EvaluateCriterion(legacy, context) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedExpressionDialect) +} + +func TestCompileCriterionJSONPath_DialectIsPartOfCacheKey(t *testing.T) { + caches := newCriterionCaches() + rfcPath, err := compileCriterionJSONPath("$.paths", criterionJSONPathRFC9535, caches) + require.NoError(t, err) + require.NotNil(t, rfcPath) + + legacyPath, err := compileCriterionJSONPath("$.paths", criterionJSONPathLegacy, caches) + assert.Nil(t, legacyPath) + assert.ErrorIs(t, err, ErrUnsupportedExpressionDialect) + assert.Len(t, caches.jsonPath, 2) + + _, err = compileCriterionJSONPath("$.paths", criterionJSONPathDialect("unknown"), caches) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown JSONPath dialect") + assert.Len(t, caches.jsonPath, 3) +} + +// Arazzo 1.0.1 section 4.7 defines a general "$components." name production that Arazzo +// 1.1.0 removed. arazzo.Validate is scoped to 1.0 documents, so a criterion context using +// that production must not be reported as an invalid expression. +func TestValidate_Arazzo10AcceptsGeneralComponentsReference(t *testing.T) { + spec := []byte(`arazzo: 1.0.1 +info: + title: components reference + version: 1.0.0 +sourceDescriptions: + - name: api + url: ./api.yaml + type: openapi +components: + inputs: + someInput: + type: string +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op + successCriteria: + - condition: $.id + context: $components.inputs.someInput + type: jsonpath +`) + + doc, err := libopenapi.NewArazzoDocument(spec) + require.NoError(t, err) + + result := Validate(doc) + if result != nil { + for _, e := range result.Errors { + assert.NotContains(t, e.Error(), "expression", + "a legal 1.0 components reference must not be an expression error") + } + } +} diff --git a/arazzo/validation_test.go b/arazzo/validation_test.go index df7b4c715..39517e9e4 100644 --- a/arazzo/validation_test.go +++ b/arazzo/validation_test.go @@ -1434,8 +1434,8 @@ func TestValidate_EarlyReturn_WhenRequiredFieldsMissing(t *testing.T) { func TestValidate_EmptyOutputKeyIsAccepted(t *testing.T) { // An output with a valid key regex should pass doc := validMinimalDoc() - outputs := orderedmap.New[string, string]() - outputs.Set("valid.key-1_0", "$steps.addPet.outputs.id") + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("valid.key-1_0", high.NewExpressionOutputValue("$steps.addPet.outputs.id")) doc.Workflows[0].Outputs = outputs result := Validate(doc) assert.Nil(t, result) @@ -1443,8 +1443,8 @@ func TestValidate_EmptyOutputKeyIsAccepted(t *testing.T) { func TestValidate_InvalidOutputKey(t *testing.T) { doc := validMinimalDoc() - outputs := orderedmap.New[string, string]() - outputs.Set("invalid key!", "$steps.addPet.outputs.id") + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("invalid key!", high.NewExpressionOutputValue("$steps.addPet.outputs.id")) doc.Workflows[0].Outputs = outputs result := Validate(doc) require.NotNil(t, result) @@ -1454,8 +1454,8 @@ func TestValidate_InvalidOutputKey(t *testing.T) { func TestValidate_StepInvalidOutputKey(t *testing.T) { doc := validMinimalDoc() - outputs := orderedmap.New[string, string]() - outputs.Set("bad key!", "$response.body#/id") + outputs := orderedmap.New[string, *high.OutputValue]() + outputs.Set("bad key!", high.NewExpressionOutputValue("$response.body#/id")) doc.Workflows[0].Steps[0].Outputs = outputs result := Validate(doc) require.NotNil(t, result) diff --git a/arazzo_test.go b/arazzo_test.go index 27cc5b3ef..f032d8be3 100644 --- a/arazzo_test.go +++ b/arazzo_test.go @@ -4,6 +4,11 @@ package libopenapi import ( + "bytes" + gocontext "context" + "encoding/json" + "log/slog" + "os" "reflect" "sync" "testing" @@ -16,9 +21,48 @@ import ( "go.yaml.in/yaml/v4" ) +type arazzoContextKey string + +type officialArazzoSchemaMetadata struct { + Specification string `json:"specification"` + Schema string `json:"schema"` + Iteration string `json:"iteration"` + SHA256 string `json:"sha256"` + Bytes int `json:"bytes"` +} + //go:linkname arazzoLowBuildModelFieldCache github.com/pb33f/libopenapi/datamodel/low.buildModelFieldCache var arazzoLowBuildModelFieldCache sync.Map +// TestArazzoOfficialSchemaMetadataIsPinned records which official schema iterations +// this package was built against. It pins the identifiers only — no schema bytes are +// stored here and nothing is hashed, so this asserts a documented claim rather than a +// verified contract. The authoritative check belongs in libopenapi-validator, which +// embeds and compiles these schemas; the checksums below exist so that a mismatch +// there can be traced back to the iteration this model targeted. +func TestArazzoOfficialSchemaMetadataIsPinned(t *testing.T) { + metadataBytes, err := os.ReadFile("arazzo/testdata/official-schema-metadata.json") + require.NoError(t, err) + var metadata map[string]officialArazzoSchemaMetadata + require.NoError(t, json.Unmarshal(metadataBytes, &metadata)) + + assert.Equal(t, officialArazzoSchemaMetadata{ + 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, + }, metadata["arazzo-1.0"]) + assert.Equal(t, officialArazzoSchemaMetadata{ + 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, + }, metadata["arazzo-1.1"]) + assert.Len(t, metadata, 2) +} + func TestNewArazzoDocument_ValidFull(t *testing.T) { yml := []byte(`arazzo: 1.0.1 info: @@ -152,6 +196,156 @@ workflows: assert.Nil(t, doc.Components) } +func TestNewArazzoDocumentWithConfiguration_OriginAndContext(t *testing.T) { + yml := []byte(`arazzo: 1.1.0 +$self: ../portable/root.yaml +info: + title: Configured + version: 1.0.0 +sourceDescriptions: + - name: api + url: api.yaml + type: openapi +workflows: + - workflowId: configured + steps: + - stepId: step + operationId: get +`) + key := arazzoContextKey("configured") + ctx := gocontext.WithValue(gocontext.Background(), key, "value") + config := &ArazzoDocumentConfiguration{ + Context: ctx, + RetrievalURI: "https://retrieval.example/workflows/source.yaml", + ApplicationBaseURI: "https://application.example/default/", + } + document, err := NewArazzoDocumentWithConfiguration(yml, config) + require.NoError(t, err) + require.NotNil(t, document) + assert.Equal(t, "value", document.GoLow().GetContext().Value(key)) + origin := document.GetDocumentOrigin() + require.NotNil(t, origin) + assert.Equal(t, "https://retrieval.example/portable/root.yaml", origin.ResolvedIdentity) + assert.Equal(t, "https://retrieval.example/workflows/source.yaml", origin.RetrievalURI) + + config.RetrievalURI = "https://mutated.example/root.yaml" + assert.Equal(t, "https://retrieval.example/workflows/source.yaml", document.GetDocumentOrigin().RetrievalURI) +} + +func TestNewArazzoDocumentWithConfiguration_InvalidOrigin(t *testing.T) { + yml := []byte(`arazzo: 1.1.0 +info: + title: Configured + version: 1.0.0 +sourceDescriptions: [] +workflows: [] +`) + document, err := NewArazzoDocumentWithConfiguration(yml, &ArazzoDocumentConfiguration{ + RetrievalURI: "https://example.com/%zz", + }) + assert.Nil(t, document) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve arazzo document origin") +} + +func TestNewArazzoDocumentWithConfiguration_MalformedAuthoredSelfStillBuilds(t *testing.T) { + yml := []byte(`arazzo: 1.1.0 +info: + title: Malformed authored identity + version: 1.0.0 +$self: https://identity.example/%zz +sourceDescriptions: [] +workflows: [] +`) + document, err := NewArazzoDocumentWithConfiguration(yml, &ArazzoDocumentConfiguration{ + RetrievalURI: "https://retrieval.example/workflows/root.yaml", + }) + require.NoError(t, err) + require.NotNil(t, document) + assert.Equal(t, "https://identity.example/%zz", document.Self) + origin := document.GetDocumentOrigin() + require.NotNil(t, origin) + assert.Equal(t, "https://identity.example/%zz", origin.AuthoredSelf) + assert.Equal(t, "https://retrieval.example/workflows/root.yaml", origin.ResolvedIdentity) +} + +func TestNewArazzoDocument_Arazzo11OfficialFixtureYAMLJSONEquivalent(t *testing.T) { + yamlBytes, err := os.ReadFile("arazzo/testdata/arazzo-1.1-official.yaml") + require.NoError(t, err) + yamlDocument, err := NewArazzoDocument(yamlBytes) + require.NoError(t, err) + + var value any + require.NoError(t, yaml.Unmarshal(yamlBytes, &value)) + jsonBytes, err := json.Marshal(value) + require.NoError(t, err) + jsonDocument, err := NewArazzoDocument(jsonBytes) + require.NoError(t, err) + + assert.Equal(t, yamlDocument.Arazzo, jsonDocument.Arazzo) + assert.Equal(t, yamlDocument.Self, jsonDocument.Self) + require.Len(t, yamlDocument.Workflows, 1) + require.Len(t, jsonDocument.Workflows, 1) + assert.Equal(t, yamlDocument.Workflows[0].WorkflowId, jsonDocument.Workflows[0].WorkflowId) + require.Len(t, yamlDocument.Workflows[0].Steps, 3) + require.Len(t, jsonDocument.Workflows[0].Steps, 3) + for index := range yamlDocument.Workflows[0].Steps { + yamlStep := yamlDocument.Workflows[0].Steps[index] + jsonStep := jsonDocument.Workflows[0].Steps[index] + assert.Equal(t, yamlStep.StepId, jsonStep.StepId) + assert.Equal(t, yamlStep.Action, jsonStep.Action) + assert.Equal(t, yamlStep.DependsOn, jsonStep.DependsOn) + assert.Equal(t, yamlStep.Timeout, jsonStep.Timeout) + } + + rendered, err := yamlDocument.Render() + require.NoError(t, err) + reloaded, err := NewArazzoDocument(rendered) + require.NoError(t, err) + assert.Equal(t, yamlDocument.Self, reloaded.Self) + assert.True(t, reloaded.Workflows[0].Steps[0].Outputs.First().Value().IsSelector()) +} + +func TestNewArazzoDocument_Arazzo10GoldenRenderCompatibility(t *testing.T) { + fixture, err := os.ReadFile("arazzo/testdata/arazzo-1.0-render.yaml") + require.NoError(t, err) + document, err := NewArazzoDocument(fixture) + require.NoError(t, err) + rendered, err := document.Render() + require.NoError(t, err) + assert.Equal(t, string(fixture), string(rendered)) + assert.NotContains(t, string(rendered), "$self") + assert.NotContains(t, string(rendered), "channelPath") + assert.NotContains(t, string(rendered), "targetSelectorType") +} + +func TestNewArazzoDocument_ConcurrentParseAndRender(t *testing.T) { + fixture, err := os.ReadFile("arazzo/testdata/arazzo-1.1-official.yaml") + require.NoError(t, err) + const workers = 32 + var waitGroup sync.WaitGroup + errorsChannel := make(chan error, workers) + for range workers { + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + document, buildErr := NewArazzoDocument(fixture) + if buildErr != nil { + errorsChannel <- buildErr + return + } + if _, renderErr := document.Render(); renderErr != nil { + errorsChannel <- renderErr + } + }() + } + waitGroup.Wait() + close(errorsChannel) + for concurrentErr := range errorsChannel { + assert.NoError(t, concurrentErr) + } +} + func TestNewArazzoDocument_InvalidYAML(t *testing.T) { yml := []byte(`{{{ not valid yaml`) doc, err := NewArazzoDocument(yml) @@ -541,3 +735,61 @@ workflows: func setArazzoUnexportedField(field reflect.Value, value any) { reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value)) } + +// The configured logger receives the resolved origin at debug level. Origin resolution weighs +// $self, the retrieval URI and the application base against each other, so the outcome is the +// detail worth tracing when a relative source URL resolves somewhere unexpected. +func TestNewArazzoDocumentWithConfiguration_LogsResolvedOrigin(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + spec := []byte(`arazzo: 1.1.0 +$self: ./workflows/main.arazzo.yaml +info: + title: origin logging + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := NewArazzoDocumentWithConfiguration(spec, &ArazzoDocumentConfiguration{ + RetrievalURI: "https://specs.example.com/root.arazzo.yaml", + ApplicationBaseURI: "https://fallback.example.com/", + Logger: logger, + }) + require.NoError(t, err) + require.NotNil(t, doc) + + logged := buf.String() + assert.Contains(t, logged, "resolved arazzo document origin") + assert.Contains(t, logged, "authoredSelf=./workflows/main.arazzo.yaml") + assert.Contains(t, logged, "retrievalURI=https://specs.example.com/root.arazzo.yaml") + assert.Contains(t, logged, "applicationBaseURI=https://fallback.example.com/") + // $self is relative, so it resolves against the retrieval URI. + assert.Contains(t, logged, "resolvedIdentity=https://specs.example.com/workflows/main.arazzo.yaml") + assert.Contains(t, logged, "effectiveBaseURI=https://specs.example.com/workflows/main.arazzo.yaml") +} + +// A nil logger must not be called, and must not stop the document building. +func TestNewArazzoDocumentWithConfiguration_NilLoggerIsSkipped(t *testing.T) { + spec := []byte(`arazzo: 1.1.0 +info: + title: no logger + version: 1.0.0 +workflows: + - workflowId: wf + steps: + - stepId: s1 + operationId: op +`) + + doc, err := NewArazzoDocumentWithConfiguration(spec, &ArazzoDocumentConfiguration{ + RetrievalURI: "https://specs.example.com/root.arazzo.yaml", + }) + require.NoError(t, err) + require.NotNil(t, doc) + assert.Equal(t, "https://specs.example.com/root.arazzo.yaml", doc.GetDocumentOrigin().ResolvedIdentity) +} diff --git a/datamodel/high/arazzo/arazzo.go b/datamodel/high/arazzo/arazzo.go index b830738b7..aa6f8f687 100644 --- a/datamodel/high/arazzo/arazzo.go +++ b/datamodel/high/arazzo/arazzo.go @@ -4,6 +4,8 @@ package arazzo import ( + "sync" + "github.com/pb33f/libopenapi/datamodel/high" v3 "github.com/pb33f/libopenapi/datamodel/high/v3" low "github.com/pb33f/libopenapi/datamodel/low/arazzo" @@ -12,25 +14,47 @@ import ( ) // Arazzo represents a high-level Arazzo document. -// https://spec.openapis.org/arazzo/v1.0.1 +// https://spec.openapis.org/arazzo/v1.1.0 type Arazzo struct { Arazzo string `json:"arazzo,omitempty" yaml:"arazzo,omitempty"` + Self string `json:"$self,omitempty" yaml:"$self,omitempty"` Info *Info `json:"info,omitempty" yaml:"info,omitempty"` SourceDescriptions []*SourceDescription `json:"sourceDescriptions,omitempty" yaml:"sourceDescriptions,omitempty"` Workflows []*Workflow `json:"workflows,omitempty" yaml:"workflows,omitempty"` Components *Components `json:"components,omitempty" yaml:"components,omitempty"` Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` - openAPISourceDocs []*v3.Document - low *low.Arazzo + + // openAPISourceDocs is attached after construction by source resolution, unlike + // every other field here, which is populated once while the model is still + // private to its builder. ResolveSources attaches to the document it was handed, + // so two goroutines resolving the same document, or one resolving while another + // validates, both touch this slice concurrently. It is guarded accordingly. + sourceDocsMu sync.RWMutex + openAPISourceDocs []*v3.Document + + origin *DocumentOrigin + low *low.Arazzo } // NewArazzo creates a new high-level Arazzo instance from a low-level one. func NewArazzo(a *low.Arazzo) *Arazzo { + return NewArazzoWithOrigin(a, nil) +} + +// NewArazzoWithOrigin creates a high-level Arazzo instance with immutable origin metadata. +func NewArazzoWithOrigin(a *low.Arazzo, origin *DocumentOrigin) *Arazzo { h := new(Arazzo) h.low = a + if origin != nil { + originCopy := *origin + h.origin = &originCopy + } if !a.Arazzo.IsEmpty() { h.Arazzo = a.Arazzo.Value } + if !a.Self.IsEmpty() { + h.Self = a.Self.Value + } if !a.Info.IsEmpty() { h.Info = NewInfo(a.Info.Value) } @@ -59,10 +83,13 @@ func (a *Arazzo) GoLowUntyped() any { // AddOpenAPISourceDocument attaches one or more OpenAPI source documents to this Arazzo model. // Attached documents are runtime metadata and are not rendered or serialized. +// It is safe to call concurrently, and concurrently with GetOpenAPISourceDocuments. func (a *Arazzo) AddOpenAPISourceDocument(docs ...*v3.Document) { if a == nil || len(docs) == 0 { return } + a.sourceDocsMu.Lock() + defer a.sourceDocsMu.Unlock() for _, doc := range docs { if doc != nil { a.openAPISourceDocs = append(a.openAPISourceDocs, doc) @@ -71,8 +98,14 @@ func (a *Arazzo) AddOpenAPISourceDocument(docs ...*v3.Document) { } // GetOpenAPISourceDocuments returns attached OpenAPI source documents. +// The returned slice is a copy, so callers may retain it while more are attached. func (a *Arazzo) GetOpenAPISourceDocuments() []*v3.Document { - if a == nil || len(a.openAPISourceDocs) == 0 { + if a == nil { + return nil + } + a.sourceDocsMu.RLock() + defer a.sourceDocsMu.RUnlock() + if len(a.openAPISourceDocs) == 0 { return nil } docs := make([]*v3.Document, len(a.openAPISourceDocs)) @@ -88,20 +121,61 @@ func (a *Arazzo) Render() ([]byte, error) { // MarshalYAML creates a ready to render YAML representation of the Arazzo object. func (a *Arazzo) MarshalYAML() (any, error) { m := orderedmap.New[string, any]() - if a.Arazzo != "" { - m.Set(low.ArazzoLabel, a.Arazzo) - } - if a.Info != nil { - m.Set(low.InfoLabel, a.Info) - } - if len(a.SourceDescriptions) > 0 { - m.Set(low.SourceDescriptionsLabel, a.SourceDescriptions) + setKnownField := func(label string) bool { + switch label { + case low.ArazzoLabel: + if a.Arazzo != "" { + m.Set(label, a.Arazzo) + } + case low.SelfLabel: + if a.Self != "" { + m.Set(label, a.Self) + } + case low.InfoLabel: + if a.Info != nil { + m.Set(label, a.Info) + } + case low.SourceDescriptionsLabel: + if len(a.SourceDescriptions) > 0 { + m.Set(label, a.SourceDescriptions) + } + case low.WorkflowsLabel: + if len(a.Workflows) > 0 { + m.Set(label, a.Workflows) + } + case low.ComponentsLabel: + if a.Components != nil { + m.Set(label, a.Components) + } + default: + return false + } + return true } - if len(a.Workflows) > 0 { - m.Set(low.WorkflowsLabel, a.Workflows) + if a.low != nil && a.low.RootNode != nil { + for i := 0; i+1 < len(a.low.RootNode.Content); i += 2 { + label := a.low.RootNode.Content[i].Value + if setKnownField(label) { + continue + } + if a.Extensions != nil { + if extension, ok := a.Extensions.Get(label); ok { + m.Set(label, extension) + } + } + } } - if a.Components != nil { - m.Set(low.ComponentsLabel, a.Components) + for _, label := range []string{ + low.ArazzoLabel, + low.SelfLabel, + low.InfoLabel, + low.SourceDescriptionsLabel, + low.WorkflowsLabel, + low.ComponentsLabel, + } { + if _, exists := m.Get(label); !exists { + setKnownField(label) + } } marshalExtensions(m, a.Extensions) return m, nil diff --git a/datamodel/high/arazzo/arazzo_test.go b/datamodel/high/arazzo/arazzo_test.go index 6c48725e3..349fc4770 100644 --- a/datamodel/high/arazzo/arazzo_test.go +++ b/datamodel/high/arazzo/arazzo_test.go @@ -250,6 +250,18 @@ func TestArazzo_AddOpenAPISourceDocument_NilReceiver(t *testing.T) { assert.Nil(t, h.GetOpenAPISourceDocuments()) } +// A live document with nothing attached returns nil rather than an empty slice. This is +// a distinct path from the nil receiver, since reading the slice requires the lock. +func TestArazzo_GetOpenAPISourceDocuments_NoneAttached(t *testing.T) { + h := &Arazzo{} + assert.Nil(t, h.GetOpenAPISourceDocuments()) + + // Attaching nothing must not change that. + h.AddOpenAPISourceDocument() + h.AddOpenAPISourceDocument(nil) + assert.Nil(t, h.GetOpenAPISourceDocuments()) +} + func TestArazzo_MinimalDocument(t *testing.T) { yml := `arazzo: 1.0.1 info: @@ -458,7 +470,9 @@ func TestWorkflow_Outputs(t *testing.T) { require.NotNil(t, wf.Outputs) val, ok := wf.Outputs.Get("createdPetId") assert.True(t, ok) - assert.Equal(t, "$steps.addPet.outputs.petId", val) + expressionValue, ok := val.GetExpression() + require.True(t, ok) + assert.Equal(t, "$steps.addPet.outputs.petId", expressionValue) } // --------------------------------------------------------------------------- @@ -516,7 +530,9 @@ func TestStep_Outputs(t *testing.T) { require.NotNil(t, step.Outputs) val, ok := step.Outputs.Get("petId") assert.True(t, ok) - assert.Equal(t, "$response.body#/id", val) + expressionValue, ok := val.GetExpression() + require.True(t, ok) + assert.Equal(t, "$response.body#/id", expressionValue) } // --------------------------------------------------------------------------- diff --git a/datamodel/high/arazzo/build_helpers.go b/datamodel/high/arazzo/build_helpers.go index 505ea2a57..632e4bae0 100644 --- a/datamodel/high/arazzo/build_helpers.go +++ b/datamodel/high/arazzo/build_helpers.go @@ -4,7 +4,12 @@ package arazzo import ( + "context" + "github.com/pb33f/libopenapi/datamodel/low" + lowarazzo "github.com/pb33f/libopenapi/datamodel/low/arazzo" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" ) // buildSlice converts a slice of low.ValueReference[L] to a slice of H using a conversion function. @@ -19,6 +24,99 @@ func buildSlice[L any, H any](refs []low.ValueReference[L], convert func(L) H) [ return out } +func dereferenceAliasNode(node *yaml.Node) *yaml.Node { + seen := make(map[*yaml.Node]struct{}) + for node != nil && node.Kind == yaml.AliasNode { + if _, exists := seen[node]; exists || node.Alias == nil { + return nil + } + seen[node] = struct{}{} + node = node.Alias + } + return node +} + +func selectorFromNode(node *yaml.Node) *Selector { + node = dereferenceAliasNode(node) + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + var hasContext, hasSelector, hasType bool + for i := 0; i+1 < len(node.Content); i += 2 { + value := dereferenceAliasNode(node.Content[i+1]) + if value == nil { + return nil + } + switch node.Content[i].Value { + case lowarazzo.ContextLabel: + if value.Kind != yaml.ScalarNode { + return nil + } + hasContext = true + case lowarazzo.SelectorLabel: + if value.Kind != yaml.ScalarNode { + return nil + } + hasSelector = true + case lowarazzo.TypeLabel: + hasType = true + } + } + if !hasContext || !hasSelector || !hasType { + return nil + } + selector := new(lowarazzo.Selector) + _ = low.BuildModel(node, selector) + if err := selector.Build(context.Background(), nil, node, nil); err != nil { + return nil + } + return NewSelector(selector) +} + +func selectorsFromNode(node *yaml.Node) []*Selector { + return selectorsFromNodePath(node, make(map[*yaml.Node]struct{})) +} + +func selectorsFromNodePath(node *yaml.Node, active map[*yaml.Node]struct{}) []*Selector { + node = dereferenceAliasNode(node) + if node == nil { + return nil + } + if _, exists := active[node]; exists { + return nil + } + active[node] = struct{}{} + defer delete(active, node) + if selector := selectorFromNode(node); selector != nil { + return []*Selector{selector} + } + var selectors []*Selector + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + selectors = append(selectors, selectorsFromNodePath(child, active)...) + } + case yaml.MappingNode: + for i := 1; i < len(node.Content); i += 2 { + selectors = append(selectors, selectorsFromNodePath(node.Content[i], active)...) + } + } + return selectors +} + +func buildOutputValueMap( + refs *orderedmap.Map[low.KeyReference[string], low.ValueReference[*lowarazzo.OutputValue]], +) *orderedmap.Map[string, *OutputValue] { + if refs == nil { + return nil + } + outputs := orderedmap.New[string, *OutputValue]() + for pair := refs.First(); pair != nil; pair = pair.Next() { + outputs.Set(pair.Key().Value, NewOutputValue(pair.Value().Value)) + } + return outputs +} + // buildValueSlice extracts the Value from each low.ValueReference into a plain slice. func buildValueSlice[T any](refs []low.ValueReference[T]) []T { if len(refs) == 0 { diff --git a/datamodel/high/arazzo/coverage_test.go b/datamodel/high/arazzo/coverage_test.go index 293a720b9..945bbfb8f 100644 --- a/datamodel/high/arazzo/coverage_test.go +++ b/datamodel/high/arazzo/coverage_test.go @@ -1128,7 +1128,7 @@ func TestWorkflow_MarshalYAML_NilOutputs(t *testing.T) { func TestWorkflow_MarshalYAML_EmptyOutputs(t *testing.T) { wf := &Workflow{ WorkflowId: "wf1", - Outputs: orderedmap.New[string, string](), + Outputs: orderedmap.New[string, *OutputValue](), } rendered, err := wf.Render() @@ -1160,7 +1160,7 @@ func TestStep_MarshalYAML_EmptyOutputs(t *testing.T) { step := &Step{ StepId: "s1", OperationId: "op1", - Outputs: orderedmap.New[string, string](), + Outputs: orderedmap.New[string, *OutputValue](), } rendered, err := step.Render() @@ -1312,3 +1312,13 @@ func TestComponents_MarshalYAML_AllMaps(t *testing.T) { assert.Contains(t, s, "successActions:") assert.Contains(t, s, "failureActions:") } + +func TestSelectorFromNodeRejectsEmptyMemberAlias(t *testing.T) { + emptyAlias := &yaml.Node{Kind: yaml.AliasNode} + root := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: low.ContextLabel}, emptyAlias, + {Kind: yaml.ScalarNode, Value: low.SelectorLabel}, {Kind: yaml.ScalarNode, Value: "$.id"}, + {Kind: yaml.ScalarNode, Value: low.TypeLabel}, {Kind: yaml.ScalarNode, Value: "jsonpath"}, + }} + assert.Nil(t, selectorFromNode(root)) +} diff --git a/datamodel/high/arazzo/criterion.go b/datamodel/high/arazzo/criterion.go index d2c090755..fd5709243 100644 --- a/datamodel/high/arazzo/criterion.go +++ b/datamodel/high/arazzo/criterion.go @@ -48,15 +48,17 @@ func NewCriterion(criterion *low.Criterion) *Criterion { } // Type is a union: scalar string or CriterionExpressionType mapping if !criterion.Type.IsEmpty() && criterion.Type.Value != nil { - node := criterion.Type.Value - switch node.Kind { - case yaml.ScalarNode: - c.Type = node.Value - case yaml.MappingNode: - cet := &low.CriterionExpressionType{} - if err := lowmodel.BuildModel(node, cet); err == nil { - if err = cet.Build(context.Background(), nil, node, nil); err == nil { - c.ExpressionType = NewCriterionExpressionType(cet) + node := dereferenceAliasNode(criterion.Type.Value) + if node != nil { + switch node.Kind { + case yaml.ScalarNode: + c.Type = node.Value + case yaml.MappingNode: + cet := &low.CriterionExpressionType{} + if err := lowmodel.BuildModel(node, cet); err == nil { + if err = cet.Build(context.Background(), nil, node, nil); err == nil { + c.ExpressionType = NewCriterionExpressionType(cet) + } } } } diff --git a/datamodel/high/arazzo/criterion_expression_type.go b/datamodel/high/arazzo/criterion_expression_type.go index 903160a5d..8812db959 100644 --- a/datamodel/high/arazzo/criterion_expression_type.go +++ b/datamodel/high/arazzo/criterion_expression_type.go @@ -10,18 +10,18 @@ import ( "go.yaml.in/yaml/v4" ) -// CriterionExpressionType represents a high-level Arazzo Criterion Expression Type Object. -// https://spec.openapis.org/arazzo/v1.0.1#criterion-expression-type-object -type CriterionExpressionType struct { +// ExpressionType represents a high-level Arazzo Expression Type Object. +// https://spec.openapis.org/arazzo/v1.1.0#expression-type-object +type ExpressionType struct { Type string `json:"type,omitempty" yaml:"type,omitempty"` Version string `json:"version,omitempty" yaml:"version,omitempty"` Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` - low *low.CriterionExpressionType + low *low.ExpressionType } -// NewCriterionExpressionType creates a new high-level CriterionExpressionType instance from a low-level one. -func NewCriterionExpressionType(cet *low.CriterionExpressionType) *CriterionExpressionType { - c := new(CriterionExpressionType) +// NewExpressionType creates a new high-level ExpressionType instance from a low-level one. +func NewExpressionType(cet *low.ExpressionType) *ExpressionType { + c := new(ExpressionType) c.low = cet if !cet.Type.IsEmpty() { c.Type = cet.Type.Value @@ -34,22 +34,22 @@ func NewCriterionExpressionType(cet *low.CriterionExpressionType) *CriterionExpr } // GoLow returns the low-level CriterionExpressionType instance used to create the high-level one. -func (c *CriterionExpressionType) GoLow() *low.CriterionExpressionType { +func (c *ExpressionType) GoLow() *low.ExpressionType { return c.low } // GoLowUntyped returns the low-level CriterionExpressionType instance with no type. -func (c *CriterionExpressionType) GoLowUntyped() any { +func (c *ExpressionType) GoLowUntyped() any { return c.low } // Render returns a YAML representation of the CriterionExpressionType object as a byte slice. -func (c *CriterionExpressionType) Render() ([]byte, error) { +func (c *ExpressionType) Render() ([]byte, error) { return yaml.Marshal(c) } // MarshalYAML creates a ready to render YAML representation of the CriterionExpressionType object. -func (c *CriterionExpressionType) MarshalYAML() (any, error) { +func (c *ExpressionType) MarshalYAML() (any, error) { m := orderedmap.New[string, any]() if c.Type != "" { m.Set("type", c.Type) @@ -60,3 +60,11 @@ func (c *CriterionExpressionType) MarshalYAML() (any, error) { marshalExtensions(m, c.Extensions) return m, nil } + +// CriterionExpressionType is retained as an alias for source compatibility with Arazzo 1.0 callers. +type CriterionExpressionType = ExpressionType + +// NewCriterionExpressionType creates a reusable ExpressionType from the legacy low-level alias. +func NewCriterionExpressionType(cet *low.CriterionExpressionType) *CriterionExpressionType { + return NewExpressionType(cet) +} diff --git a/datamodel/high/arazzo/failure_action.go b/datamodel/high/arazzo/failure_action.go index 1a5c69d26..ffcc04d3e 100644 --- a/datamodel/high/arazzo/failure_action.go +++ b/datamodel/high/arazzo/failure_action.go @@ -12,7 +12,7 @@ import ( // FailureAction represents a high-level Arazzo Failure Action Object. // A failure action can be a full definition or a Reusable Object with a $components reference. -// https://spec.openapis.org/arazzo/v1.0.1#failure-action-object +// https://spec.openapis.org/arazzo/v1.1.0#failure-action-object type FailureAction struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` Type string `json:"type,omitempty" yaml:"type,omitempty"` @@ -21,6 +21,7 @@ type FailureAction struct { RetryAfter *float64 `json:"retryAfter,omitempty" yaml:"retryAfter,omitempty"` RetryLimit *int64 `json:"retryLimit,omitempty" yaml:"retryLimit,omitempty"` Criteria []*Criterion `json:"criteria,omitempty" yaml:"criteria,omitempty"` + Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` Reference string `json:"reference,omitempty" yaml:"reference,omitempty"` Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` low *low.FailureAction @@ -61,6 +62,9 @@ func NewFailureAction(fa *low.FailureAction) *FailureAction { if !fa.Criteria.IsEmpty() { f.Criteria = buildSlice(fa.Criteria.Value, NewCriterion) } + if !fa.Parameters.IsEmpty() { + f.Parameters = buildSlice(fa.Parameters.Value, NewParameter) + } f.Extensions = high.ExtractExtensions(fa.Extensions) return f } @@ -108,6 +112,9 @@ func (f *FailureAction) MarshalYAML() (any, error) { if len(f.Criteria) > 0 { m.Set(low.CriteriaLabel, f.Criteria) } + if len(f.Parameters) > 0 { + m.Set(low.ParametersLabel, f.Parameters) + } marshalExtensions(m, f.Extensions) return m, nil } diff --git a/datamodel/high/arazzo/fuzz_test.go b/datamodel/high/arazzo/fuzz_test.go new file mode 100644 index 000000000..2d60ba100 --- /dev/null +++ b/datamodel/high/arazzo/fuzz_test.go @@ -0,0 +1,42 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "testing" + + "go.yaml.in/yaml/v4" +) + +func FuzzParseSpecificationVersionNeverPanics(f *testing.F) { + for _, version := range []string{"1.0.0", "1.1.0", "1.1.2-rc.1", "", "1..0"} { + f.Add(version) + } + f.Fuzz(func(_ *testing.T, version string) { + _, _ = ParseSpecificationVersion(version) + }) +} + +func FuzzSelectorUnionNeverPanics(f *testing.F) { + for _, source := range []string{ + "context: $response.body\nselector: $.id\ntype: jsonpath\n", + "context: $response.body\nselector: /id\ntype:\n type: jsonpointer\n version: rfc6901\n", + "selector: malformed\n", + "null\n", + } { + f.Add([]byte(source)) + } + f.Fuzz(func(_ *testing.T, source []byte) { + var document yaml.Node + if yaml.Unmarshal(source, &document) != nil { + return + } + selectors := selectorsFromNode(&document) + for _, selector := range selectors { + if selector != nil { + _, _ = selector.Render() + } + } + }) +} diff --git a/datamodel/high/arazzo/model_rendering_test.go b/datamodel/high/arazzo/model_rendering_test.go new file mode 100644 index 000000000..acb564bb3 --- /dev/null +++ b/datamodel/high/arazzo/model_rendering_test.go @@ -0,0 +1,458 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "encoding/json" + "strings" + "testing" + + lowmodel "github.com/pb33f/libopenapi/datamodel/low" + low "github.com/pb33f/libopenapi/datamodel/low/arazzo" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +const arazzo11HighYAML = `arazzo: 1.1.0 +$self: https://example.com/workflows/root.arazzo.yaml +info: + title: Arazzo 1.1 + version: 1.0.0 +sourceDescriptions: + - name: messages + url: asyncapi.yaml + type: asyncapi +workflows: + - workflowId: publish + steps: + - stepId: send + channelPath: $sourceDescriptions.messages#/channels/orders + action: send + correlationId: order-id + timeout: 2500 + dependsOn: [prepare] + requestBody: + replacements: + - target: $.id + targetSelectorType: + type: jsonpath + version: rfc9535 + value: + context: $inputs + selector: $.id + type: jsonpath + onSuccess: + - name: done + type: end + parameters: + - name: result + value: $outputs.result + onFailure: + - name: retry + type: retry + retryLimit: 1 + parameters: + - name: reason + value: failed + outputs: + selected: + context: $response.body + selector: $.id + type: + type: jsonpath + version: rfc9535 + outputs: + status: $steps.send.outputs.selected +` + +func TestArazzo11HighModel_RoundTripAndTypedAccess(t *testing.T) { + document := buildHighArazzo(t, arazzo11HighYAML) + assert.Equal(t, "https://example.com/workflows/root.arazzo.yaml", document.Self) + step := document.Workflows[0].Steps[0] + assert.Equal(t, "$sourceDescriptions.messages#/channels/orders", step.ChannelPath) + assert.Equal(t, "send", step.Action) + assert.Equal(t, "order-id", step.CorrelationId) + require.NotNil(t, step.Timeout) + assert.EqualValues(t, 2500, *step.Timeout) + assert.Equal(t, []string{"prepare"}, step.DependsOn) + require.Len(t, step.OnSuccess[0].Parameters, 1) + require.Len(t, step.OnFailure[0].Parameters, 1) + + output, ok := step.Outputs.Get("selected") + require.True(t, ok) + assert.True(t, output.IsSelector()) + assert.False(t, output.IsExpression()) + selector, ok := output.GetSelector() + require.True(t, ok) + assert.Equal(t, "jsonpath", selector.GetEffectiveType()) + assert.Equal(t, "rfc9535", selector.ExpressionType.Version) + assert.NotNil(t, selector.GoLow()) + assert.NotNil(t, selector.GoLowUntyped()) + assert.NotNil(t, output.GoLow()) + assert.NotNil(t, output.GoLowUntyped()) + + workflowOutput, ok := document.Workflows[0].Outputs.Get("status") + require.True(t, ok) + expression, ok := workflowOutput.GetExpression() + require.True(t, ok) + assert.Equal(t, "$steps.send.outputs.selected", expression) + assert.False(t, workflowOutput.IsSelector()) + _, ok = workflowOutput.GetSelector() + assert.False(t, ok) + + replacement := step.RequestBody.Replacements[0] + assert.Equal(t, "jsonpath", replacement.TargetSelectorExpressionType.Type) + assert.True(t, replacement.IsSelector()) + assert.False(t, replacement.IsRuntimeExpression()) + replacementSelector, ok := replacement.GetSelector() + require.True(t, ok) + assert.Equal(t, "$.id", replacementSelector.Selector) + assert.Same(t, replacement.Value, replacement.GetValueNode()) + + rendered, err := document.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "$self: https://example.com/workflows/root.arazzo.yaml") + assert.Contains(t, string(rendered), "channelPath:") + assert.Contains(t, string(rendered), "targetSelectorType:") + + reloaded := buildHighArazzo(t, string(rendered)) + assert.Equal(t, document.Self, reloaded.Self) + assert.True(t, reloaded.Workflows[0].Steps[0].Outputs.First().Value().IsSelector()) +} + +func TestCriterionBuildsAliasedTypeVariants(t *testing.T) { + for _, test := range []struct { + name string + source string + wantType string + wantObjectType string + wantVersion string + }{ + { + name: "scalar", + source: "typeValue: &typeValue jsonpath\ncriterion:\n context: $response.body\n condition: $.items\n type: *typeValue", + wantType: "jsonpath", + }, + { + name: "expression type object", + source: "typeValue: &typeValue\n type: jsonpath\n version: rfc9535\ncriterion:\n context: $response.body\n condition: $.items\n type: *typeValue", + wantObjectType: "jsonpath", + wantVersion: "rfc9535", + }, + } { + t.Run(test.name, func(t *testing.T) { + var document yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(test.source), &document)) + root := document.Content[0] + criterionNode := root.Content[3] + lowCriterion := new(low.Criterion) + require.NoError(t, lowmodel.BuildModel(criterionNode, lowCriterion)) + require.NoError(t, lowCriterion.Build(context.Background(), root.Content[2], criterionNode, nil)) + + criterion := NewCriterion(lowCriterion) + assert.Equal(t, test.wantType, criterion.Type) + if test.wantObjectType == "" { + assert.Nil(t, criterion.ExpressionType) + return + } + require.NotNil(t, criterion.ExpressionType) + assert.Equal(t, test.wantObjectType, criterion.ExpressionType.Type) + assert.Equal(t, test.wantVersion, criterion.ExpressionType.Version) + }) + } +} + +func TestArazzo11RootRenderingPreservesAuthoredSelfOrder(t *testing.T) { + document := buildHighArazzo(t, `info: + title: ordered + version: 1.0.0 +x-before: true +arazzo: 1.1.0 +$self: https://example.com/root.yaml +sourceDescriptions: + - name: source + url: openapi.yaml +workflows: + - workflowId: workflow + steps: [] +`) + rendered, err := document.Render() + require.NoError(t, err) + text := string(rendered) + infoOffset := strings.Index(text, "info:") + extensionOffset := strings.Index(text, "x-before:") + arazzoOffset := strings.Index(text, "arazzo:") + selfOffset := strings.Index(text, "$self:") + sourceOffset := strings.Index(text, "sourceDescriptions:") + assert.True(t, + infoOffset < extensionOffset && + extensionOffset < arazzoOffset && + arazzoOffset < selfOffset && + selfOffset < sourceOffset, + text, + ) + + document.Self = "" + rendered, err = document.Render() + require.NoError(t, err) + assert.NotContains(t, string(rendered), "$self:") +} + +func TestSelector_ScalarAndObjectRendering(t *testing.T) { + scalar := &Selector{Context: "$inputs", Selector: "$.id", Type: "jsonpath"} + assert.Equal(t, "jsonpath", scalar.GetEffectiveType()) + rendered, err := scalar.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "type: jsonpath") + jsonBytes, err := json.Marshal(scalar) + require.NoError(t, err) + assert.JSONEq(t, `{"context":"$inputs","selector":"$.id","type":"jsonpath"}`, string(jsonBytes)) + + object := &Selector{ + Context: "$response.body", + Selector: "/id", + ExpressionType: &ExpressionType{ + Type: "jsonpointer", + Version: "rfc6901", + }, + } + assert.Equal(t, "jsonpointer", object.GetEffectiveType()) + jsonBytes, err = json.Marshal(object) + require.NoError(t, err) + assert.JSONEq(t, `{"context":"$response.body","selector":"/id","type":{"type":"jsonpointer","version":"rfc6901"}}`, string(jsonBytes)) + + invalid := &Selector{Type: "jsonpath", ExpressionType: &ExpressionType{Type: "xpath"}} + _, err = invalid.MarshalYAML() + require.Error(t, err) + _, err = invalid.MarshalJSON() + require.Error(t, err) +} + +func TestOutputValue_AllVariantsAndInvalidStates(t *testing.T) { + expression := NewExpressionOutputValue("$statusCode") + value, ok := expression.GetExpression() + require.True(t, ok) + assert.Equal(t, "$statusCode", value) + rendered, err := expression.Render() + require.NoError(t, err) + assert.Equal(t, "$statusCode\n", string(rendered)) + jsonBytes, err := expression.MarshalJSON() + require.NoError(t, err) + assert.Equal(t, `"$statusCode"`, string(jsonBytes)) + + emptyExpression := NewExpressionOutputValue("") + assert.True(t, emptyExpression.IsExpression()) + rendered, err = emptyExpression.Render() + require.NoError(t, err) + assert.Equal(t, `""`+"\n", string(rendered)) + + selector := NewSelectorOutputValue(&Selector{Context: "$response.body", Selector: "$.id", Type: "jsonpath"}) + require.True(t, selector.IsSelector()) + _, ok = selector.GetExpression() + assert.False(t, ok) + rendered, err = selector.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "selector: $.id") + jsonBytes, err = selector.MarshalJSON() + require.NoError(t, err) + assert.JSONEq(t, `{"context":"$response.body","selector":"$.id","type":"jsonpath"}`, string(jsonBytes)) + + for _, invalid := range []*OutputValue{ + nil, + {}, + NewSelectorOutputValue(nil), + } { + _, err = invalid.MarshalYAML() + require.Error(t, err) + _, err = invalid.MarshalJSON() + require.Error(t, err) + } + + expression.SetSelector(&Selector{Context: "$response.body", Selector: "$.id", Type: "jsonpath"}) + assert.True(t, expression.IsSelector()) + _, ok = expression.GetExpression() + assert.False(t, ok) + + expression.SetExpression("") + assert.True(t, expression.IsExpression()) + value, ok = expression.GetExpression() + assert.True(t, ok) + assert.Empty(t, value) + + expression.SetSelector(nil) + assert.False(t, expression.IsExpression()) + assert.False(t, expression.IsSelector()) +} + +func TestPayloadReplacement_TypedInspectionAndTypeUnion(t *testing.T) { + var nilReplacement *PayloadReplacement + assert.False(t, nilReplacement.IsRuntimeExpression()) + assert.False(t, nilReplacement.IsSelector()) + assert.Nil(t, nilReplacement.GetValueNode()) + _, ok := nilReplacement.GetSelector() + assert.False(t, ok) + + runtimeNode := &yaml.Node{Kind: yaml.ScalarNode, Value: "{$inputs.id}"} + replacement := &PayloadReplacement{ + Target: "/id", + TargetSelectorType: "jsonpointer", + Value: runtimeNode, + } + assert.True(t, replacement.IsRuntimeExpression()) + rendered, err := replacement.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "targetSelectorType: jsonpointer") + + literal := &PayloadReplacement{Value: &yaml.Node{Kind: yaml.ScalarNode, Value: "literal"}} + assert.False(t, literal.IsRuntimeExpression()) + + invalid := &PayloadReplacement{ + TargetSelectorType: "jsonpath", + TargetSelectorExpressionType: &ExpressionType{Type: "jsonpath", Version: "rfc9535"}, + } + _, err = invalid.MarshalYAML() + require.Error(t, err) + + scalarTypeNode := &yaml.Node{Kind: yaml.ScalarNode, Value: "jsonpath"} + lowReplacement := &low.PayloadReplacement{ + TargetSelectorType: lowmodel.NodeReference[*yaml.Node]{ + Value: scalarTypeNode, + ValueNode: scalarTypeNode, + }, + } + highReplacement := NewPayloadReplacement(lowReplacement) + assert.Equal(t, "jsonpath", highReplacement.TargetSelectorType) + + var selectorValue yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("context: $inputs\nselector: $.id\ntype: jsonpath"), &selectorValue)) + highReplacement.Value = selectorValue.Content[0] + assert.True(t, highReplacement.IsSelector()) + highReplacement.Value = &yaml.Node{Kind: yaml.ScalarNode, Value: "mutated"} + assert.False(t, highReplacement.IsSelector()) + _, ok = highReplacement.GetSelector() + assert.False(t, ok) +} + +func TestArazzo11BuildHelpers_Boundaries(t *testing.T) { + assert.Nil(t, buildOutputValueMap(nil)) + assert.Nil(t, selectorFromNode(nil)) + assert.Nil(t, selectorFromNode(&yaml.Node{Kind: yaml.ScalarNode, Value: "no"})) + + var missing yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("context: $inputs"), &missing)) + assert.Nil(t, selectorFromNode(missing.Content[0])) + + var malformed yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("context: $inputs\nselector: $.id\ntype: [jsonpath]"), &malformed)) + assert.Nil(t, selectorFromNode(malformed.Content[0])) + require.NoError(t, yaml.Unmarshal([]byte("context: [bad]\nselector: $.id\ntype: jsonpath"), &malformed)) + assert.Nil(t, selectorFromNode(malformed.Content[0])) + require.NoError(t, yaml.Unmarshal([]byte("context: $inputs\nselector: [bad]\ntype: jsonpath"), &malformed)) + assert.Nil(t, selectorFromNode(malformed.Content[0])) + + empty := orderedmap.New[string, *OutputValue]() + assert.NotNil(t, empty) + + assert.Nil(t, selectorsFromNode(nil)) + var document yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("- plain\n- context: $inputs\n selector: $.id\n type: jsonpath"), &document)) + selectors := selectorsFromNode(&document) + require.Len(t, selectors, 1) + assert.Equal(t, "$.id", selectors[0].Selector) +} + +func TestSelectorHelpersResolveAliasesAndTerminateCycles(t *testing.T) { + var document yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`selector: &selector + context: $inputs + selector: $.id + type: jsonpath +alias: *selector +`), &document)) + root := document.Content[0] + alias := root.Content[3] + selector := selectorFromNode(alias) + require.NotNil(t, selector) + assert.Equal(t, "$.id", selector.Selector) + + cyclic := &yaml.Node{Kind: yaml.MappingNode} + aliasCycle := &yaml.Node{Kind: yaml.AliasNode, Alias: cyclic} + cyclic.Content = []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "selector"}, alias, + {Kind: yaml.ScalarNode, Value: "cycle"}, aliasCycle, + } + selectors := selectorsFromNode(cyclic) + require.Len(t, selectors, 1) + assert.Equal(t, "$.id", selectors[0].Selector) + + selfAlias := &yaml.Node{Kind: yaml.AliasNode} + selfAlias.Alias = selfAlias + assert.Nil(t, selectorFromNode(selfAlias)) + assert.Nil(t, selectorsFromNode(selfAlias)) + assert.Nil(t, selectorFromNode(&yaml.Node{Kind: yaml.AliasNode})) +} + +func TestRequestBodyAndReplacement_DiscoverNestedSelectors(t *testing.T) { + var payload yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`items: + - literal + - selected: + context: $inputs + selector: $.id + type: jsonpath +second: + context: $response.body + selector: /id + type: jsonpointer +`), &payload)) + requestBody := &RequestBody{Payload: payload.Content[0]} + selectors := requestBody.GetSelectors() + require.Len(t, selectors, 2) + assert.Equal(t, "$.id", selectors[0].Selector) + assert.Equal(t, "/id", selectors[1].Selector) + var nilRequestBody *RequestBody + assert.Nil(t, nilRequestBody.GetSelectors()) + + replacement := &PayloadReplacement{Value: payload.Content[0]} + require.Len(t, replacement.GetSelectors(), 2) + var nilReplacement *PayloadReplacement + assert.Nil(t, nilReplacement.GetSelectors()) +} + +func TestParameter_TypedValueInspection(t *testing.T) { + var nilParameter *Parameter + assert.False(t, nilParameter.IsRuntimeExpression()) + assert.False(t, nilParameter.IsSelector()) + assert.Nil(t, nilParameter.GetValueNode()) + _, ok := nilParameter.GetSelector() + assert.False(t, ok) + + runtime := &Parameter{Value: &yaml.Node{Kind: yaml.ScalarNode, Value: "$inputs.id"}} + assert.True(t, runtime.IsRuntimeExpression()) + assert.Same(t, runtime.Value, runtime.GetValueNode()) + + literal := &Parameter{Value: &yaml.Node{Kind: yaml.ScalarNode, Value: "literal"}} + assert.False(t, literal.IsRuntimeExpression()) + + var selectorNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("context: $inputs\nselector: $.id\ntype: jsonpath"), &selectorNode)) + lowParameter := &low.Parameter{ + Value: lowmodel.NodeReference[*yaml.Node]{ + Value: selectorNode.Content[0], + ValueNode: selectorNode.Content[0], + }, + } + parameter := NewParameter(lowParameter) + assert.True(t, parameter.IsSelector()) + selector, ok := parameter.GetSelector() + require.True(t, ok) + assert.Equal(t, "$.id", selector.Selector) + parameter.Value = &yaml.Node{Kind: yaml.ScalarNode, Value: "mutated"} + assert.False(t, parameter.IsSelector()) + _, ok = parameter.GetSelector() + assert.False(t, ok) +} diff --git a/datamodel/high/arazzo/origin.go b/datamodel/high/arazzo/origin.go new file mode 100644 index 000000000..01528881c --- /dev/null +++ b/datamodel/high/arazzo/origin.go @@ -0,0 +1,92 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "fmt" + "net/url" + "strings" +) + +// DocumentOrigin keeps authored identity separate from retrieval and effective base metadata. +// The values are runtime metadata and are never rendered into the document. +type DocumentOrigin struct { + AuthoredSelf string + ResolvedIdentity string + RetrievalURI string + ApplicationBaseURI string + EffectiveBaseURI string +} + +// ResolveDocumentOrigin applies Arazzo base-URI precedence without performing retrieval. +func ResolveDocumentOrigin(authoredSelf, retrievalURI, applicationBaseURI string) (*DocumentOrigin, error) { + origin := &DocumentOrigin{ + AuthoredSelf: authoredSelf, + RetrievalURI: retrievalURI, + ApplicationBaseURI: applicationBaseURI, + } + retrieval, err := parseOptionalURI("retrieval URI", retrievalURI) + if err != nil { + return nil, err + } + applicationBase, err := parseOptionalURI("application base URI", applicationBaseURI) + if err != nil { + return nil, err + } + + if authoredSelf != "" { + self, parseErr := url.Parse(authoredSelf) + // $self is authored document content. Preserve malformed values for the + // validator instead of turning semantic document errors into build errors. + if parseErr == nil { + switch { + case self.IsAbs(): + origin.ResolvedIdentity = self.String() + origin.EffectiveBaseURI = self.String() + case retrieval != nil: + resolved := retrieval.ResolveReference(self) + origin.ResolvedIdentity = resolved.String() + origin.EffectiveBaseURI = resolved.String() + case applicationBase != nil: + resolved := applicationBase.ResolveReference(self) + origin.ResolvedIdentity = resolved.String() + origin.EffectiveBaseURI = resolved.String() + default: + origin.ResolvedIdentity = self.String() + origin.EffectiveBaseURI = self.String() + } + return origin, nil + } + } + + switch { + case retrieval != nil: + origin.ResolvedIdentity = retrieval.String() + origin.EffectiveBaseURI = retrieval.String() + case applicationBase != nil: + origin.ResolvedIdentity = applicationBase.String() + origin.EffectiveBaseURI = applicationBase.String() + } + return origin, nil +} + +func parseOptionalURI(name, value string) (*url.URL, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + parsed, err := url.Parse(value) + if err != nil { + return nil, fmt.Errorf("invalid %s %q: %w", name, value, err) + } + return parsed, nil +} + +// GetDocumentOrigin returns a copy of the document's immutable construction metadata. +func (a *Arazzo) GetDocumentOrigin() *DocumentOrigin { + if a == nil || a.origin == nil { + return nil + } + copy := *a.origin + return © +} diff --git a/datamodel/high/arazzo/output_value.go b/datamodel/high/arazzo/output_value.go new file mode 100644 index 000000000..cb2fcd3ba --- /dev/null +++ b/datamodel/high/arazzo/output_value.go @@ -0,0 +1,147 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "encoding/json" + "fmt" + + low "github.com/pb33f/libopenapi/datamodel/low/arazzo" + "go.yaml.in/yaml/v4" +) + +type outputValueVariant uint8 + +const ( + outputValueUnset outputValueVariant = iota + outputValueExpression + outputValueSelector +) + +// OutputValue represents a runtime-expression scalar or Selector Object. +// Exactly one variant must be active when the value is rendered. +type OutputValue struct { + expression string + selector *Selector + low *low.OutputValue + variant outputValueVariant +} + +// NewOutputValue creates a high-level output union from a low-level value. +func NewOutputValue(value *low.OutputValue) *OutputValue { + o := &OutputValue{low: value} + if value.IsExpression() { + o.expression = value.Expression.Value + o.variant = outputValueExpression + } + if value.IsSelector() { + o.selector = NewSelector(value.Selector.Value) + o.variant = outputValueSelector + } + return o +} + +// NewExpressionOutputValue creates a scalar runtime-expression output. +func NewExpressionOutputValue(expression string) *OutputValue { + return &OutputValue{expression: expression, variant: outputValueExpression} +} + +// NewSelectorOutputValue creates a Selector Object output. +func NewSelectorOutputValue(selector *Selector) *OutputValue { + if selector == nil { + return &OutputValue{} + } + return &OutputValue{selector: selector, variant: outputValueSelector} +} + +// SetExpression switches the output to the runtime-expression variant. +func (o *OutputValue) SetExpression(expression string) { + if o == nil { + return + } + o.expression = expression + o.selector = nil + o.variant = outputValueExpression +} + +// SetSelector switches the output to the Selector Object variant. A nil selector +// clears the output so it cannot accidentally render as a valid union member. +func (o *OutputValue) SetSelector(selector *Selector) { + if o == nil { + return + } + o.expression = "" + o.selector = selector + if selector == nil { + o.variant = outputValueUnset + return + } + o.variant = outputValueSelector +} + +// IsExpression reports whether exactly the expression variant is active. +func (o *OutputValue) IsExpression() bool { + return o != nil && + o.selector == nil && + o.variant == outputValueExpression +} + +// IsSelector reports whether exactly the selector variant is active. +func (o *OutputValue) IsSelector() bool { + return o != nil && + o.selector != nil && + o.variant == outputValueSelector +} + +// GetExpression returns the expression and whether it is the active variant. +func (o *OutputValue) GetExpression() (string, bool) { + if !o.IsExpression() { + return "", false + } + return o.expression, true +} + +// GetSelector returns the Selector and whether it is the active variant. +func (o *OutputValue) GetSelector() (*Selector, bool) { + if !o.IsSelector() { + return nil, false + } + return o.selector, true +} + +// GoLow returns the low-level output value used to create this model. +func (o *OutputValue) GoLow() *low.OutputValue { + return o.low +} + +// GoLowUntyped returns the low-level output value without a concrete type. +func (o *OutputValue) GoLowUntyped() any { + return o.low +} + +// Render returns the active output variant as YAML. +func (o *OutputValue) Render() ([]byte, error) { + return yaml.Marshal(o) +} + +// MarshalYAML renders the active union variant. +func (o *OutputValue) MarshalYAML() (any, error) { + switch { + case o.IsExpression(): + return o.expression, nil + case o.IsSelector(): + return o.selector, nil + default: + return nil, fmt.Errorf("output value must contain exactly one expression or selector variant") + } +} + +// MarshalJSON renders the active output union variant. +func (o *OutputValue) MarshalJSON() ([]byte, error) { + value, err := o.MarshalYAML() + if err != nil { + return nil, err + } + return json.Marshal(value) +} diff --git a/datamodel/high/arazzo/output_value_example_test.go b/datamodel/high/arazzo/output_value_example_test.go new file mode 100644 index 000000000..fa70404e1 --- /dev/null +++ b/datamodel/high/arazzo/output_value_example_test.go @@ -0,0 +1,32 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo_test + +import ( + "fmt" + + arazzo "github.com/pb33f/libopenapi/datamodel/high/arazzo" + "github.com/pb33f/libopenapi/orderedmap" +) + +func ExampleOutputValue() { + outputs := orderedmap.New[string, *arazzo.OutputValue]() + outputs.Set("createdPetId", arazzo.NewExpressionOutputValue("$response.body#/id")) + outputs.Set("email", arazzo.NewSelectorOutputValue(&arazzo.Selector{ + Context: "$response.body", + Selector: "$.profile.email", + Type: "jsonpath", + })) + + expressionOutput, _ := outputs.Get("createdPetId") + expression, _ := expressionOutput.GetExpression() + selectorOutput, _ := outputs.Get("email") + selector, _ := selectorOutput.GetSelector() + + fmt.Println(expression) + fmt.Println(selector.Selector) + // Output: + // $response.body#/id + // $.profile.email +} diff --git a/datamodel/high/arazzo/parameter.go b/datamodel/high/arazzo/parameter.go index a398a5c99..c768ff937 100644 --- a/datamodel/high/arazzo/parameter.go +++ b/datamodel/high/arazzo/parameter.go @@ -4,6 +4,8 @@ package arazzo import ( + "strings" + "github.com/pb33f/libopenapi/datamodel/high" low "github.com/pb33f/libopenapi/datamodel/low/arazzo" "github.com/pb33f/libopenapi/orderedmap" @@ -12,7 +14,7 @@ import ( // Parameter represents a high-level Arazzo Parameter Object. // A parameter can be a full parameter definition or a Reusable Object with a $components reference. -// https://spec.openapis.org/arazzo/v1.0.1#parameter-object +// https://spec.openapis.org/arazzo/v1.1.0#parameter-object type Parameter struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` In string `json:"in,omitempty" yaml:"in,omitempty"` @@ -47,6 +49,36 @@ func NewParameter(param *low.Parameter) *Parameter { return p } +// IsRuntimeExpression reports whether the parameter value is a runtime-expression scalar. +func (p *Parameter) IsRuntimeExpression() bool { + if p == nil || p.Value == nil || p.Value.Kind != yaml.ScalarNode { + return false + } + return strings.HasPrefix(p.Value.Value, "$") || strings.Contains(p.Value.Value, "{$") +} + +// IsSelector reports whether the parameter value is a Selector Object. +func (p *Parameter) IsSelector() bool { + return p != nil && selectorFromNode(p.Value) != nil +} + +// GetSelector returns the typed Selector Object when active. +func (p *Parameter) GetSelector() (*Selector, bool) { + if p == nil { + return nil, false + } + selector := selectorFromNode(p.Value) + return selector, selector != nil +} + +// GetValueNode returns the original parameter value node. +func (p *Parameter) GetValueNode() *yaml.Node { + if p == nil { + return nil + } + return p.Value +} + // GoLow returns the low-level Parameter instance used to create the high-level one. func (p *Parameter) GoLow() *low.Parameter { return p.low diff --git a/datamodel/high/arazzo/payload_replacement.go b/datamodel/high/arazzo/payload_replacement.go index a168fc845..5950200f9 100644 --- a/datamodel/high/arazzo/payload_replacement.go +++ b/datamodel/high/arazzo/payload_replacement.go @@ -4,19 +4,26 @@ package arazzo import ( + "context" + "fmt" + "strings" + "github.com/pb33f/libopenapi/datamodel/high" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" low "github.com/pb33f/libopenapi/datamodel/low/arazzo" "github.com/pb33f/libopenapi/orderedmap" "go.yaml.in/yaml/v4" ) // PayloadReplacement represents a high-level Arazzo Payload Replacement Object. -// https://spec.openapis.org/arazzo/v1.0.1#payload-replacement-object +// https://spec.openapis.org/arazzo/v1.1.0#payload-replacement-object type PayloadReplacement struct { - Target string `json:"target,omitempty" yaml:"target,omitempty"` - Value *yaml.Node `json:"value,omitempty" yaml:"value,omitempty"` - Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` - low *low.PayloadReplacement + Target string `json:"target,omitempty" yaml:"target,omitempty"` + TargetSelectorType string `json:"-" yaml:"-"` + TargetSelectorExpressionType *ExpressionType `json:"-" yaml:"-"` + Value *yaml.Node `json:"value,omitempty" yaml:"value,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + low *low.PayloadReplacement } // NewPayloadReplacement creates a new high-level PayloadReplacement instance from a low-level one. @@ -26,6 +33,22 @@ func NewPayloadReplacement(pr *low.PayloadReplacement) *PayloadReplacement { if !pr.Target.IsEmpty() { p.Target = pr.Target.Value } + if !pr.TargetSelectorType.IsEmpty() && pr.TargetSelectorType.Value != nil { + node := dereferenceAliasNode(pr.TargetSelectorType.Value) + if node != nil { + switch node.Kind { + case yaml.ScalarNode: + p.TargetSelectorType = node.Value + case yaml.MappingNode: + expressionType := new(low.ExpressionType) + if err := lowmodel.BuildModel(node, expressionType); err == nil { + if err = expressionType.Build(context.Background(), nil, node, nil); err == nil { + p.TargetSelectorExpressionType = NewExpressionType(expressionType) + } + } + } + } + } if !pr.Value.IsEmpty() { p.Value = pr.Value.Value } @@ -33,6 +56,44 @@ func NewPayloadReplacement(pr *low.PayloadReplacement) *PayloadReplacement { return p } +// IsRuntimeExpression reports whether the replacement value is a runtime-expression scalar. +func (p *PayloadReplacement) IsRuntimeExpression() bool { + if p == nil || p.Value == nil || p.Value.Kind != yaml.ScalarNode { + return false + } + return strings.HasPrefix(p.Value.Value, "$") || strings.Contains(p.Value.Value, "{$") +} + +// IsSelector reports whether the replacement value is a Selector Object. +func (p *PayloadReplacement) IsSelector() bool { + return p != nil && selectorFromNode(p.Value) != nil +} + +// GetSelector returns the typed Selector Object when the value has selector shape. +func (p *PayloadReplacement) GetSelector() (*Selector, bool) { + if p == nil { + return nil, false + } + selector := selectorFromNode(p.Value) + return selector, selector != nil +} + +// GetSelectors returns every Selector Object nested in the replacement value. +func (p *PayloadReplacement) GetSelectors() []*Selector { + if p == nil { + return nil + } + return selectorsFromNode(p.Value) +} + +// GetValueNode returns the original replacement value node. +func (p *PayloadReplacement) GetValueNode() *yaml.Node { + if p == nil { + return nil + } + return p.Value +} + // GoLow returns the low-level PayloadReplacement instance used to create the high-level one. func (p *PayloadReplacement) GoLow() *low.PayloadReplacement { return p.low @@ -54,6 +115,14 @@ func (p *PayloadReplacement) MarshalYAML() (any, error) { if p.Target != "" { m.Set(low.TargetLabel, p.Target) } + if p.TargetSelectorExpressionType != nil { + if p.TargetSelectorType != "" { + return nil, fmt.Errorf("payload replacement cannot contain both target selector type variants") + } + m.Set(low.TargetSelectorTypeLabel, p.TargetSelectorExpressionType) + } else if p.TargetSelectorType != "" { + m.Set(low.TargetSelectorTypeLabel, p.TargetSelectorType) + } if p.Value != nil { m.Set(low.ValueLabel, p.Value) } diff --git a/datamodel/high/arazzo/request_body.go b/datamodel/high/arazzo/request_body.go index 712069cee..849216d5b 100644 --- a/datamodel/high/arazzo/request_body.go +++ b/datamodel/high/arazzo/request_body.go @@ -11,7 +11,7 @@ import ( ) // RequestBody represents a high-level Arazzo Request Body Object. -// https://spec.openapis.org/arazzo/v1.0.1#request-body-object +// https://spec.openapis.org/arazzo/v1.1.0#request-body-object type RequestBody struct { ContentType string `json:"contentType,omitempty" yaml:"contentType,omitempty"` Payload *yaml.Node `json:"payload,omitempty" yaml:"payload,omitempty"` @@ -20,6 +20,15 @@ type RequestBody struct { low *low.RequestBody } +// GetSelectors returns every typed Selector Object nested in the request payload. +// Callers do not need to traverse untyped YAML mappings to discover selectors. +func (r *RequestBody) GetSelectors() []*Selector { + if r == nil { + return nil + } + return selectorsFromNode(r.Payload) +} + // NewRequestBody creates a new high-level RequestBody instance from a low-level one. func NewRequestBody(rb *low.RequestBody) *RequestBody { r := new(RequestBody) diff --git a/datamodel/high/arazzo/selector.go b/datamodel/high/arazzo/selector.go new file mode 100644 index 000000000..ae455fcb8 --- /dev/null +++ b/datamodel/high/arazzo/selector.go @@ -0,0 +1,109 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pb33f/libopenapi/datamodel/high" + lowmodel "github.com/pb33f/libopenapi/datamodel/low" + low "github.com/pb33f/libopenapi/datamodel/low/arazzo" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +// Selector represents a high-level Arazzo Selector Object. +// https://spec.openapis.org/arazzo/v1.1.0#selector-object +type Selector struct { + Context string `json:"context,omitempty" yaml:"context,omitempty"` + Selector string `json:"selector,omitempty" yaml:"selector,omitempty"` + Type string `json:"-" yaml:"-"` + ExpressionType *ExpressionType `json:"-" yaml:"-"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + low *low.Selector +} + +// NewSelector creates a high-level Selector from its low-level model. +func NewSelector(selector *low.Selector) *Selector { + s := &Selector{low: selector} + if !selector.Context.IsEmpty() { + s.Context = selector.Context.Value + } + if !selector.Selector.IsEmpty() { + s.Selector = selector.Selector.Value + } + if !selector.Type.IsEmpty() && selector.Type.Value != nil { + node := dereferenceAliasNode(selector.Type.Value) + if node != nil { + switch node.Kind { + case yaml.ScalarNode: + s.Type = node.Value + case yaml.MappingNode: + expressionType := new(low.ExpressionType) + if err := lowmodel.BuildModel(node, expressionType); err == nil { + if err = expressionType.Build(context.Background(), nil, node, nil); err == nil { + s.ExpressionType = NewExpressionType(expressionType) + } + } + } + } + } + s.Extensions = high.ExtractExtensions(selector.Extensions) + return s +} + +// GetEffectiveType returns the scalar type or the type declared by the Expression Type Object. +func (s *Selector) GetEffectiveType() string { + if s.ExpressionType != nil { + return s.ExpressionType.Type + } + return s.Type +} + +// GoLow returns the low-level Selector used to create this model. +func (s *Selector) GoLow() *low.Selector { + return s.low +} + +// GoLowUntyped returns the low-level Selector without a concrete type. +func (s *Selector) GoLowUntyped() any { + return s.low +} + +// Render returns a YAML representation of the Selector. +func (s *Selector) Render() ([]byte, error) { + return yaml.Marshal(s) +} + +// MarshalYAML renders the Selector and rejects competing type variants. +func (s *Selector) MarshalYAML() (any, error) { + if s.Type != "" && s.ExpressionType != nil { + return nil, fmt.Errorf("selector cannot contain both scalar and object type variants") + } + m := orderedmap.New[string, any]() + if s.Context != "" { + m.Set(low.ContextLabel, s.Context) + } + if s.Selector != "" { + m.Set(low.SelectorLabel, s.Selector) + } + if s.ExpressionType != nil { + m.Set(low.TypeLabel, s.ExpressionType) + } else if s.Type != "" { + m.Set(low.TypeLabel, s.Type) + } + marshalExtensions(m, s.Extensions) + return m, nil +} + +// MarshalJSON renders the same scalar-or-object type union used by YAML. +func (s *Selector) MarshalJSON() ([]byte, error) { + value, err := s.MarshalYAML() + if err != nil { + return nil, err + } + return json.Marshal(value) +} diff --git a/datamodel/high/arazzo/step.go b/datamodel/high/arazzo/step.go index a6745cbf1..b86875dfe 100644 --- a/datamodel/high/arazzo/step.go +++ b/datamodel/high/arazzo/step.go @@ -5,27 +5,31 @@ package arazzo import ( "github.com/pb33f/libopenapi/datamodel/high" - lowmodel "github.com/pb33f/libopenapi/datamodel/low" low "github.com/pb33f/libopenapi/datamodel/low/arazzo" "github.com/pb33f/libopenapi/orderedmap" "go.yaml.in/yaml/v4" ) // Step represents a high-level Arazzo Step Object. -// https://spec.openapis.org/arazzo/v1.0.1#step-object +// https://spec.openapis.org/arazzo/v1.1.0#step-object type Step struct { - StepId string `json:"stepId,omitempty" yaml:"stepId,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - OperationId string `json:"operationId,omitempty" yaml:"operationId,omitempty"` - OperationPath string `json:"operationPath,omitempty" yaml:"operationPath,omitempty"` - WorkflowId string `json:"workflowId,omitempty" yaml:"workflowId,omitempty"` - Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` - RequestBody *RequestBody `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` - SuccessCriteria []*Criterion `json:"successCriteria,omitempty" yaml:"successCriteria,omitempty"` - OnSuccess []*SuccessAction `json:"onSuccess,omitempty" yaml:"onSuccess,omitempty"` - OnFailure []*FailureAction `json:"onFailure,omitempty" yaml:"onFailure,omitempty"` - Outputs *orderedmap.Map[string, string] `json:"outputs,omitempty" yaml:"outputs,omitempty"` - Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + StepId string `json:"stepId,omitempty" yaml:"stepId,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + OperationId string `json:"operationId,omitempty" yaml:"operationId,omitempty"` + OperationPath string `json:"operationPath,omitempty" yaml:"operationPath,omitempty"` + ChannelPath string `json:"channelPath,omitempty" yaml:"channelPath,omitempty"` + Action string `json:"action,omitempty" yaml:"action,omitempty"` + WorkflowId string `json:"workflowId,omitempty" yaml:"workflowId,omitempty"` + CorrelationId string `json:"correlationId,omitempty" yaml:"correlationId,omitempty"` + Timeout *int64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` + DependsOn []string `json:"dependsOn,omitempty" yaml:"dependsOn,omitempty"` + Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` + RequestBody *RequestBody `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` + SuccessCriteria []*Criterion `json:"successCriteria,omitempty" yaml:"successCriteria,omitempty"` + OnSuccess []*SuccessAction `json:"onSuccess,omitempty" yaml:"onSuccess,omitempty"` + OnFailure []*FailureAction `json:"onFailure,omitempty" yaml:"onFailure,omitempty"` + Outputs *orderedmap.Map[string, *OutputValue] `json:"outputs,omitempty" yaml:"outputs,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` low *low.Step } @@ -45,9 +49,25 @@ func NewStep(step *low.Step) *Step { if !step.OperationPath.IsEmpty() { s.OperationPath = step.OperationPath.Value } + if !step.ChannelPath.IsEmpty() { + s.ChannelPath = step.ChannelPath.Value + } + if !step.Action.IsEmpty() { + s.Action = step.Action.Value + } if !step.WorkflowId.IsEmpty() { s.WorkflowId = step.WorkflowId.Value } + if !step.CorrelationId.IsEmpty() { + s.CorrelationId = step.CorrelationId.Value + } + if !step.Timeout.IsEmpty() { + timeout := step.Timeout.Value + s.Timeout = &timeout + } + if !step.DependsOn.IsEmpty() { + s.DependsOn = buildValueSlice(step.DependsOn.Value) + } if !step.Parameters.IsEmpty() { s.Parameters = buildSlice(step.Parameters.Value, NewParameter) } @@ -64,7 +84,7 @@ func NewStep(step *low.Step) *Step { s.OnFailure = buildSlice(step.OnFailure.Value, NewFailureAction) } if !step.Outputs.IsEmpty() { - s.Outputs = lowmodel.FromReferenceMap[string, string](step.Outputs.Value) + s.Outputs = buildOutputValueMap(step.Outputs.Value) } s.Extensions = high.ExtractExtensions(step.Extensions) return s @@ -100,9 +120,24 @@ func (s *Step) MarshalYAML() (any, error) { if s.OperationPath != "" { m.Set(low.OperationPathLabel, s.OperationPath) } + if s.ChannelPath != "" { + m.Set(low.ChannelPathLabel, s.ChannelPath) + } + if s.Action != "" { + m.Set(low.ActionLabel, s.Action) + } if s.WorkflowId != "" { m.Set(low.WorkflowIdLabel, s.WorkflowId) } + if s.CorrelationId != "" { + m.Set(low.CorrelationIdLabel, s.CorrelationId) + } + if s.Timeout != nil { + m.Set(low.TimeoutLabel, *s.Timeout) + } + if len(s.DependsOn) > 0 { + m.Set(low.DependsOnLabel, s.DependsOn) + } if len(s.Parameters) > 0 { m.Set(low.ParametersLabel, s.Parameters) } diff --git a/datamodel/high/arazzo/success_action.go b/datamodel/high/arazzo/success_action.go index 9c50798d2..3ddc2c8b9 100644 --- a/datamodel/high/arazzo/success_action.go +++ b/datamodel/high/arazzo/success_action.go @@ -12,13 +12,14 @@ import ( // SuccessAction represents a high-level Arazzo Success Action Object. // A success action can be a full definition or a Reusable Object with a $components reference. -// https://spec.openapis.org/arazzo/v1.0.1#success-action-object +// https://spec.openapis.org/arazzo/v1.1.0#success-action-object type SuccessAction struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` Type string `json:"type,omitempty" yaml:"type,omitempty"` WorkflowId string `json:"workflowId,omitempty" yaml:"workflowId,omitempty"` StepId string `json:"stepId,omitempty" yaml:"stepId,omitempty"` Criteria []*Criterion `json:"criteria,omitempty" yaml:"criteria,omitempty"` + Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` Reference string `json:"reference,omitempty" yaml:"reference,omitempty"` Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` low *low.SuccessAction @@ -51,6 +52,9 @@ func NewSuccessAction(sa *low.SuccessAction) *SuccessAction { if !sa.Criteria.IsEmpty() { s.Criteria = buildSlice(sa.Criteria.Value, NewCriterion) } + if !sa.Parameters.IsEmpty() { + s.Parameters = buildSlice(sa.Parameters.Value, NewParameter) + } s.Extensions = high.ExtractExtensions(sa.Extensions) return s } @@ -92,6 +96,9 @@ func (s *SuccessAction) MarshalYAML() (any, error) { if len(s.Criteria) > 0 { m.Set(low.CriteriaLabel, s.Criteria) } + if len(s.Parameters) > 0 { + m.Set(low.ParametersLabel, s.Parameters) + } marshalExtensions(m, s.Extensions) return m, nil } diff --git a/datamodel/high/arazzo/version.go b/datamodel/high/arazzo/version.go new file mode 100644 index 000000000..d23badd04 --- /dev/null +++ b/datamodel/high/arazzo/version.go @@ -0,0 +1,80 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +// FeatureFamily identifies the patch-insensitive Arazzo feature set. +type FeatureFamily string + +const ( + // FeatureFamily10 identifies the Arazzo 1.0 feature set. + FeatureFamily10 FeatureFamily = "1.0" + // FeatureFamily11 identifies the Arazzo 1.1 feature set. + FeatureFamily11 FeatureFamily = "1.1" +) + +// ErrUnsupportedVersion indicates a malformed or unsupported Arazzo specification version. +var ErrUnsupportedVersion = errors.New("unsupported Arazzo specification version") + +// SpecificationVersion is the parsed Arazzo specification version, distinct from info.version. +type SpecificationVersion struct { + Authored string + Major int + Minor int + Patch int + Family FeatureFamily +} + +// ParseSpecificationVersion parses supported 1.0.x and 1.1.x specification versions. +func ParseSpecificationVersion(authored string) (*SpecificationVersion, error) { + numericVersion, suffix, hasSuffix := strings.Cut(authored, "-") + if hasSuffix && suffix == "" { + return nil, fmt.Errorf("%w %q: empty prerelease suffix", ErrUnsupportedVersion, authored) + } + parts := strings.Split(numericVersion, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("%w %q: expected major.minor.patch", ErrUnsupportedVersion, authored) + } + values := [3]int{} + for i, part := range parts { + if part == "" { + return nil, fmt.Errorf("%w %q: expected major.minor.patch", ErrUnsupportedVersion, authored) + } + value, err := strconv.Atoi(part) + if err != nil || value < 0 { + return nil, fmt.Errorf("%w %q: expected non-negative numeric components", ErrUnsupportedVersion, authored) + } + values[i] = value + } + var family FeatureFamily + switch { + case values[0] == 1 && values[1] == 0: + family = FeatureFamily10 + case values[0] == 1 && values[1] == 1: + family = FeatureFamily11 + default: + return nil, fmt.Errorf("%w %q", ErrUnsupportedVersion, authored) + } + return &SpecificationVersion{ + Authored: authored, + Major: values[0], + Minor: values[1], + Patch: values[2], + Family: family, + }, nil +} + +// GetSpecificationVersion returns patch-insensitive feature metadata for the authored arazzo field. +func (a *Arazzo) GetSpecificationVersion() (*SpecificationVersion, error) { + if a == nil { + return nil, fmt.Errorf("%w: nil document", ErrUnsupportedVersion) + } + return ParseSpecificationVersion(a.Arazzo) +} diff --git a/datamodel/high/arazzo/version_origin_test.go b/datamodel/high/arazzo/version_origin_test.go new file mode 100644 index 000000000..737cd6d38 --- /dev/null +++ b/datamodel/high/arazzo/version_origin_test.go @@ -0,0 +1,157 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "testing" + + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +func TestParseSpecificationVersion_SupportedFamilies(t *testing.T) { + tests := []struct { + authored string + family FeatureFamily + patch int + }{ + {authored: "1.0.99", family: FeatureFamily10, patch: 99}, + {authored: "1.1.0", family: FeatureFamily11, patch: 0}, + {authored: "1.1.2-rc.1", family: FeatureFamily11, patch: 2}, + } + for _, test := range tests { + version, err := ParseSpecificationVersion(test.authored) + require.NoError(t, err) + assert.Equal(t, test.authored, version.Authored) + assert.Equal(t, 1, version.Major) + assert.Equal(t, test.family, version.Family) + assert.Equal(t, test.patch, version.Patch) + } +} + +func TestParseSpecificationVersion_Unsupported(t *testing.T) { + for _, authored := range []string{ + "", + "1.1", + "1..0", + "1.one.0", + "1.-1.0", + "1.1.0-", + "2.0.0", + } { + version, err := ParseSpecificationVersion(authored) + assert.Nil(t, version) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedVersion) + } +} + +func TestArazzo_GetSpecificationVersion(t *testing.T) { + document := &Arazzo{Arazzo: "1.1.7"} + version, err := document.GetSpecificationVersion() + require.NoError(t, err) + assert.Equal(t, FeatureFamily11, version.Family) + assert.Equal(t, 7, version.Patch) + + var nilDocument *Arazzo + version, err = nilDocument.GetSpecificationVersion() + assert.Nil(t, version) + assert.ErrorIs(t, err, ErrUnsupportedVersion) +} + +func TestResolveDocumentOrigin_Precedence(t *testing.T) { + absolute, err := ResolveDocumentOrigin( + "https://identity.example/workflows/root.yaml", + "file:///tmp/root.yaml", + "https://application.example/default/", + ) + require.NoError(t, err) + assert.Equal(t, "https://identity.example/workflows/root.yaml", absolute.ResolvedIdentity) + assert.Equal(t, absolute.ResolvedIdentity, absolute.EffectiveBaseURI) + assert.Equal(t, "file:///tmp/root.yaml", absolute.RetrievalURI) + + relativeRetrieval, err := ResolveDocumentOrigin( + "../portable/root.yaml", + "https://retrieval.example/workflows/source.yaml", + "https://application.example/default/", + ) + require.NoError(t, err) + assert.Equal(t, "https://retrieval.example/portable/root.yaml", relativeRetrieval.ResolvedIdentity) + + relativeApplication, err := ResolveDocumentOrigin( + "portable/root.yaml", + "", + "https://application.example/default/", + ) + require.NoError(t, err) + assert.Equal(t, "https://application.example/default/portable/root.yaml", relativeApplication.ResolvedIdentity) + + unresolvedRelative, err := ResolveDocumentOrigin("portable/root.yaml", "", "") + require.NoError(t, err) + assert.Equal(t, "portable/root.yaml", unresolvedRelative.ResolvedIdentity) + + retrievalOnly, err := ResolveDocumentOrigin("", "file:///tmp/root.yaml", "https://ignored.example/") + require.NoError(t, err) + assert.Equal(t, "file:///tmp/root.yaml", retrievalOnly.ResolvedIdentity) + + applicationOnly, err := ResolveDocumentOrigin("", "", "https://application.example/root.yaml") + require.NoError(t, err) + assert.Equal(t, "https://application.example/root.yaml", applicationOnly.ResolvedIdentity) + + empty, err := ResolveDocumentOrigin("", "", "") + require.NoError(t, err) + assert.Empty(t, empty.ResolvedIdentity) + assert.Empty(t, empty.EffectiveBaseURI) +} + +func TestResolveDocumentOrigin_InvalidURIs(t *testing.T) { + for _, test := range []struct { + retrieval string + application string + }{ + {retrieval: "https://example.com/%zz"}, + {application: "https://example.com/%zz"}, + } { + origin, err := ResolveDocumentOrigin("", test.retrieval, test.application) + assert.Nil(t, origin) + require.Error(t, err) + } +} + +func TestResolveDocumentOrigin_MalformedAuthoredSelfIsPreserved(t *testing.T) { + origin, err := ResolveDocumentOrigin( + "https://identity.example/%zz", + "https://retrieval.example/workflows/root.yaml", + "https://application.example/default/", + ) + require.NoError(t, err) + require.NotNil(t, origin) + assert.Equal(t, "https://identity.example/%zz", origin.AuthoredSelf) + assert.Equal(t, "https://retrieval.example/workflows/root.yaml", origin.ResolvedIdentity) + assert.Equal(t, "https://retrieval.example/workflows/root.yaml", origin.EffectiveBaseURI) + + origin, err = ResolveDocumentOrigin("https://identity.example/%zz", "", "") + require.NoError(t, err) + require.NotNil(t, origin) + assert.Equal(t, "https://identity.example/%zz", origin.AuthoredSelf) + assert.Empty(t, origin.ResolvedIdentity) + assert.Empty(t, origin.EffectiveBaseURI) +} + +func TestArazzo_DocumentOriginIsCopied(t *testing.T) { + lowDocument := buildHighArazzo(t, arazzo11HighYAML).GoLow() + origin := &DocumentOrigin{ResolvedIdentity: "https://example.com/original"} + document := NewArazzoWithOrigin(lowDocument, origin) + origin.ResolvedIdentity = "changed" + + first := document.GetDocumentOrigin() + require.NotNil(t, first) + assert.Equal(t, "https://example.com/original", first.ResolvedIdentity) + first.ResolvedIdentity = "mutated-copy" + assert.Equal(t, "https://example.com/original", document.GetDocumentOrigin().ResolvedIdentity) + + assert.Nil(t, NewArazzo(lowDocument).GetDocumentOrigin()) + var nilDocument *Arazzo + assert.Nil(t, nilDocument.GetDocumentOrigin()) +} diff --git a/datamodel/high/arazzo/workflow.go b/datamodel/high/arazzo/workflow.go index 277774e03..3fa361964 100644 --- a/datamodel/high/arazzo/workflow.go +++ b/datamodel/high/arazzo/workflow.go @@ -5,26 +5,25 @@ package arazzo import ( "github.com/pb33f/libopenapi/datamodel/high" - lowmodel "github.com/pb33f/libopenapi/datamodel/low" low "github.com/pb33f/libopenapi/datamodel/low/arazzo" "github.com/pb33f/libopenapi/orderedmap" "go.yaml.in/yaml/v4" ) // Workflow represents a high-level Arazzo Workflow Object. -// https://spec.openapis.org/arazzo/v1.0.1#workflow-object +// https://spec.openapis.org/arazzo/v1.1.0#workflow-object type Workflow struct { - WorkflowId string `json:"workflowId,omitempty" yaml:"workflowId,omitempty"` - Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Inputs *yaml.Node `json:"inputs,omitempty" yaml:"inputs,omitempty"` - DependsOn []string `json:"dependsOn,omitempty" yaml:"dependsOn,omitempty"` - Steps []*Step `json:"steps,omitempty" yaml:"steps,omitempty"` - SuccessActions []*SuccessAction `json:"successActions,omitempty" yaml:"successActions,omitempty"` - FailureActions []*FailureAction `json:"failureActions,omitempty" yaml:"failureActions,omitempty"` - Outputs *orderedmap.Map[string, string] `json:"outputs,omitempty" yaml:"outputs,omitempty"` - Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` - Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + WorkflowId string `json:"workflowId,omitempty" yaml:"workflowId,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Inputs *yaml.Node `json:"inputs,omitempty" yaml:"inputs,omitempty"` + DependsOn []string `json:"dependsOn,omitempty" yaml:"dependsOn,omitempty"` + Steps []*Step `json:"steps,omitempty" yaml:"steps,omitempty"` + SuccessActions []*SuccessAction `json:"successActions,omitempty" yaml:"successActions,omitempty"` + FailureActions []*FailureAction `json:"failureActions,omitempty" yaml:"failureActions,omitempty"` + Outputs *orderedmap.Map[string, *OutputValue] `json:"outputs,omitempty" yaml:"outputs,omitempty"` + Parameters []*Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` low *low.Workflow } @@ -57,7 +56,7 @@ func NewWorkflow(wf *low.Workflow) *Workflow { w.FailureActions = buildSlice(wf.FailureActions.Value, NewFailureAction) } if !wf.Outputs.IsEmpty() { - w.Outputs = lowmodel.FromReferenceMap[string, string](wf.Outputs.Value) + w.Outputs = buildOutputValueMap(wf.Outputs.Value) } if !wf.Parameters.IsEmpty() { w.Parameters = buildSlice(wf.Parameters.Value, NewParameter) diff --git a/datamodel/high/base/example_inline_ref_test.go b/datamodel/high/base/example_inline_ref_test.go new file mode 100644 index 000000000..261b10705 --- /dev/null +++ b/datamodel/high/base/example_inline_ref_test.go @@ -0,0 +1,80 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + + lowmodel "github.com/pb33f/libopenapi/datamodel/low" + lowbase "github.com/pb33f/libopenapi/datamodel/low/base" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +// buildReferencedExample builds an Example whose low model is a reference to another example +// in the same document, which is what the inline renderer resolves through. +func buildReferencedExample(t *testing.T) *Example { + t.Helper() + + const spec = `components: + examples: + Target: + summary: the referenced example + value: resolved-value + Referenced: + $ref: '#/components/examples/Target' +` + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(spec), &root)) + + idx := index.NewSpecIndexWithConfig(&root, index.CreateOpenAPIIndexConfig()) + + // Locate the Referenced example node. + examples := root.Content[0].Content[1].Content[1] + var refNode *yaml.Node + for i := 0; i < len(examples.Content)-1; i += 2 { + if examples.Content[i].Value == "Referenced" { + refNode = examples.Content[i+1] + } + } + require.NotNil(t, refNode) + + low := new(lowbase.Example) + require.NoError(t, lowmodel.BuildModel(refNode, low)) + require.NoError(t, low.Build(context.Background(), nil, refNode, idx)) + + return NewExample(low) +} + +// Inline rendering resolves a referenced example and renders its target in place. The high +// model carries no Reference of its own here, so resolution goes through the low model rather +// than short-circuiting to a $ref node. +func TestExample_MarshalYAMLInline_ResolvesReference(t *testing.T) { + example := buildReferencedExample(t) + require.NotNil(t, example) + require.Empty(t, example.Reference, "the high model holds no reference of its own here") + + t.Run("without context", func(t *testing.T) { + rendered, err := example.MarshalYAMLInline() + require.NoError(t, err) + require.NotNil(t, rendered) + + out, err := yaml.Marshal(rendered) + require.NoError(t, err) + assert.NotContains(t, string(out), "$ref", "the target is rendered in place") + }) + + t.Run("with context", func(t *testing.T) { + rendered, err := example.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, rendered) + + out, err := yaml.Marshal(rendered) + require.NoError(t, err) + assert.NotContains(t, string(out), "$ref") + }) +} diff --git a/datamodel/high/base/schema_proxy.go b/datamodel/high/base/schema_proxy.go index edee4df71..3401c677c 100644 --- a/datamodel/high/base/schema_proxy.go +++ b/datamodel/high/base/schema_proxy.go @@ -8,6 +8,7 @@ import ( "fmt" "net/url" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -1091,14 +1092,22 @@ func (sp *SchemaProxy) marshalYAMLInlineInternal(ctx *InlineRenderContext) (inte if s != nil && s.GoLow() != nil && s.GoLow().Index != nil { idx := s.GoLow().Index - circ := idx.GetCircularReferences() - - // Extract ignored and safe circular references from rolodex if available - if idx.GetRolodex() != nil { - ignored := idx.GetRolodex().GetIgnoredCircularReferences() - safe := idx.GetRolodex().GetSafeCircularReferences() - circ = append(circ, ignored...) - circ = append(circ, safe...) + + // GetCircularReferences hands back the index's own slice, which the resolver grows with + // append and therefore leaves spare capacity on. clone before extending, or the appends + // below write into memory shared with every other render using this index. + circ := slices.Clone(idx.GetCircularReferences()) + + // extract ignored and safe circular references from rolodex if available, along with the + // root index circulars. circular references are registered on the rolodex root, but this + // schema's index is the one owning its resolved content, which for an external $ref is not + // the root. without that the guard below silently stops firing for referenced schemas. + if rolodex := idx.GetRolodex(); rolodex != nil { + if root := rolodex.GetRootIndex(); root != nil && root != idx { + circ = append(circ, root.GetCircularReferences()...) + } + circ = append(circ, rolodex.GetIgnoredCircularReferences()...) + circ = append(circ, rolodex.GetSafeCircularReferences()...) } cirError := func(str string) error { diff --git a/datamodel/high/node_builder_test.go b/datamodel/high/node_builder_test.go index 2c16d226d..7eb35b021 100644 --- a/datamodel/high/node_builder_test.go +++ b/datamodel/high/node_builder_test.go @@ -112,15 +112,10 @@ func (te *test1) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.V func (te *test1) MarshalYAML() (interface{}, error) { panic("MarshalYAML") - nb := NewNodeBuilder(te, te) - return nb.Render(), nil } func (te *test1) GetKeyNode() *yaml.Node { panic("GetKeyNode") - kn := utils.CreateStringNode("meddy") - kn.Line = 20 - return kn } func (te *test1) GetValueNode() *yaml.Node { @@ -131,7 +126,6 @@ func (te *test1) GetValueNode() *yaml.Node { func (te *test1) GoesLowUntyped() any { panic("GoesLowUntyped") - return te } type test2 struct { diff --git a/datamodel/high/v3/inline_ref_render_test.go b/datamodel/high/v3/inline_ref_render_test.go new file mode 100644 index 000000000..96e45f32d --- /dev/null +++ b/datamodel/high/v3/inline_ref_render_test.go @@ -0,0 +1,141 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package v3 + +import ( + "testing" + + "github.com/pb33f/libopenapi/datamodel" + v3 "github.com/pb33f/libopenapi/datamodel/low/v3" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +const refRenderSpec = `openapi: 3.1.0 +info: + title: reference rendering + version: 1.0.0 +paths: + /referenced: + $ref: '#/components/pathItems/Target' +components: + pathItems: + Target: + get: + description: the referenced path item + responses: + '200': + description: ok + securitySchemes: + Target: + type: apiKey + name: api_key + in: header + Referenced: + $ref: '#/components/securitySchemes/Target' +` + +func buildRefRenderDocument(t *testing.T) *Document { + t.Helper() + + info, err := datamodel.ExtractSpecInfo([]byte(refRenderSpec)) + require.NoError(t, err) + + lowDoc, err := v3.CreateDocumentFromConfig(info, &datamodel.DocumentConfiguration{}) + require.NoError(t, err) + + return NewDocument(lowDoc) +} + +// Inline rendering resolves a referenced object and renders its target in place. The high model +// carries no Reference of its own for these types, so the resolution happens through the low +// model rather than short-circuiting to a $ref node. +func TestPathItem_MarshalYAMLInline_ResolvesReference(t *testing.T) { + doc := buildRefRenderDocument(t) + + pathItem := doc.Paths.PathItems.GetOrZero("/referenced") + require.NotNil(t, pathItem) + require.Empty(t, pathItem.Reference, "the high model holds no reference of its own here") + require.True(t, pathItem.GoLow().IsReference(), "but the low model knows it is one") + + t.Run("without context", func(t *testing.T) { + rendered, err := pathItem.MarshalYAMLInline() + require.NoError(t, err) + require.NotNil(t, rendered) + + out, err := yaml.Marshal(rendered) + require.NoError(t, err) + assert.Contains(t, string(out), "the referenced path item") + assert.NotContains(t, string(out), "$ref", "the target is rendered in place") + }) + + t.Run("with context", func(t *testing.T) { + rendered, err := pathItem.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, rendered) + + out, err := yaml.Marshal(rendered) + require.NoError(t, err) + assert.Contains(t, string(out), "the referenced path item") + assert.NotContains(t, string(out), "$ref") + }) +} + +func TestSecurityScheme_MarshalYAMLInline_ResolvesReference(t *testing.T) { + doc := buildRefRenderDocument(t) + + scheme := doc.Components.SecuritySchemes.GetOrZero("Referenced") + require.NotNil(t, scheme) + require.Empty(t, scheme.Reference) + require.True(t, scheme.GoLow().IsReference()) + + t.Run("without context", func(t *testing.T) { + rendered, err := scheme.MarshalYAMLInline() + require.NoError(t, err) + require.NotNil(t, rendered) + + out, err := yaml.Marshal(rendered) + require.NoError(t, err) + assert.Contains(t, string(out), "apiKey") + assert.Contains(t, string(out), "api_key") + assert.NotContains(t, string(out), "$ref") + }) + + t.Run("with context", func(t *testing.T) { + rendered, err := scheme.MarshalYAMLInlineWithContext(nil) + require.NoError(t, err) + require.NotNil(t, rendered) + + out, err := yaml.Marshal(rendered) + require.NoError(t, err) + assert.Contains(t, string(out), "apiKey") + assert.NotContains(t, string(out), "$ref") + }) +} + +// RenderJSON converts the rendered YAML tree to JSON, which can fail on values YAML permits +// but JSON has no representation for. NaN is the reachable case: it is a legal YAML float and +// a legal mapping key, but json.Marshal refuses it. The error must surface rather than yield a +// half-written document. +func TestDocument_RenderJSON_PropagatesConversionError(t *testing.T) { + spec := `openapi: 3.1.0 +info: + title: unconvertible extension + version: 1.0.0 + x-weird: + .nan: value +paths: {} +` + info, err := datamodel.ExtractSpecInfo([]byte(spec)) + require.NoError(t, err) + + lowDoc, err := v3.CreateDocumentFromConfig(info, &datamodel.DocumentConfiguration{}) + require.NoError(t, err) + + rendered, err := NewDocument(lowDoc).RenderJSON(" ") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported value: NaN") + assert.Empty(t, rendered) +} diff --git a/datamodel/low/arazzo/arazzo.go b/datamodel/low/arazzo/arazzo.go index cf65e40fc..c525f0d1b 100644 --- a/datamodel/low/arazzo/arazzo.go +++ b/datamodel/low/arazzo/arazzo.go @@ -14,9 +14,10 @@ import ( ) // Arazzo represents a low-level Arazzo document. -// https://spec.openapis.org/arazzo/v1.0.1 +// https://spec.openapis.org/arazzo/v1.1.0 type Arazzo struct { Arazzo low.NodeReference[string] + Self low.NodeReference[string] Info low.NodeReference[*Info] SourceDescriptions low.NodeReference[[]low.ValueReference[*SourceDescription]] Workflows low.NodeReference[[]low.ValueReference[*Workflow]] @@ -71,6 +72,11 @@ func (a *Arazzo) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index Context: &a.context, }, ctx, keyNode, root, idx) + if err := requireScalar(SelfLabel, "a scalar URI", root); err != nil { + return err + } + a.Self = extractComponentRef(SelfLabel, root) + info, err := low.ExtractObject[*Info](ctx, InfoLabel, root, idx) if err != nil { return err @@ -110,6 +116,10 @@ func (a *Arazzo) Hash() uint64 { h.WriteString(a.Arazzo.Value) h.WriteByte(low.HASH_PIPE) } + if !a.Self.IsEmpty() { + h.WriteString(a.Self.Value) + h.WriteByte(low.HASH_PIPE) + } if !a.Info.IsEmpty() { low.HashUint64(h, a.Info.Value.Hash()) } diff --git a/datamodel/low/arazzo/arazzo_test.go b/datamodel/low/arazzo/arazzo_test.go index 4c13a4a7f..9a5814010 100644 --- a/datamodel/low/arazzo/arazzo_test.go +++ b/datamodel/low/arazzo/arazzo_test.go @@ -1198,7 +1198,7 @@ outputs: pair := step.Outputs.Value.First() require.NotNil(t, pair) assert.Equal(t, "petName", pair.Key().Value) - assert.Equal(t, "$response.body#/name", pair.Value().Value) + assert.Equal(t, "$response.body#/name", pair.Value().Value.Expression.Value) } func TestStep_Build_WithOperationPath(t *testing.T) { @@ -1408,7 +1408,7 @@ parameters: pair := wf.Outputs.Value.First() require.NotNil(t, pair) assert.Equal(t, "result", pair.Key().Value) - assert.Equal(t, "$steps.getPet.outputs.petName", pair.Value().Value) + assert.Equal(t, "$steps.getPet.outputs.petName", pair.Value().Value.Expression.Value) // Parameters require.False(t, wf.Parameters.IsEmpty()) @@ -1851,14 +1851,14 @@ components: outPair := step.Outputs.Value.First() require.NotNil(t, outPair) assert.Equal(t, "petName", outPair.Key().Value) - assert.Equal(t, "$response.body#/name", outPair.Value().Value) + assert.Equal(t, "$response.body#/name", outPair.Value().Value.Expression.Value) // First workflow outputs require.False(t, wf1.Outputs.IsEmpty()) wfOutPair := wf1.Outputs.Value.First() require.NotNil(t, wfOutPair) assert.Equal(t, "result", wfOutPair.Key().Value) - assert.Equal(t, "$steps.getPet.outputs.petName", wfOutPair.Value().Value) + assert.Equal(t, "$steps.getPet.outputs.petName", wfOutPair.Value().Value.Expression.Value) // Second workflow wf2 := arazzo.Workflows.Value[1].Value @@ -2135,8 +2135,7 @@ func TestExtractArray_NotSequence(t *testing.T) { require.NoError(t, err) result, err := extractArray[Parameter](context.Background(), ParametersLabel, node.Content[0], nil) - require.NoError(t, err) - // Has key/value nodes set but no items since it is not a sequence + require.Error(t, err) assert.NotNil(t, result.KeyNode) assert.Nil(t, result.Value) } @@ -2172,7 +2171,8 @@ func TestExtractStringArray_NotSequence(t *testing.T) { err := yaml.Unmarshal([]byte(yml), &node) require.NoError(t, err) - result := extractStringArray(DependsOnLabel, node.Content[0]) + result, err := extractStringArray(DependsOnLabel, node.Content[0]) + require.Error(t, err) assert.NotNil(t, result.KeyNode) assert.Nil(t, result.Value) } @@ -2184,7 +2184,8 @@ func TestExtractStringArray_Empty(t *testing.T) { err := yaml.Unmarshal([]byte(yml), &node) require.NoError(t, err) - result := extractStringArray(DependsOnLabel, node.Content[0]) + result, err := extractStringArray(DependsOnLabel, node.Content[0]) + require.NoError(t, err) assert.Len(t, result.Value, 0) } @@ -2198,7 +2199,8 @@ func TestExtractStringArray_Multiple(t *testing.T) { err := yaml.Unmarshal([]byte(yml), &node) require.NoError(t, err) - result := extractStringArray(DependsOnLabel, node.Content[0]) + result, err := extractStringArray(DependsOnLabel, node.Content[0]) + require.NoError(t, err) require.Len(t, result.Value, 3) assert.Equal(t, "alpha", result.Value[0].Value) assert.Equal(t, "beta", result.Value[1].Value) @@ -2279,7 +2281,8 @@ func TestExtractRawNodeMap_Found(t *testing.T) { err := yaml.Unmarshal([]byte(yml), &node) require.NoError(t, err) - result := extractRawNodeMap(InputsLabel, node.Content[0]) + result, err := extractRawNodeMap(InputsLabel, node.Content[0]) + require.NoError(t, err) require.False(t, result.IsEmpty()) require.NotNil(t, result.Value) assert.Equal(t, 1, result.Value.Len()) @@ -2295,7 +2298,8 @@ func TestExtractRawNodeMap_NotMapping(t *testing.T) { err := yaml.Unmarshal([]byte(yml), &node) require.NoError(t, err) - result := extractRawNodeMap(InputsLabel, node.Content[0]) + result, err := extractRawNodeMap(InputsLabel, node.Content[0]) + require.Error(t, err) assert.NotNil(t, result.KeyNode) assert.Nil(t, result.Value) } @@ -2307,7 +2311,8 @@ func TestExtractRawNodeMap_Missing(t *testing.T) { err := yaml.Unmarshal([]byte(yml), &node) require.NoError(t, err) - result := extractRawNodeMap(InputsLabel, node.Content[0]) + result, err := extractRawNodeMap(InputsLabel, node.Content[0]) + require.NoError(t, err) assert.True(t, result.IsEmpty()) } @@ -2351,7 +2356,7 @@ func TestExtractObjectMap_NotMapping(t *testing.T) { require.NoError(t, err) result, err := extractObjectMap[Parameter](context.Background(), ParametersLabel, node.Content[0], nil) - require.NoError(t, err) + require.Error(t, err) assert.Nil(t, result.Value) } @@ -2403,7 +2408,8 @@ func TestExtractStringArray_OddContentLength(t *testing.T) { Value: "orphan", }) - result := extractStringArray(DependsOnLabel, root) + result, err := extractStringArray(DependsOnLabel, root) + require.NoError(t, err) assert.Nil(t, result.Value) } @@ -2472,7 +2478,8 @@ func TestExtractRawNodeMap_OddContentLength(t *testing.T) { Value: "orphan", }) - result := extractRawNodeMap(InputsLabel, root) + result, err := extractRawNodeMap(InputsLabel, root) + require.NoError(t, err) assert.True(t, result.IsEmpty()) } @@ -2974,7 +2981,8 @@ func TestExtractRawNodeMap_OddInnerContentLength(t *testing.T) { } } - result := extractRawNodeMap(InputsLabel, root) + result, err := extractRawNodeMap(InputsLabel, root) + require.NoError(t, err) require.NotNil(t, result.Value) assert.Equal(t, 1, result.Value.Len()) } diff --git a/datamodel/low/arazzo/components.go b/datamodel/low/arazzo/components.go index 73eb15220..f1c9ea103 100644 --- a/datamodel/low/arazzo/components.go +++ b/datamodel/low/arazzo/components.go @@ -72,7 +72,11 @@ func (c *Components) Build(ctx context.Context, keyNode, root *yaml.Node, idx *i }, ctx, keyNode, root, idx) // Extract inputs as raw node map (JSON Schema objects keyed by name) - c.Inputs = extractRawNodeMap(InputsLabel, root) + inputs, err := extractRawNodeMap(InputsLabel, root) + if err != nil { + return err + } + c.Inputs = inputs // Extract parameters map params, err := extractComponentsParametersMap(ctx, ParametersLabel, root, idx) diff --git a/datamodel/low/arazzo/constants.go b/datamodel/low/arazzo/constants.go index 21007ce42..5c26a175f 100644 --- a/datamodel/low/arazzo/constants.go +++ b/datamodel/low/arazzo/constants.go @@ -7,6 +7,7 @@ package arazzo // https://spec.openapis.org/arazzo/v1.0.1 const ( ArazzoLabel = "arazzo" + SelfLabel = "$self" InfoLabel = "info" SourceDescriptionsLabel = "sourceDescriptions" WorkflowsLabel = "workflows" @@ -29,6 +30,10 @@ const ( StepIdLabel = "stepId" OperationIdLabel = "operationId" OperationPathLabel = "operationPath" + ChannelPathLabel = "channelPath" + ActionLabel = "action" + CorrelationIdLabel = "correlationId" + TimeoutLabel = "timeout" RequestBodyLabel = "requestBody" SuccessCriteriaLabel = "successCriteria" OnSuccessLabel = "onSuccess" @@ -45,4 +50,6 @@ const ( PayloadLabel = "payload" ReplacementsLabel = "replacements" TargetLabel = "target" + TargetSelectorTypeLabel = "targetSelectorType" + SelectorLabel = "selector" ) diff --git a/datamodel/low/arazzo/coverage_test.go b/datamodel/low/arazzo/coverage_test.go index a67778816..c03de6f18 100644 --- a/datamodel/low/arazzo/coverage_test.go +++ b/datamodel/low/arazzo/coverage_test.go @@ -67,8 +67,7 @@ workflows: require.NoError(t, err) err = a.Build(context.Background(), nil, node.Content[0], nil) - assert.NoError(t, err) - // sourceDescriptions is not a valid sequence, so it should be empty + assert.Error(t, err) assert.True(t, a.SourceDescriptions.IsEmpty() || len(a.SourceDescriptions.Value) == 0) } @@ -90,7 +89,7 @@ workflows: not-a-sequence` require.NoError(t, err) err = a.Build(context.Background(), nil, node.Content[0], nil) - assert.NoError(t, err) + assert.Error(t, err) assert.True(t, a.Workflows.IsEmpty() || len(a.Workflows.Value) == 0) } @@ -325,7 +324,7 @@ func TestComponents_Build_ParametersNotMapping(t *testing.T) { var comp Components _ = low.BuildModel(node.Content[0], &comp) err := comp.Build(context.Background(), nil, node.Content[0], nil) - assert.NoError(t, err) + assert.Error(t, err) // parameters value is not a mapping, so the map value should be nil assert.Nil(t, comp.Parameters.Value) } @@ -339,7 +338,7 @@ func TestComponents_Build_SuccessActionsNotMapping(t *testing.T) { var comp Components _ = low.BuildModel(node.Content[0], &comp) err := comp.Build(context.Background(), nil, node.Content[0], nil) - assert.NoError(t, err) + assert.Error(t, err) assert.Nil(t, comp.SuccessActions.Value) } @@ -352,7 +351,7 @@ func TestComponents_Build_FailureActionsNotMapping(t *testing.T) { var comp Components _ = low.BuildModel(node.Content[0], &comp) err := comp.Build(context.Background(), nil, node.Content[0], nil) - assert.NoError(t, err) + assert.Error(t, err) assert.Nil(t, comp.FailureActions.Value) } @@ -591,8 +590,7 @@ func TestCov_ExtractArray_NotSequence(t *testing.T) { _ = yaml.Unmarshal([]byte(yml), &node) result, err := extractArray[SourceDescription](context.Background(), "items", node.Content[0], nil) - assert.NoError(t, err) - // Should have KeyNode set but no items + assert.Error(t, err) assert.Nil(t, result.Value) } @@ -604,7 +602,7 @@ func TestCov_ExtractObjectMap_NotMapping(t *testing.T) { _ = yaml.Unmarshal([]byte(yml), &node) result, err := extractObjectMap[Parameter](context.Background(), "things", node.Content[0], nil) - assert.NoError(t, err) + assert.Error(t, err) assert.Nil(t, result.Value) } @@ -640,7 +638,8 @@ func TestCov_ExtractStringArray_NotSequence(t *testing.T) { var node yaml.Node _ = yaml.Unmarshal([]byte(yml), &node) - result := extractStringArray("items", node.Content[0]) + result, err := extractStringArray("items", node.Content[0]) + assert.Error(t, err) assert.Nil(t, result.Value) } @@ -652,7 +651,8 @@ func TestExtractStringArray_Found(t *testing.T) { var node yaml.Node _ = yaml.Unmarshal([]byte(yml), &node) - result := extractStringArray("items", node.Content[0]) + result, err := extractStringArray("items", node.Content[0]) + require.NoError(t, err) require.NotNil(t, result.Value) assert.Len(t, result.Value, 2) assert.Equal(t, "alpha", result.Value[0].Value) @@ -703,7 +703,8 @@ func TestCov_ExtractRawNodeMap_NotMapping(t *testing.T) { var node yaml.Node _ = yaml.Unmarshal([]byte(yml), &node) - result := extractRawNodeMap("inputs", node.Content[0]) + result, err := extractRawNodeMap("inputs", node.Content[0]) + assert.Error(t, err) assert.Nil(t, result.Value) } @@ -720,7 +721,8 @@ func TestExtractRawNodeMap_OddContent(t *testing.T) { }, } - result := extractRawNodeMap("inputs", root) + result, err := extractRawNodeMap("inputs", root) + assert.NoError(t, err) require.NotNil(t, result.Value) assert.Equal(t, 1, result.Value.Len()) } @@ -1112,7 +1114,8 @@ func TestExtractStringArray_OddRootContent(t *testing.T) { }, } - result := extractStringArray("items", root) + result, err := extractStringArray("items", root) + require.NoError(t, err) require.NotNil(t, result.Value) assert.Len(t, result.Value, 1) assert.Equal(t, "a", result.Value[0].Value) diff --git a/datamodel/low/arazzo/criterion_expression_type.go b/datamodel/low/arazzo/criterion_expression_type.go index 3f7c20c2c..48248d051 100644 --- a/datamodel/low/arazzo/criterion_expression_type.go +++ b/datamodel/low/arazzo/criterion_expression_type.go @@ -13,9 +13,9 @@ import ( "go.yaml.in/yaml/v4" ) -// CriterionExpressionType represents a low-level Arazzo Criterion Expression Type Object. -// https://spec.openapis.org/arazzo/v1.0.1#criterion-expression-type-object -type CriterionExpressionType struct { +// ExpressionType represents a low-level Arazzo Expression Type Object. +// https://spec.openapis.org/arazzo/v1.1.0#expression-type-object +type ExpressionType struct { Type low.NodeReference[string] Version low.NodeReference[string] Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] @@ -30,32 +30,32 @@ type CriterionExpressionType struct { // GetIndex returns the index.SpecIndex instance attached to the CriterionExpressionType object. // For Arazzo low models this is typically nil, because Arazzo parsing does not build a SpecIndex. // The index parameter is still required to satisfy the shared low.Buildable interface and generic extractors. -func (c *CriterionExpressionType) GetIndex() *index.SpecIndex { +func (c *ExpressionType) GetIndex() *index.SpecIndex { return c.index } // GetContext returns the context.Context instance used when building the CriterionExpressionType object. -func (c *CriterionExpressionType) GetContext() context.Context { +func (c *ExpressionType) GetContext() context.Context { return c.context } // FindExtension returns a ValueReference containing the extension value, if found. -func (c *CriterionExpressionType) FindExtension(ext string) *low.ValueReference[*yaml.Node] { +func (c *ExpressionType) FindExtension(ext string) *low.ValueReference[*yaml.Node] { return low.FindItemInOrderedMap(ext, c.Extensions) } // GetRootNode returns the root yaml node of the CriterionExpressionType object. -func (c *CriterionExpressionType) GetRootNode() *yaml.Node { +func (c *ExpressionType) GetRootNode() *yaml.Node { return c.RootNode } // GetKeyNode returns the key yaml node of the CriterionExpressionType object. -func (c *CriterionExpressionType) GetKeyNode() *yaml.Node { +func (c *ExpressionType) GetKeyNode() *yaml.Node { return c.KeyNode } // Build will extract all properties of the CriterionExpressionType object. -func (c *CriterionExpressionType) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { +func (c *ExpressionType) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { root = initBuild(&arazzoBase{ KeyNode: &c.KeyNode, RootNode: &c.RootNode, @@ -69,12 +69,12 @@ func (c *CriterionExpressionType) Build(ctx context.Context, keyNode, root *yaml } // GetExtensions returns all CriterionExpressionType extensions and satisfies the low.HasExtensions interface. -func (c *CriterionExpressionType) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { +func (c *ExpressionType) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { return c.Extensions } // Hash will return a consistent hash of the CriterionExpressionType object. -func (c *CriterionExpressionType) Hash() uint64 { +func (c *ExpressionType) Hash() uint64 { return low.WithHasher(func(h *maphash.Hash) uint64 { if !c.Type.IsEmpty() { h.WriteString(c.Type.Value) @@ -88,3 +88,6 @@ func (c *CriterionExpressionType) Hash() uint64 { return h.Sum64() }) } + +// CriterionExpressionType is retained as an alias for source compatibility with Arazzo 1.0 callers. +type CriterionExpressionType = ExpressionType diff --git a/datamodel/low/arazzo/failure_action.go b/datamodel/low/arazzo/failure_action.go index e7d73366a..523b66bfd 100644 --- a/datamodel/low/arazzo/failure_action.go +++ b/datamodel/low/arazzo/failure_action.go @@ -17,26 +17,28 @@ import ( // FailureAction represents a low-level Arazzo Failure Action Object. // A failure action can be a full definition or a Reusable Object with a $components reference. -// https://spec.openapis.org/arazzo/v1.0.1#failure-action-object +// https://spec.openapis.org/arazzo/v1.1.0#failure-action-object type FailureAction struct { - Name low.NodeReference[string] - Type low.NodeReference[string] - WorkflowId low.NodeReference[string] - StepId low.NodeReference[string] - RetryAfter low.NodeReference[float64] - RetryLimit low.NodeReference[int64] - Criteria low.NodeReference[[]low.ValueReference[*Criterion]] + Name low.NodeReference[string] + Type low.NodeReference[string] + WorkflowId low.NodeReference[string] + StepId low.NodeReference[string] + RetryAfter low.NodeReference[float64] + RetryLimit low.NodeReference[int64] + Criteria low.NodeReference[[]low.ValueReference[*Criterion]] + Parameters low.NodeReference[[]low.ValueReference[*Parameter]] ComponentRef low.NodeReference[string] - Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] - KeyNode *yaml.Node - RootNode *yaml.Node - index *index.SpecIndex - context context.Context + Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] + KeyNode *yaml.Node + RootNode *yaml.Node + index *index.SpecIndex + context context.Context *low.Reference low.NodeMap } var extractFailureActionCriteria = extractArray[Criterion] +var extractFailureActionParameters = extractArray[Parameter] // IsReusable returns true if this failure action is a Reusable Object (has a reference field). func (f *FailureAction) IsReusable() bool { @@ -121,6 +123,15 @@ func (f *FailureAction) Build(ctx context.Context, keyNode, root *yaml.Node, idx return err } f.Criteria = criteria + + if err := requireSequence(ParametersLabel, "a sequence of Parameter Objects", root); err != nil { + return err + } + parameters, err := extractFailureActionParameters(ctx, ParametersLabel, root, idx) + if err != nil { + return err + } + f.Parameters = parameters return nil } @@ -165,6 +176,11 @@ func (f *FailureAction) Hash() uint64 { low.HashUint64(h, c.Value.Hash()) } } + if !f.Parameters.IsEmpty() { + for _, parameter := range f.Parameters.Value { + low.HashUint64(h, parameter.Value.Hash()) + } + } hashExtensionsInto(h, f.Extensions) return h.Sum64() }) diff --git a/datamodel/low/arazzo/final_coverage_test.go b/datamodel/low/arazzo/final_coverage_test.go index 912132d7f..5ea662a11 100644 --- a/datamodel/low/arazzo/final_coverage_test.go +++ b/datamodel/low/arazzo/final_coverage_test.go @@ -101,7 +101,7 @@ parameters: not-a-sequence` var step Step require.NoError(t, low.BuildModel(root, &step)) err := step.Build(context.Background(), nil, root, nil) - assert.NoError(t, err) + assert.Error(t, err) assert.Nil(t, step.Parameters.Value) } @@ -113,7 +113,7 @@ successCriteria: not-a-sequence` root := buildNode(t, yml) var step Step require.NoError(t, low.BuildModel(root, &step)) - assert.NoError(t, step.Build(context.Background(), nil, root, nil)) + assert.Error(t, step.Build(context.Background(), nil, root, nil)) assert.Nil(t, step.SuccessCriteria.Value) } @@ -125,7 +125,7 @@ onSuccess: not-a-sequence` root := buildNode(t, yml) var step Step require.NoError(t, low.BuildModel(root, &step)) - assert.NoError(t, step.Build(context.Background(), nil, root, nil)) + assert.Error(t, step.Build(context.Background(), nil, root, nil)) assert.Nil(t, step.OnSuccess.Value) } @@ -137,7 +137,7 @@ onFailure: not-a-sequence` root := buildNode(t, yml) var step Step require.NoError(t, low.BuildModel(root, &step)) - assert.NoError(t, step.Build(context.Background(), nil, root, nil)) + assert.Error(t, step.Build(context.Background(), nil, root, nil)) assert.Nil(t, step.OnFailure.Value) } @@ -214,7 +214,7 @@ steps: not-a-sequence` root := buildNode(t, yml) var wf Workflow require.NoError(t, low.BuildModel(root, &wf)) - assert.NoError(t, wf.Build(context.Background(), nil, root, nil)) + assert.Error(t, wf.Build(context.Background(), nil, root, nil)) assert.Nil(t, wf.Steps.Value) } @@ -228,7 +228,7 @@ successActions: not-a-sequence` root := buildNode(t, yml) var wf Workflow require.NoError(t, low.BuildModel(root, &wf)) - assert.NoError(t, wf.Build(context.Background(), nil, root, nil)) + assert.Error(t, wf.Build(context.Background(), nil, root, nil)) assert.Nil(t, wf.SuccessActions.Value) } @@ -242,7 +242,7 @@ failureActions: not-a-sequence` root := buildNode(t, yml) var wf Workflow require.NoError(t, low.BuildModel(root, &wf)) - assert.NoError(t, wf.Build(context.Background(), nil, root, nil)) + assert.Error(t, wf.Build(context.Background(), nil, root, nil)) assert.Nil(t, wf.FailureActions.Value) } @@ -256,7 +256,7 @@ parameters: not-a-sequence` root := buildNode(t, yml) var wf Workflow require.NoError(t, low.BuildModel(root, &wf)) - assert.NoError(t, wf.Build(context.Background(), nil, root, nil)) + assert.Error(t, wf.Build(context.Background(), nil, root, nil)) assert.Nil(t, wf.Parameters.Value) } @@ -402,7 +402,7 @@ replacements: not-a-sequence` root := buildNode(t, yml) var rb RequestBody require.NoError(t, low.BuildModel(root, &rb)) - assert.NoError(t, rb.Build(context.Background(), nil, root, nil)) + assert.Error(t, rb.Build(context.Background(), nil, root, nil)) assert.Nil(t, rb.Replacements.Value) } @@ -434,7 +434,7 @@ criteria: not-a-sequence` root := buildNode(t, yml) var sa SuccessAction require.NoError(t, low.BuildModel(root, &sa)) - assert.NoError(t, sa.Build(context.Background(), nil, root, nil)) + assert.Error(t, sa.Build(context.Background(), nil, root, nil)) assert.Nil(t, sa.Criteria.Value) } @@ -490,7 +490,7 @@ criteria: not-a-sequence` root := buildNode(t, yml) var fa FailureAction require.NoError(t, low.BuildModel(root, &fa)) - assert.NoError(t, fa.Build(context.Background(), nil, root, nil)) + assert.Error(t, fa.Build(context.Background(), nil, root, nil)) assert.Nil(t, fa.Criteria.Value) } @@ -951,14 +951,16 @@ type: func TestFinalCov_ExtractStringArray_NotSeq(t *testing.T) { yml := `dependsOn: not-a-sequence` root := buildNode(t, yml) - result := extractStringArray(DependsOnLabel, root) + result, err := extractStringArray(DependsOnLabel, root) + require.Error(t, err) assert.Nil(t, result.Value) } func TestFinalCov_ExtractStringArray_Empty(t *testing.T) { yml := `dependsOn: []` root := buildNode(t, yml) - result := extractStringArray(DependsOnLabel, root) + result, err := extractStringArray(DependsOnLabel, root) + require.NoError(t, err) assert.NotNil(t, result.Value) assert.Len(t, result.Value, 0) } @@ -988,14 +990,16 @@ func TestFinalCov_ExtractExpressionsMap_Empty(t *testing.T) { func TestFinalCov_ExtractRawNodeMap_NotMapping(t *testing.T) { yml := `inputs: not-a-mapping` root := buildNode(t, yml) - result := extractRawNodeMap(InputsLabel, root) + result, err := extractRawNodeMap(InputsLabel, root) + require.Error(t, err) assert.Nil(t, result.Value) } func TestFinalCov_ExtractRawNodeMap_Empty(t *testing.T) { yml := `inputs: {}` root := buildNode(t, yml) - result := extractRawNodeMap(InputsLabel, root) + result, err := extractRawNodeMap(InputsLabel, root) + require.NoError(t, err) assert.NotNil(t, result.Value) assert.Equal(t, 0, result.Value.Len()) } diff --git a/datamodel/low/arazzo/helpers.go b/datamodel/low/arazzo/helpers.go index 6430a002d..43ce09ffe 100644 --- a/datamodel/low/arazzo/helpers.go +++ b/datamodel/low/arazzo/helpers.go @@ -5,7 +5,9 @@ package arazzo import ( "context" + "fmt" "hash/maphash" + "strconv" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -71,6 +73,56 @@ func assignNodeReference[T any]( return nil } +// resolveAliasNode follows an alias chain without allowing malformed or cyclic +// aliases to hang model construction. The caller retains the authored node for +// source metadata and uses the returned node only for shape inspection/building. +func resolveAliasNode(node *yaml.Node) (*yaml.Node, error) { + if node == nil { + return nil, nil + } + if node.Kind != yaml.AliasNode { + return node, nil + } + seen := make(map[*yaml.Node]struct{}) + current := node + for current.Kind == yaml.AliasNode { + if _, exists := seen[current]; exists { + return nil, fmt.Errorf("cyclic YAML alias at line %d, column %d", node.Line, node.Column) + } + seen[current] = struct{}{} + if current.Alias == nil { + return nil, fmt.Errorf("empty YAML alias at line %d, column %d", current.Line, current.Column) + } + current = current.Alias + } + return current, nil +} + +// extractScalarString preserves the authored YAML node while reading the scalar +// value from its resolved alias target. +func extractScalarString(label string, root *yaml.Node) (low.NodeReference[string], error) { + var result low.NodeReference[string] + key, value, found := findLabeledNode(label, root) + if !found { + return result, nil + } + result.KeyNode = key + result.ValueNode = value + resolved, err := resolveAliasNode(value) + if err != nil { + return result, fmt.Errorf("%s: %w", label, err) + } + if resolved.Tag == "!!null" { + return result, nil + } + if resolved.Kind != yaml.ScalarNode { + return result, fmt.Errorf("%s at line %d, column %d must be a scalar", + label, value.Line, value.Column) + } + result.Value = resolved.Value + return result, nil +} + // extractArray extracts a YAML sequence node into a slice of ValueReferences for the given label. func extractArray[N any, T interface { *N @@ -85,16 +137,32 @@ func extractArray[N any, T interface { } result.KeyNode = key result.ValueNode = value - if value.Kind != yaml.SequenceNode { + resolvedValue, err := resolveAliasNode(value) + if err != nil { + return result, fmt.Errorf("%s: %w", label, err) + } + if resolvedValue.Tag == "!!null" { return result, nil } - items := make([]low.ValueReference[T], 0, len(value.Content)) - for _, itemNode := range value.Content { + if resolvedValue.Kind != yaml.SequenceNode { + return result, fmt.Errorf("%s at line %d, column %d must be a sequence of objects", + label, value.Line, value.Column) + } + items := make([]low.ValueReference[T], 0, len(resolvedValue.Content)) + for _, itemNode := range resolvedValue.Content { + resolvedItem, resolveErr := resolveAliasNode(itemNode) + if resolveErr != nil { + return result, fmt.Errorf("%s item: %w", label, resolveErr) + } + if resolvedItem.Kind != yaml.MappingNode { + return result, fmt.Errorf("%s item at line %d, column %d must be a mapping", + label, itemNode.Line, itemNode.Column) + } obj := T(new(N)) - if err := low.BuildModel(itemNode, obj); err != nil { + if err := low.BuildModel(resolvedItem, obj); err != nil { return result, err } - if err := obj.Build(ctx, nil, itemNode, idx); err != nil { + if err := obj.Build(ctx, nil, resolvedItem, idx); err != nil { return result, err } items = append(items, low.ValueReference[T]{ @@ -120,21 +188,37 @@ func extractObjectMap[N any, T interface { } result.KeyNode = key result.ValueNode = value - if value.Kind != yaml.MappingNode { + resolvedValue, err := resolveAliasNode(value) + if err != nil { + return result, fmt.Errorf("%s: %w", label, err) + } + if resolvedValue.Tag == "!!null" { return result, nil } + if resolvedValue.Kind != yaml.MappingNode { + return result, fmt.Errorf("%s at line %d, column %d must be a mapping", + label, value.Line, value.Column) + } m := orderedmap.New[low.KeyReference[string], low.ValueReference[T]]() - for j := 0; j < len(value.Content); j += 2 { - if j+1 >= len(value.Content) { + for j := 0; j < len(resolvedValue.Content); j += 2 { + if j+1 >= len(resolvedValue.Content) { break } - mapKey := value.Content[j] - mapVal := value.Content[j+1] + mapKey := resolvedValue.Content[j] + mapVal := resolvedValue.Content[j+1] + resolvedMapVal, resolveErr := resolveAliasNode(mapVal) + if resolveErr != nil { + return result, fmt.Errorf("%s member %q: %w", label, mapKey.Value, resolveErr) + } + if resolvedMapVal.Kind != yaml.MappingNode { + return result, fmt.Errorf("%s member %q at line %d, column %d must be a mapping", + label, mapKey.Value, mapVal.Line, mapVal.Column) + } obj := T(new(N)) - if err := low.BuildModel(mapVal, obj); err != nil { + if err := low.BuildModel(resolvedMapVal, obj); err != nil { return result, err } - if err := obj.Build(ctx, mapKey, mapVal, idx); err != nil { + if err := obj.Build(ctx, mapKey, resolvedMapVal, idx); err != nil { return result, err } m.Set(low.KeyReference[string]{ @@ -150,26 +234,42 @@ func extractObjectMap[N any, T interface { } // extractStringArray extracts a YAML sequence of scalar strings into a NodeReference. -func extractStringArray(label string, root *yaml.Node) low.NodeReference[[]low.ValueReference[string]] { +func extractStringArray(label string, root *yaml.Node) (low.NodeReference[[]low.ValueReference[string]], error) { var result low.NodeReference[[]low.ValueReference[string]] key, value, found := findLabeledNode(label, root) if !found { - return result + return result, nil } result.KeyNode = key result.ValueNode = value - if value.Kind != yaml.SequenceNode { - return result + resolvedValue, err := resolveAliasNode(value) + if err != nil { + return result, fmt.Errorf("%s: %w", label, err) } - items := make([]low.ValueReference[string], 0, len(value.Content)) - for _, itemNode := range value.Content { + if resolvedValue.Tag == "!!null" { + return result, nil + } + if resolvedValue.Kind != yaml.SequenceNode { + return result, fmt.Errorf("%s at line %d, column %d must be a sequence of scalar strings", + label, value.Line, value.Column) + } + items := make([]low.ValueReference[string], 0, len(resolvedValue.Content)) + for _, itemNode := range resolvedValue.Content { + resolvedItem, resolveErr := resolveAliasNode(itemNode) + if resolveErr != nil { + return result, fmt.Errorf("%s item: %w", label, resolveErr) + } + if resolvedItem.Kind != yaml.ScalarNode || resolvedItem.Tag == "!!null" { + return result, fmt.Errorf("%s item at line %d, column %d must be a scalar step identifier", + label, itemNode.Line, itemNode.Column) + } items = append(items, low.ValueReference[string]{ - Value: itemNode.Value, + Value: resolvedItem.Value, ValueNode: itemNode, }) } result.Value = items - return result + return result, nil } // extractRawNode extracts a raw *yaml.Node for a given label without further processing. @@ -216,25 +316,81 @@ func extractExpressionsMap(label string, root *yaml.Node) low.NodeReference[*ord return result } +// extractOutputValuesMap extracts a map whose values are runtime-expression scalars or Selector Objects. +func extractOutputValuesMap( + ctx context.Context, + label string, + root *yaml.Node, + idx *index.SpecIndex, +) (low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*OutputValue]]], error) { + var result low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*OutputValue]]] + key, value, found := findLabeledNode(label, root) + if !found { + return result, nil + } + result.KeyNode = key + result.ValueNode = value + resolvedValue, err := resolveAliasNode(value) + if err != nil { + return result, fmt.Errorf("%s: %w", label, err) + } + if resolvedValue.Tag == "!!null" { + return result, nil + } + if resolvedValue.Kind != yaml.MappingNode { + return result, fmt.Errorf("%s at line %d, column %d must be a mapping", + label, value.Line, value.Column) + } + outputs := orderedmap.New[low.KeyReference[string], low.ValueReference[*OutputValue]]() + for i := 0; i < len(resolvedValue.Content); i += 2 { + if i+1 >= len(resolvedValue.Content) { + break + } + outputKey := resolvedValue.Content[i] + outputNode := resolvedValue.Content[i+1] + output := new(OutputValue) + if err := output.Build(ctx, outputKey, outputNode, idx); err != nil { + return result, err + } + outputs.Set(low.KeyReference[string]{ + Value: outputKey.Value, + KeyNode: outputKey, + }, low.ValueReference[*OutputValue]{ + Value: output, + ValueNode: outputNode, + }) + } + result.Value = outputs + return result, nil +} + // extractRawNodeMap extracts a YAML mapping node into an ordered map of string keys to raw *yaml.Node values. -func extractRawNodeMap(label string, root *yaml.Node) low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]]] { +func extractRawNodeMap(label string, root *yaml.Node) (low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]]], error) { var result low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]]] key, value, found := findLabeledNode(label, root) if !found { - return result + return result, nil } result.KeyNode = key result.ValueNode = value - if value.Kind != yaml.MappingNode { - return result + resolvedValue, err := resolveAliasNode(value) + if err != nil { + return result, fmt.Errorf("%s: %w", label, err) + } + if resolvedValue.Tag == "!!null" { + return result, nil + } + if resolvedValue.Kind != yaml.MappingNode { + return result, fmt.Errorf("%s at line %d, column %d must be a mapping", + label, value.Line, value.Column) } m := orderedmap.New[low.KeyReference[string], low.ValueReference[*yaml.Node]]() - for j := 0; j < len(value.Content); j += 2 { - if j+1 >= len(value.Content) { + for j := 0; j < len(resolvedValue.Content); j += 2 { + if j+1 >= len(resolvedValue.Content) { break } - mapKey := value.Content[j] - mapVal := value.Content[j+1] + mapKey := resolvedValue.Content[j] + mapVal := resolvedValue.Content[j+1] m.Set(low.KeyReference[string]{ Value: mapKey.Value, KeyNode: mapKey, @@ -244,7 +400,7 @@ func extractRawNodeMap(label string, root *yaml.Node) low.NodeReference[*ordered }) } result.Value = m - return result + return result, nil } // extractComponentRef extracts a string field from root.Content by label, returning it as a NodeReference. @@ -264,24 +420,37 @@ func extractComponentRef(label string, root *yaml.Node) low.NodeReference[string // hashYAMLNode writes a yaml.Node tree directly into a maphash.Hash for efficient hashing. func hashYAMLNode(h *maphash.Hash, node *yaml.Node) { + hashYAMLNodePath(h, node, make(map[*yaml.Node]struct{})) +} + +func hashYAMLNodePath(h *maphash.Hash, node *yaml.Node, active map[*yaml.Node]struct{}) { if node == nil { return } + if _, exists := active[node]; exists { + return + } + active[node] = struct{}{} + defer delete(active, node) switch node.Kind { case yaml.ScalarNode: h.WriteString(node.Value) h.WriteByte(low.HASH_PIPE) case yaml.MappingNode, yaml.SequenceNode: + // The kind is part of the hash: without it a mapping and a sequence holding the same + // scalars are indistinguishable, so `{a: b}` and `[a, b]` would compare as equal. + h.WriteByte(byte(node.Kind)) + h.WriteByte(low.HASH_PIPE) for _, child := range node.Content { - hashYAMLNode(h, child) + hashYAMLNodePath(h, child, active) } case yaml.DocumentNode: for _, child := range node.Content { - hashYAMLNode(h, child) + hashYAMLNodePath(h, child, active) } case yaml.AliasNode: if node.Alias != nil { - hashYAMLNode(h, node.Alias) + hashYAMLNodePath(h, node.Alias, active) } } } @@ -297,3 +466,78 @@ func hashExtensionsInto(h *maphash.Hash, ext *orderedmap.Map[low.KeyReference[st hashYAMLNode(h, pair.Value().Value) } } + +// requireNodeKind verifies that a named field, when present, uses one of the expected +// YAML node kinds. +// +// The reflection-driven model builder silently ignores a value whose node kind does not +// match the target Go field, which turns an authoring mistake into quiet data loss: a +// mapping-valued timeout, or a scalar dependsOn, simply disappears from the model. The +// composite Arazzo 1.1 objects (Selector, OutputValue, targetSelectorType) already reject +// an unexpected node kind with a source-positioned error, so this brings the scalar and +// sequence fields into line with them. +// +// An absent field is not an error; requiredness is the validator's concern, not the +// parser's. Likewise a well-formed value that is semantically wrong is left alone. +func requireNodeKind(label, description string, root *yaml.Node, kinds ...yaml.Kind) error { + _, value, found := findLabeledNode(label, root) + if !found || value == nil { + return nil + } + resolved, err := resolveAliasNode(value) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + // An explicit or aliased YAML null is treated as absent rather than as a kind violation. + if resolved.Tag == "!!null" { + return nil + } + for _, kind := range kinds { + if resolved.Kind == kind { + return nil + } + } + return fmt.Errorf("%s at line %d, column %d must be %s", + label, value.Line, value.Column, description) +} + +// requireScalar is the common case: a field that must be a single scalar value. +func requireScalar(label, description string, root *yaml.Node) error { + return requireNodeKind(label, description, root, yaml.ScalarNode) +} + +// requireSequence is the common case: a field that must be a YAML sequence. +func requireSequence(label, description string, root *yaml.Node) error { + return requireNodeKind(label, description, root, yaml.SequenceNode) +} + +// requireInteger verifies that a named field, when present, is an integer the model +// builder will actually accept. +// +// A node-kind check alone is not enough. The builder gates integer fields on the YAML +// tag via utils.IsNodeIntValue, so a quoted scalar such as "5000" is a well-formed +// scalar that parses as an integer yet is still silently dropped. Gating on the same +// predicate the builder uses closes that gap; the subsequent base-10 parse then rejects +// values the builder would coerce to zero, such as 0x1F. +func requireInteger(label, description string, root *yaml.Node) error { + _, value, found := findLabeledNode(label, root) + if !found || value == nil { + return nil + } + resolved, err := resolveAliasNode(value) + if err != nil { + return fmt.Errorf("%s: %w", label, err) + } + if resolved.Tag == "!!null" { + return nil + } + if !utils.IsNodeIntValue(resolved) { + return fmt.Errorf("%s at line %d, column %d must be %s, got %q", + label, value.Line, value.Column, description, resolved.Value) + } + if _, err := strconv.ParseInt(resolved.Value, 10, 64); err != nil { + return fmt.Errorf("%s at line %d, column %d must be %s, got %q", + label, value.Line, value.Column, description, resolved.Value) + } + return nil +} diff --git a/datamodel/low/arazzo/model_fields_test.go b/datamodel/low/arazzo/model_fields_test.go new file mode 100644 index 000000000..70c205ff2 --- /dev/null +++ b/datamodel/low/arazzo/model_fields_test.go @@ -0,0 +1,346 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "errors" + "testing" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +const arazzo11ModelYAML = `arazzo: 1.1.0 +$self: https://example.com/workflows/root.arazzo.yaml +info: + title: Arazzo 1.1 + version: 1.0.0 +sourceDescriptions: + - name: messages + url: asyncapi.yaml + type: asyncapi +workflows: + - workflowId: publish + steps: + - stepId: send + channelPath: $sourceDescriptions.messages#/channels/orders + action: send + correlationId: order-id + timeout: 2500 + dependsOn: [prepare] + requestBody: + contentType: application/json + payload: + id: old + replacements: + - target: $.id + targetSelectorType: + type: jsonpath + version: rfc9535 + value: + context: $inputs + selector: $.id + type: jsonpath + onSuccess: + - name: done + type: end + parameters: + - name: result + value: $outputs.result + onFailure: + - name: retry + type: retry + retryLimit: 1 + parameters: + - name: reason + value: failed + outputs: + selected: + context: $response.body + selector: $.id + type: + type: jsonpath + version: rfc9535 + outputs: + status: $steps.send.outputs.selected +` + +func buildLowArazzo11(t *testing.T) *Arazzo { + t.Helper() + var document yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(arazzo11ModelYAML), &document)) + root := document.Content[0] + model := new(Arazzo) + require.NoError(t, low.BuildModel(root, model)) + require.NoError(t, model.Build(context.Background(), nil, root, nil)) + return model +} + +func TestArazzo11LowModel_AllFieldsAndUnions(t *testing.T) { + document := buildLowArazzo11(t) + assert.Equal(t, "https://example.com/workflows/root.arazzo.yaml", document.Self.Value) + assert.NotZero(t, document.Hash()) + + workflow := document.Workflows.Value[0].Value + step := workflow.Steps.Value[0].Value + assert.Equal(t, "$sourceDescriptions.messages#/channels/orders", step.ChannelPath.Value) + assert.Equal(t, "send", step.Action.Value) + assert.Equal(t, "order-id", step.CorrelationId.Value) + assert.EqualValues(t, 2500, step.Timeout.Value) + require.Len(t, step.DependsOn.Value, 1) + assert.Equal(t, "prepare", step.DependsOn.Value[0].Value) + require.Len(t, step.OnSuccess.Value[0].Value.Parameters.Value, 1) + require.Len(t, step.OnFailure.Value[0].Value.Parameters.Value, 1) + + replacement := step.RequestBody.Value.Replacements.Value[0].Value + require.Equal(t, yaml.MappingNode, replacement.TargetSelectorType.Value.Kind) + assert.NotZero(t, replacement.Hash()) + + stepOutput := step.Outputs.Value.First().Value().Value + assert.True(t, stepOutput.IsSelector()) + assert.False(t, stepOutput.IsExpression()) + assert.Same(t, step.Outputs.Value.First().Key().KeyNode, stepOutput.GetKeyNode()) + assert.Same(t, step.Outputs.Value.First().Value().ValueNode, stepOutput.GetRootNode()) + assert.Equal(t, context.Background(), stepOutput.GetContext()) + assert.Nil(t, stepOutput.GetIndex()) + assert.NotZero(t, stepOutput.Hash()) + + selector := stepOutput.Selector.Value + assert.Equal(t, "$response.body", selector.Context.Value) + assert.Equal(t, "$.id", selector.Selector.Value) + assert.NotNil(t, selector.GetContext()) + assert.Nil(t, selector.GetIndex()) + assert.Same(t, selector.RootNode, selector.GetRootNode()) + assert.Same(t, selector.KeyNode, selector.GetKeyNode()) + assert.Nil(t, selector.FindExtension("x-missing")) + assert.Equal(t, selector.Extensions, selector.GetExtensions()) + assert.NotZero(t, selector.Hash()) + + workflowOutput := workflow.Outputs.Value.First().Value().Value + assert.True(t, workflowOutput.IsExpression()) + assert.False(t, workflowOutput.IsSelector()) + assert.Equal(t, "$steps.send.outputs.selected", workflowOutput.Expression.Value) + assert.NotZero(t, workflowOutput.Hash()) +} + +func TestOutputValue_BuildRejectsUnexpectedNodeKind(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("- one\n- two"), &node)) + output := new(OutputValue) + err := output.Build(context.Background(), nil, node.Content[0], nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a scalar or Selector Object") +} + +func TestOutputValue_BuildPropagatesMalformedSelectorType(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("context: $response.body\nselector: $.id\ntype: [jsonpath]"), &node)) + output := new(OutputValue) + err := output.Build(context.Background(), nil, node.Content[0], nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "selector type") +} + +func TestSelector_BuildScalarTypeAndExtensionHash(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("context: $inputs\nselector: /id\ntype: jsonpointer\nx-note: yes"), &node)) + selector := new(Selector) + require.NoError(t, low.BuildModel(node.Content[0], selector)) + require.NoError(t, selector.Build(context.Background(), nil, node.Content[0], nil)) + require.NotNil(t, selector.FindExtension("x-note")) + firstHash := selector.Hash() + selector.Context.Value = "$response.body" + assert.NotEqual(t, firstHash, selector.Hash()) +} + +func TestSelector_BuildRejectsSequenceType(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("context: $inputs\nselector: $.id\ntype: [jsonpath]"), &node)) + selector := new(Selector) + require.NoError(t, low.BuildModel(node.Content[0], selector)) + err := selector.Build(context.Background(), nil, node.Content[0], nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "line 3") +} + +func TestExtractOutputValuesMap_AbsentAndWrongKind(t *testing.T) { + var absent yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("name: value"), &absent)) + outputs, err := extractOutputValuesMap(context.Background(), OutputsLabel, absent.Content[0], nil) + require.NoError(t, err) + assert.True(t, outputs.IsEmpty()) + + var scalar yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("outputs: invalid"), &scalar)) + outputs, err = extractOutputValuesMap(context.Background(), OutputsLabel, scalar.Content[0], nil) + require.Error(t, err) + assert.Nil(t, outputs.Value) + assert.NotNil(t, outputs.ValueNode) + + oddOutputs := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "orphan"}, + }, + } + oddRoot := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: OutputsLabel}, + oddOutputs, + }, + } + outputs, err = extractOutputValuesMap(context.Background(), OutputsLabel, oddRoot, nil) + require.NoError(t, err) + require.NotNil(t, outputs.Value) + assert.Equal(t, 0, outputs.Value.Len()) + + var malformed yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("outputs:\n bad: [one]"), &malformed)) + _, err = extractOutputValuesMap(context.Background(), OutputsLabel, malformed.Content[0], nil) + require.Error(t, err) +} + +func TestArazzo11HashChangesForNewFields(t *testing.T) { + document := buildLowArazzo11(t) + base := document.Hash() + document.Self.Value = "https://example.com/changed" + assert.NotEqual(t, base, document.Hash()) + + step := document.Workflows.Value[0].Value.Steps.Value[0].Value + mutations := []func(){ + func() { step.ChannelPath.Value = "changed-channel" }, + func() { step.Action.Value = "receive" }, + func() { step.CorrelationId.Value = "changed-correlation" }, + func() { step.Timeout.Value++ }, + func() { step.DependsOn.Value[0].Value = "changed-dependency" }, + } + for _, mutate := range mutations { + before := step.Hash() + mutate() + assert.NotEqual(t, before, step.Hash()) + } +} + +func TestArazzo11NestedHashesCoverEveryNewField(t *testing.T) { + t.Run("selector fields and step output", func(t *testing.T) { + document := buildLowArazzo11(t) + step := document.Workflows.Value[0].Value.Steps.Value[0].Value + selector := step.Outputs.Value.First().Value().Value.Selector.Value + + beforeSelector := selector.Hash() + beforeStep := step.Hash() + selector.Selector.Value = "$.changed" + assert.NotEqual(t, beforeSelector, selector.Hash()) + assert.NotEqual(t, beforeStep, step.Hash()) + + beforeSelector = selector.Hash() + selector.Type.Value.Content[1].Value = "xpath" + assert.NotEqual(t, beforeSelector, selector.Hash()) + }) + + t.Run("target selector type and request body", func(t *testing.T) { + document := buildLowArazzo11(t) + step := document.Workflows.Value[0].Value.Steps.Value[0].Value + replacement := step.RequestBody.Value.Replacements.Value[0].Value + beforeReplacement := replacement.Hash() + beforeRequestBody := step.RequestBody.Value.Hash() + beforeStep := step.Hash() + replacement.TargetSelectorType.Value.Content[3].Value = "draft-goessner-dispatch-jsonpath-00" + assert.NotEqual(t, beforeReplacement, replacement.Hash()) + assert.NotEqual(t, beforeRequestBody, step.RequestBody.Value.Hash()) + assert.NotEqual(t, beforeStep, step.Hash()) + }) + + t.Run("action parameters", func(t *testing.T) { + document := buildLowArazzo11(t) + step := document.Workflows.Value[0].Value.Steps.Value[0].Value + success := step.OnSuccess.Value[0].Value + beforeSuccess := success.Hash() + beforeStep := step.Hash() + success.Parameters.Value[0].Value.Name.Value = "changed-success" + assert.NotEqual(t, beforeSuccess, success.Hash()) + assert.NotEqual(t, beforeStep, step.Hash()) + + failure := step.OnFailure.Value[0].Value + beforeFailure := failure.Hash() + beforeStep = step.Hash() + failure.Parameters.Value[0].Value.Name.Value = "changed-failure" + assert.NotEqual(t, beforeFailure, failure.Hash()) + assert.NotEqual(t, beforeStep, step.Hash()) + }) + + t.Run("workflow output", func(t *testing.T) { + document := buildLowArazzo11(t) + workflow := document.Workflows.Value[0].Value + output := workflow.Outputs.Value.First().Value().Value + beforeOutput := output.Hash() + beforeWorkflow := workflow.Hash() + output.Expression.Value = "$statusCode" + assert.NotEqual(t, beforeOutput, output.Hash()) + assert.NotEqual(t, beforeWorkflow, workflow.Hash()) + }) +} + +func TestArazzo11Build_PropagatesOutputUnionErrors(t *testing.T) { + var stepNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("stepId: bad\noperationId: bad\noutputs:\n invalid: [one]"), &stepNode)) + step := new(Step) + require.NoError(t, low.BuildModel(stepNode.Content[0], step)) + require.Error(t, step.Build(context.Background(), nil, stepNode.Content[0], nil)) + + var workflowNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("workflowId: bad\nsteps: []\noutputs:\n invalid: [one]"), &workflowNode)) + workflow := new(Workflow) + require.NoError(t, low.BuildModel(workflowNode.Content[0], workflow)) + require.Error(t, workflow.Build(context.Background(), nil, workflowNode.Content[0], nil)) +} + +func TestPayloadReplacement_BuildRejectsInvalidTargetSelectorType(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("target: $.id\ntargetSelectorType: [jsonpath]\nvalue: 1"), &node)) + replacement := new(PayloadReplacement) + require.NoError(t, low.BuildModel(node.Content[0], replacement)) + err := replacement.Build(context.Background(), nil, node.Content[0], nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "targetSelectorType") +} + +func TestArazzo11ActionParameterExtractionErrors(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("name: action\ntype: end"), &node)) + sentinel := errors.New("parameter extraction failed") + + originalSuccess := extractSuccessActionParameters + extractSuccessActionParameters = func( + context.Context, + string, + *yaml.Node, + *index.SpecIndex, + ) (low.NodeReference[[]low.ValueReference[*Parameter]], error) { + return low.NodeReference[[]low.ValueReference[*Parameter]]{}, sentinel + } + success := new(SuccessAction) + require.NoError(t, low.BuildModel(node.Content[0], success)) + assert.ErrorIs(t, success.Build(context.Background(), nil, node.Content[0], nil), sentinel) + extractSuccessActionParameters = originalSuccess + + originalFailure := extractFailureActionParameters + extractFailureActionParameters = func( + context.Context, + string, + *yaml.Node, + *index.SpecIndex, + ) (low.NodeReference[[]low.ValueReference[*Parameter]], error) { + return low.NodeReference[[]low.ValueReference[*Parameter]]{}, sentinel + } + failure := new(FailureAction) + require.NoError(t, low.BuildModel(node.Content[0], failure)) + assert.ErrorIs(t, failure.Build(context.Background(), nil, node.Content[0], nil), sentinel) + extractFailureActionParameters = originalFailure +} diff --git a/datamodel/low/arazzo/node_kind_test.go b/datamodel/low/arazzo/node_kind_test.go new file mode 100644 index 000000000..5b4e2d3d6 --- /dev/null +++ b/datamodel/low/arazzo/node_kind_test.go @@ -0,0 +1,593 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "hash/maphash" + "testing" + "time" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +func mappingNode(t *testing.T, src string) *yaml.Node { + t.Helper() + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &node)) + return node.Content[0] +} + +// requireNodeKind accepts an absent field, accepts an explicit null as absent, accepts any +// listed kind, and otherwise reports the offending node's position. +func TestRequireNodeKind(t *testing.T) { + t.Run("absent field is not an error", func(t *testing.T) { + assert.NoError(t, requireScalar("missing", "a scalar", mappingNode(t, "other: value"))) + }) + + t.Run("explicit null is treated as absent", func(t *testing.T) { + assert.NoError(t, requireScalar("field", "a scalar", mappingNode(t, "field: ~"))) + assert.NoError(t, requireSequence("field", "a sequence", mappingNode(t, "field: null"))) + }) + + t.Run("matching kind is accepted", func(t *testing.T) { + assert.NoError(t, requireScalar("field", "a scalar", mappingNode(t, "field: value"))) + assert.NoError(t, requireSequence("field", "a sequence", mappingNode(t, "field:\n - one"))) + }) + + t.Run("mismatched kind reports position", func(t *testing.T) { + err := requireScalar("field", "a scalar thing", mappingNode(t, "other: x\nfield:\n nested: y")) + require.Error(t, err) + assert.Contains(t, err.Error(), "field at line 3, column 3 must be a scalar thing") + }) + + t.Run("multiple accepted kinds", func(t *testing.T) { + root := mappingNode(t, "field:\n nested: y") + assert.NoError(t, requireNodeKind("field", "a scalar or mapping", root, + yaml.ScalarNode, yaml.MappingNode)) + + err := requireNodeKind("field", "a scalar or sequence", root, + yaml.ScalarNode, yaml.SequenceNode) + assert.Error(t, err) + }) +} + +// requireInteger additionally rejects a scalar that does not parse as an integer, since a +// wrong-typed scalar passes the node-kind check but is still dropped by the builder. +func TestRequireInteger(t *testing.T) { + t.Run("absent and null are accepted", func(t *testing.T) { + assert.NoError(t, requireInteger("timeout", "an integer", mappingNode(t, "other: 1"))) + assert.NoError(t, requireInteger("timeout", "an integer", mappingNode(t, "timeout: ~"))) + }) + + t.Run("integers are accepted", func(t *testing.T) { + assert.NoError(t, requireInteger("timeout", "an integer", mappingNode(t, "timeout: 2500"))) + assert.NoError(t, requireInteger("timeout", "an integer", mappingNode(t, "timeout: -5"))) + }) + + t.Run("non-scalar is rejected", func(t *testing.T) { + err := requireInteger("timeout", "an integer", mappingNode(t, "timeout:\n - 1")) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout at line 2, column 3 must be an integer") + }) + + t.Run("non-integer scalar is rejected with its value", func(t *testing.T) { + err := requireInteger("timeout", "an integer", mappingNode(t, "timeout: soon")) + require.Error(t, err) + assert.Contains(t, err.Error(), `must be an integer, got "soon"`) + }) + + t.Run("float scalar is rejected", func(t *testing.T) { + err := requireInteger("timeout", "an integer", mappingNode(t, "timeout: 1.5")) + require.Error(t, err) + assert.Contains(t, err.Error(), `got "1.5"`) + }) +} + +// Each Build method must surface the node-kind failure for its own 1.1 fields. +func TestBuildRejectsMalformed11FieldNodeKinds(t *testing.T) { + t.Run("arazzo $self", func(t *testing.T) { + root := mappingNode(t, "arazzo: 1.1.0\n$self:\n not: scalar") + doc := new(Arazzo) + require.NoError(t, low.BuildModel(root, doc)) + err := doc.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "$self") + assert.Contains(t, err.Error(), "must be a scalar URI") + }) + + stepCases := []struct { + name string + src string + wantSub string + }{ + {"channelPath", "stepId: s\nchannelPath:\n not: scalar", "a scalar channel reference"}, + {"action", "stepId: s\naction:\n - not-scalar", "a scalar action name"}, + {"correlationId", "stepId: s\ncorrelationId:\n not: scalar", "a scalar string"}, + {"timeout", "stepId: s\ntimeout: soon", "an integer number of milliseconds"}, + {"dependsOn", "stepId: s\ndependsOn:\n not: sequence", "a sequence of step identifiers"}, + } + for _, test := range stepCases { + t.Run("step "+test.name, func(t *testing.T) { + root := mappingNode(t, test.src) + step := new(Step) + require.NoError(t, low.BuildModel(root, step)) + err := step.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), test.name) + assert.Contains(t, err.Error(), test.wantSub) + }) + } + + t.Run("success action parameters", func(t *testing.T) { + root := mappingNode(t, "name: n\ntype: goto\nparameters: not-a-sequence") + action := new(SuccessAction) + require.NoError(t, low.BuildModel(root, action)) + err := action.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "a sequence of Parameter Objects") + }) + + t.Run("failure action parameters", func(t *testing.T) { + root := mappingNode(t, "name: n\ntype: goto\nparameters: not-a-sequence") + action := new(FailureAction) + require.NoError(t, low.BuildModel(root, action)) + err := action.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "a sequence of Parameter Objects") + }) +} + +func TestBuildRejectsMalformed11CollectionMembers(t *testing.T) { + t.Run("step dependsOn member must be scalar", func(t *testing.T) { + root := mappingNode(t, "stepId: s\ndependsOn:\n - nested: value") + step := new(Step) + require.NoError(t, low.BuildModel(root, step)) + err := step.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "dependsOn item at line 3, column 5 must be a scalar step identifier") + }) + + t.Run("workflow collections require their authored container kinds", func(t *testing.T) { + for _, test := range []struct { + name string + src string + want string + }{ + {name: "success actions", src: "workflowId: w\nsuccessActions: wrong", want: "successActions"}, + {name: "failure actions", src: "workflowId: w\nfailureActions: wrong", want: "failureActions"}, + {name: "parameters", src: "workflowId: w\nparameters: wrong", want: "parameters"}, + {name: "outputs", src: "workflowId: w\noutputs: []", want: "outputs"}, + } { + t.Run(test.name, func(t *testing.T) { + root := mappingNode(t, test.src) + workflow := new(Workflow) + require.NoError(t, low.BuildModel(root, workflow)) + err := workflow.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), test.want) + }) + } + }) + + t.Run("object array member must be mapping", func(t *testing.T) { + root := mappingNode(t, "name: done\ntype: end\nparameters:\n - wrong") + action := new(SuccessAction) + require.NoError(t, low.BuildModel(root, action)) + err := action.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "parameters item") + assert.Contains(t, err.Error(), "must be a mapping") + }) +} + +func TestOutputValueBuildResolvesAliasesWithoutLosingAuthoredNode(t *testing.T) { + root := mappingNode(t, "expression: &expression $inputs.id\nexpressionAlias: *expression\nselector: &selector\n context: $response.body\n selector: $.id\n type: jsonpath\nselectorAlias: *selector") + + expressionAlias := root.Content[3] + expression := new(OutputValue) + require.NoError(t, expression.Build(context.Background(), root.Content[2], expressionAlias, nil)) + assert.True(t, expression.IsExpression()) + assert.Equal(t, "$inputs.id", expression.Expression.Value) + assert.Same(t, expressionAlias, expression.RootNode) + assert.Same(t, expressionAlias, expression.Expression.ValueNode) + + selectorAlias := root.Content[7] + selector := new(OutputValue) + require.NoError(t, selector.Build(context.Background(), root.Content[6], selectorAlias, nil)) + assert.True(t, selector.IsSelector()) + assert.Equal(t, "$.id", selector.Selector.Value.Selector.Value) + assert.Same(t, selectorAlias, selector.RootNode) + assert.Same(t, selectorAlias, selector.Selector.ValueNode) +} + +func TestOutputValueBuildRejectsCyclicOrEmptyAliases(t *testing.T) { + cyclic := &yaml.Node{Kind: yaml.AliasNode, Line: 7, Column: 11} + cyclic.Alias = cyclic + value := new(OutputValue) + err := value.Build(context.Background(), nil, cyclic, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "cyclic YAML alias") + + empty := &yaml.Node{Kind: yaml.AliasNode, Line: 8, Column: 12} + err = value.Build(context.Background(), nil, empty, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty YAML alias") +} + +func TestStrictCollectionExtractorsCoverNullAndAliasFailures(t *testing.T) { + cyclic := func(line int) *yaml.Node { + node := &yaml.Node{Kind: yaml.AliasNode, Line: line, Column: 3} + node.Alias = node + return node + } + rootWith := func(label string, value *yaml.Node) *yaml.Node { + return &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: label}, value, + }} + } + + t.Run("resolver accepts nil", func(t *testing.T) { + resolved, err := resolveAliasNode(nil) + require.NoError(t, err) + assert.Nil(t, resolved) + }) + + t.Run("aliases to null are absent consistently", func(t *testing.T) { + nullNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null"} + alias := &yaml.Node{Kind: yaml.AliasNode, Alias: nullNode, Line: 2, Column: 3} + root := rootWith("field", alias) + array, err := extractArray[Parameter](context.Background(), "field", root, nil) + require.NoError(t, err) + assert.Nil(t, array.Value) + strings, err := extractStringArray("field", root) + require.NoError(t, err) + assert.Nil(t, strings.Value) + outputs, err := extractOutputValuesMap(context.Background(), "field", root, nil) + require.NoError(t, err) + assert.Nil(t, outputs.Value) + assert.NoError(t, requireNodeKind("field", "a mapping", root, yaml.MappingNode)) + assert.NoError(t, requireInteger("field", "an integer", root)) + }) + + t.Run("object array null is absent", func(t *testing.T) { + result, err := extractArray[Parameter](context.Background(), ParametersLabel, + mappingNode(t, "parameters: null"), nil) + require.NoError(t, err) + assert.Nil(t, result.Value) + }) + + t.Run("object array rejects cyclic container alias", func(t *testing.T) { + _, err := extractArray[Parameter](context.Background(), ParametersLabel, + rootWith(ParametersLabel, cyclic(2)), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "parameters: cyclic YAML alias") + }) + + t.Run("object array rejects cyclic member alias", func(t *testing.T) { + sequence := &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{cyclic(3)}} + _, err := extractArray[Parameter](context.Background(), ParametersLabel, + rootWith(ParametersLabel, sequence), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "parameters item: cyclic YAML alias") + }) + + t.Run("string array null is absent", func(t *testing.T) { + result, err := extractStringArray(DependsOnLabel, mappingNode(t, "dependsOn: null")) + require.NoError(t, err) + assert.Nil(t, result.Value) + }) + + t.Run("string array rejects cyclic container alias", func(t *testing.T) { + _, err := extractStringArray(DependsOnLabel, rootWith(DependsOnLabel, cyclic(2))) + require.Error(t, err) + assert.Contains(t, err.Error(), "dependsOn: cyclic YAML alias") + }) + + t.Run("string array rejects cyclic member alias", func(t *testing.T) { + sequence := &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{cyclic(3)}} + _, err := extractStringArray(DependsOnLabel, rootWith(DependsOnLabel, sequence)) + require.Error(t, err) + assert.Contains(t, err.Error(), "dependsOn item: cyclic YAML alias") + }) + + t.Run("output map null is absent", func(t *testing.T) { + result, err := extractOutputValuesMap(context.Background(), OutputsLabel, + mappingNode(t, "outputs: null"), nil) + require.NoError(t, err) + assert.Nil(t, result.Value) + }) + + t.Run("output map rejects cyclic container alias", func(t *testing.T) { + _, err := extractOutputValuesMap(context.Background(), OutputsLabel, + rootWith(OutputsLabel, cyclic(2)), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "outputs: cyclic YAML alias") + }) + + t.Run("kind and integer guards reject cyclic aliases", func(t *testing.T) { + root := rootWith("field", cyclic(2)) + assert.Error(t, requireNodeKind("field", "a scalar", root, yaml.ScalarNode)) + assert.Error(t, requireInteger("field", "an integer", root)) + }) + + t.Run("workflow propagates malformed dependsOn member", func(t *testing.T) { + root := mappingNode(t, "workflowId: w\ndependsOn:\n - nested: value") + workflow := new(Workflow) + require.NoError(t, low.BuildModel(root, workflow)) + err := workflow.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "dependsOn item") + }) +} + +func TestComponentCollectionsRejectMalformedShapesAndResolveAliases(t *testing.T) { + t.Run("containers must be mappings", func(t *testing.T) { + for _, field := range []string{InputsLabel, ParametersLabel, SuccessActionsLabel, FailureActionsLabel} { + root := mappingNode(t, field+": wrong") + components := new(Components) + require.NoError(t, low.BuildModel(root, components)) + err := components.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), field) + assert.Contains(t, err.Error(), "must be a mapping") + } + }) + + t.Run("object members must be mappings", func(t *testing.T) { + root := mappingNode(t, "parameters:\n bad: wrong") + components := new(Components) + require.NoError(t, low.BuildModel(root, components)) + err := components.Build(context.Background(), nil, root, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "parameters member") + assert.Contains(t, err.Error(), "must be a mapping") + }) + + t.Run("mapping container and member aliases are accepted", func(t *testing.T) { + root := mappingNode(t, "parameter: ¶meter\n name: id\n in: path\n value: 1\nparameterMap: ¶meterMap\n id: *parameter\nparameters: *parameterMap") + components := new(Components) + require.NoError(t, low.BuildModel(root, components)) + require.NoError(t, components.Build(context.Background(), nil, root, nil)) + require.NotNil(t, components.Parameters.Value) + pair := components.Parameters.Value.First() + require.NotNil(t, pair) + assert.Equal(t, "id", pair.Value().Value.Name.Value) + }) +} + +func TestAliasedSelectorUnionMembersBuildAndPreserveAuthoredNodes(t *testing.T) { + root := mappingNode(t, "scalarType: &scalarType jsonpath\nobjectType: &objectType\n type: jsonpath\n version: rfc9535\ncontextValue: &contextValue $response.body\nselectorValue: &selectorValue $.id\ntargetValue: &targetValue /id\nselector:\n context: *contextValue\n selector: *selectorValue\n type: *scalarType\nreplacement:\n target: *targetValue\n targetSelectorType: *objectType\n value: 1") + + selectorNode := root.Content[11] + selector := new(Selector) + require.NoError(t, low.BuildModel(selectorNode, selector)) + require.NoError(t, selector.Build(context.Background(), root.Content[10], selectorNode, nil)) + assert.Equal(t, "$response.body", selector.Context.Value) + assert.Equal(t, "$.id", selector.Selector.Value) + assert.Equal(t, yaml.AliasNode, selector.Context.ValueNode.Kind) + assert.Equal(t, yaml.AliasNode, selector.Selector.ValueNode.Kind) + require.Equal(t, yaml.AliasNode, selector.Type.Value.Kind) + resolvedType, err := resolveAliasNode(selector.Type.Value) + require.NoError(t, err) + assert.Equal(t, "jsonpath", resolvedType.Value) + + replacementNode := root.Content[13] + replacement := new(PayloadReplacement) + require.NoError(t, low.BuildModel(replacementNode, replacement)) + require.NoError(t, replacement.Build(context.Background(), root.Content[12], replacementNode, nil)) + assert.Equal(t, "/id", replacement.Target.Value) + assert.Equal(t, yaml.AliasNode, replacement.Target.ValueNode.Kind) + require.Equal(t, yaml.AliasNode, replacement.TargetSelectorType.Value.Kind) + resolvedType, err = resolveAliasNode(replacement.TargetSelectorType.Value) + require.NoError(t, err) + assert.Equal(t, yaml.MappingNode, resolvedType.Kind) +} + +func TestHashYAMLNodeIncludesContentBelowFormerDepthLimit(t *testing.T) { + deepNode := func(leaf string) *yaml.Node { + var node *yaml.Node = &yaml.Node{Kind: yaml.ScalarNode, Value: leaf} + for range 110 { + node = &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{node}} + } + return node + } + hash := func(node *yaml.Node) uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + hashYAMLNode(h, node) + return h.Sum64() + }) + } + assert.NotEqual(t, hash(deepNode("left")), hash(deepNode("right"))) +} + +func TestAliasAwareCollectionHelpersCoverMalformedAndNullInputs(t *testing.T) { + t.Run("scalar extraction", func(t *testing.T) { + missing, err := extractScalarString("field", mappingNode(t, "other: value")) + require.NoError(t, err) + assert.True(t, missing.IsEmpty()) + + nullValue, err := extractScalarString("field", mappingNode(t, "field: null")) + require.NoError(t, err) + assert.Empty(t, nullValue.Value) + + _, err = extractScalarString("field", mappingNode(t, "field:\n nested: value")) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be a scalar") + + cyclic := &yaml.Node{Kind: yaml.AliasNode, Line: 4, Column: 7} + cyclic.Alias = cyclic + root := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "field"}, cyclic, + }} + _, err = extractScalarString("field", root) + require.Error(t, err) + assert.Contains(t, err.Error(), "cyclic YAML alias") + }) + + t.Run("object map extraction", func(t *testing.T) { + nullValue, err := extractObjectMap[Parameter](context.Background(), ParametersLabel, + mappingNode(t, "parameters: null"), nil) + require.NoError(t, err) + assert.Nil(t, nullValue.Value) + + cyclic := &yaml.Node{Kind: yaml.AliasNode, Line: 5, Column: 9} + cyclic.Alias = cyclic + container := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: ParametersLabel}, cyclic, + }} + _, err = extractObjectMap[Parameter](context.Background(), ParametersLabel, container, nil) + require.Error(t, err) + + memberMap := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "bad"}, cyclic, + }} + container.Content[1] = memberMap + _, err = extractObjectMap[Parameter](context.Background(), ParametersLabel, container, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), `member "bad"`) + }) + + t.Run("raw map extraction", func(t *testing.T) { + nullValue, err := extractRawNodeMap(InputsLabel, mappingNode(t, "inputs: null")) + require.NoError(t, err) + assert.Nil(t, nullValue.Value) + + cyclic := &yaml.Node{Kind: yaml.AliasNode, Line: 6, Column: 11} + cyclic.Alias = cyclic + root := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: InputsLabel}, cyclic, + }} + _, err = extractRawNodeMap(InputsLabel, root) + require.Error(t, err) + assert.Contains(t, err.Error(), "cyclic YAML alias") + }) +} + +func TestSelectorAndPayloadReplacementSurfaceAliasExtractionErrors(t *testing.T) { + for _, test := range []struct { + name string + field string + build func(*yaml.Node) error + }{ + { + name: "selector context", field: ContextLabel, + build: func(root *yaml.Node) error { + return new(Selector).Build(context.Background(), nil, root, nil) + }, + }, + { + name: "selector selector", field: SelectorLabel, + build: func(root *yaml.Node) error { + return new(Selector).Build(context.Background(), nil, root, nil) + }, + }, + { + name: "selector type", field: TypeLabel, + build: func(root *yaml.Node) error { + return new(Selector).Build(context.Background(), nil, root, nil) + }, + }, + { + name: "replacement target", field: TargetLabel, + build: func(root *yaml.Node) error { + return new(PayloadReplacement).Build(context.Background(), nil, root, nil) + }, + }, + { + name: "replacement selector type", field: TargetSelectorTypeLabel, + build: func(root *yaml.Node) error { + return new(PayloadReplacement).Build(context.Background(), nil, root, nil) + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + cyclic := &yaml.Node{Kind: yaml.AliasNode, Line: 8, Column: 13} + cyclic.Alias = cyclic + root := &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: test.field}, cyclic, + }} + err := test.build(root) + require.Error(t, err) + assert.Contains(t, err.Error(), "cyclic YAML alias") + }) + } +} + +// A quoted integer is a well-formed scalar that parses as an integer, but the model +// builder gates on the YAML tag and drops it. requireInteger must gate on the same +// predicate so the value is reported rather than silently lost. +func TestRequireInteger_RejectsQuotedIntegerTheBuilderWouldDrop(t *testing.T) { + err := requireInteger("timeout", "an integer", mappingNode(t, `timeout: "5000"`)) + require.Error(t, err) + assert.Contains(t, err.Error(), `must be an integer, got "5000"`) +} + +// Booleans and other non-integer tags are rejected for the same reason. +func TestRequireInteger_RejectsNonIntegerTaggedScalars(t *testing.T) { + for _, src := range []string{"timeout: true", `timeout: "abc"`, "timeout: 1.5"} { + err := requireInteger("timeout", "an integer", mappingNode(t, src)) + assert.Error(t, err, src) + } +} + +// Hex, octal and underscore-separated literals are tagged !!int by the YAML parser, so +// they pass the tag gate, but the builder parses base 10 and would coerce them to zero. +// Reporting them is better than silently storing the wrong number. +func TestRequireInteger_RejectsIntTaggedValuesTheBuilderWouldCoerceToZero(t *testing.T) { + for _, src := range []string{"timeout: 0x1F", "timeout: 0o17", "timeout: 1_000"} { + err := requireInteger("timeout", "an integer", mappingNode(t, src)) + require.Error(t, err, src) + assert.Contains(t, err.Error(), "must be an integer, got") + } +} + +// A leading plus is a valid base-10 integer and must be accepted. +func TestRequireInteger_AcceptsExplicitlySignedInteger(t *testing.T) { + assert.NoError(t, requireInteger("timeout", "an integer", mappingNode(t, "timeout: +5"))) +} + +// The hash must encode node kind. Without it a mapping and a sequence holding the same +// scalars are indistinguishable, so structurally different values compare as equal. +func TestHashYAMLNode_DistinguishesMappingFromSequence(t *testing.T) { + build := func(src string) uint64 { + root := mappingNode(t, src) + s := new(Selector) + require.NoError(t, low.BuildModel(root, s)) + require.NoError(t, s.Build(context.Background(), nil, root, nil)) + return s.Hash() + } + + mapForm := build("context: $response.body\nselector: $.id\nx-e:\n a: b") + seqForm := build("context: $response.body\nselector: $.id\nx-e:\n - a\n - b") + assert.NotEqual(t, mapForm, seqForm, + "a mapping and a sequence of the same scalars must not hash alike") + + nestedMap := build("context: $c\nselector: $.id\nx-e:\n a:\n b: c") + flatSeq := build("context: $c\nselector: $.id\nx-e:\n - a\n - b\n - c") + assert.NotEqual(t, nestedMap, flatSeq) +} + +// A YAML alias may point back into its own ancestor, which parses without error. Hashing +// must terminate rather than recurse until the stack gives out. +func TestHashYAMLNode_TerminatesOnSelfReferentialAlias(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("context: $c\nselector: $.id\nx-e: &loop [*loop]"), &node)) + + s := new(Selector) + require.NoError(t, low.BuildModel(node.Content[0], s)) + require.NoError(t, s.Build(context.Background(), nil, node.Content[0], nil)) + + done := make(chan uint64, 1) + go func() { done <- s.Hash() }() + select { + case h := <-done: + assert.NotZero(t, h) + case <-time.After(5 * time.Second): + t.Fatal("hashing a self-referential alias did not terminate") + } +} diff --git a/datamodel/low/arazzo/output_value.go b/datamodel/low/arazzo/output_value.go new file mode 100644 index 000000000..f91b297f3 --- /dev/null +++ b/datamodel/low/arazzo/output_value.go @@ -0,0 +1,104 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "fmt" + "hash/maphash" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "go.yaml.in/yaml/v4" +) + +// OutputValue represents the scalar-runtime-expression or Selector Object output union. +// https://spec.openapis.org/arazzo/v1.1.0#workflow-object +type OutputValue struct { + Expression low.NodeReference[string] + Selector low.NodeReference[*Selector] + KeyNode *yaml.Node + RootNode *yaml.Node + index *index.SpecIndex + context context.Context +} + +// GetIndex returns the index attached to the OutputValue. +func (o *OutputValue) GetIndex() *index.SpecIndex { + return o.index +} + +// GetContext returns the context used to build the OutputValue. +func (o *OutputValue) GetContext() context.Context { + return o.context +} + +// GetRootNode returns the scalar or mapping node that defines the output. +func (o *OutputValue) GetRootNode() *yaml.Node { + return o.RootNode +} + +// GetKeyNode returns the output-name key node. +func (o *OutputValue) GetKeyNode() *yaml.Node { + return o.KeyNode +} + +// IsExpression reports whether the output contains a runtime-expression scalar. +func (o *OutputValue) IsExpression() bool { + return o != nil && !o.Expression.IsEmpty() && o.Selector.IsEmpty() +} + +// IsSelector reports whether the output contains a Selector Object. +func (o *OutputValue) IsSelector() bool { + return o != nil && o.Expression.IsEmpty() && !o.Selector.IsEmpty() +} + +// Build parses exactly one permitted output union variant. +func (o *OutputValue) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + o.KeyNode = keyNode + o.RootNode = root + o.index = idx + o.context = ctx + resolved, err := resolveAliasNode(root) + if err != nil { + return err + } + switch resolved.Kind { + case yaml.ScalarNode: + o.Expression = low.NodeReference[string]{ + Value: resolved.Value, + KeyNode: keyNode, + ValueNode: root, + } + case yaml.MappingNode: + selector := new(Selector) + _ = low.BuildModel(resolved, selector) + if err := selector.Build(ctx, keyNode, resolved, idx); err != nil { + return err + } + o.Selector = low.NodeReference[*Selector]{ + Value: selector, + KeyNode: keyNode, + ValueNode: root, + } + default: + return fmt.Errorf("output value at line %d, column %d must be a scalar or Selector Object", + root.Line, root.Column) + } + return nil +} + +// Hash returns a deterministic hash of the active output variant. +func (o *OutputValue) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if o.IsExpression() { + h.WriteString(o.Expression.Value) + h.WriteByte(low.HASH_PIPE) + } + if o.IsSelector() { + low.HashUint64(h, o.Selector.Value.Hash()) + } + return h.Sum64() + }) +} diff --git a/datamodel/low/arazzo/payload_replacement.go b/datamodel/low/arazzo/payload_replacement.go index 2ed85c069..1d5cb8b0b 100644 --- a/datamodel/low/arazzo/payload_replacement.go +++ b/datamodel/low/arazzo/payload_replacement.go @@ -5,6 +5,7 @@ package arazzo import ( "context" + "fmt" "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" @@ -14,15 +15,16 @@ import ( ) // PayloadReplacement represents a low-level Arazzo Payload Replacement Object. -// https://spec.openapis.org/arazzo/v1.0.1#payload-replacement-object +// https://spec.openapis.org/arazzo/v1.1.0#payload-replacement-object type PayloadReplacement struct { - Target low.NodeReference[string] - Value low.NodeReference[*yaml.Node] - Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] - KeyNode *yaml.Node - RootNode *yaml.Node - index *index.SpecIndex - context context.Context + Target low.NodeReference[string] + TargetSelectorType low.NodeReference[*yaml.Node] + Value low.NodeReference[*yaml.Node] + Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] + KeyNode *yaml.Node + RootNode *yaml.Node + index *index.SpecIndex + context context.Context *low.Reference low.NodeMap } @@ -66,7 +68,24 @@ func (p *PayloadReplacement) Build(ctx context.Context, keyNode, root *yaml.Node Context: &p.context, }, ctx, keyNode, root, idx) + var err error + p.Target, err = extractScalarString(TargetLabel, root) + if err != nil { + return err + } p.Value = extractRawNode(ValueLabel, root) + p.TargetSelectorType = extractRawNode(TargetSelectorTypeLabel, root) + if p.TargetSelectorType.IsEmpty() { + return nil + } + resolvedType, err := resolveAliasNode(p.TargetSelectorType.Value) + if err != nil { + return fmt.Errorf("targetSelectorType: %w", err) + } + if resolvedType.Kind != yaml.ScalarNode && resolvedType.Kind != yaml.MappingNode { + return fmt.Errorf("targetSelectorType at line %d, column %d must be a scalar or mapping", + p.TargetSelectorType.Value.Line, p.TargetSelectorType.Value.Column) + } return nil } @@ -82,6 +101,9 @@ func (p *PayloadReplacement) Hash() uint64 { h.WriteString(p.Target.Value) h.WriteByte(low.HASH_PIPE) } + if !p.TargetSelectorType.IsEmpty() { + hashYAMLNode(h, p.TargetSelectorType.Value) + } if !p.Value.IsEmpty() { hashYAMLNode(h, p.Value.Value) } diff --git a/datamodel/low/arazzo/selector.go b/datamodel/low/arazzo/selector.go new file mode 100644 index 000000000..f215a8eaa --- /dev/null +++ b/datamodel/low/arazzo/selector.go @@ -0,0 +1,115 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "context" + "fmt" + "hash/maphash" + + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "go.yaml.in/yaml/v4" +) + +// Selector represents a low-level Arazzo Selector Object. +// https://spec.openapis.org/arazzo/v1.1.0#selector-object +type Selector struct { + Context low.NodeReference[string] + Selector low.NodeReference[string] + Type low.NodeReference[*yaml.Node] + Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] + KeyNode *yaml.Node + RootNode *yaml.Node + index *index.SpecIndex + context context.Context + *low.Reference + low.NodeMap +} + +// GetIndex returns the index attached to the Selector. +func (s *Selector) GetIndex() *index.SpecIndex { + return s.index +} + +// GetContext returns the context used to build the Selector. +func (s *Selector) GetContext() context.Context { + return s.context +} + +// FindExtension returns a Selector extension when present. +func (s *Selector) FindExtension(ext string) *low.ValueReference[*yaml.Node] { + return low.FindItemInOrderedMap(ext, s.Extensions) +} + +// GetRootNode returns the Selector mapping node. +func (s *Selector) GetRootNode() *yaml.Node { + return s.RootNode +} + +// GetKeyNode returns the key node associated with the Selector. +func (s *Selector) GetKeyNode() *yaml.Node { + return s.KeyNode +} + +// Build extracts the Selector fields while preserving its scalar-or-object type union. +func (s *Selector) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.SpecIndex) error { + root = initBuild(&arazzoBase{ + KeyNode: &s.KeyNode, + RootNode: &s.RootNode, + Reference: &s.Reference, + NodeMap: &s.NodeMap, + Extensions: &s.Extensions, + Index: &s.index, + Context: &s.context, + }, ctx, keyNode, root, idx) + + var err error + s.Context, err = extractScalarString(ContextLabel, root) + if err != nil { + return err + } + s.Selector, err = extractScalarString(SelectorLabel, root) + if err != nil { + return err + } + s.Type = extractRawNode(TypeLabel, root) + if s.Type.IsEmpty() { + return nil + } + resolvedType, err := resolveAliasNode(s.Type.Value) + if err != nil { + return fmt.Errorf("selector type: %w", err) + } + if resolvedType.Kind != yaml.ScalarNode && resolvedType.Kind != yaml.MappingNode { + return fmt.Errorf("selector type at line %d, column %d must be a scalar or mapping", + s.Type.Value.Line, s.Type.Value.Column) + } + return nil +} + +// GetExtensions returns all Selector extensions. +func (s *Selector) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] { + return s.Extensions +} + +// Hash returns a deterministic hash of every Selector field. +func (s *Selector) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !s.Context.IsEmpty() { + h.WriteString(s.Context.Value) + h.WriteByte(low.HASH_PIPE) + } + if !s.Selector.IsEmpty() { + h.WriteString(s.Selector.Value) + h.WriteByte(low.HASH_PIPE) + } + if !s.Type.IsEmpty() { + hashYAMLNode(h, s.Type.Value) + } + hashExtensionsInto(h, s.Extensions) + return h.Sum64() + }) +} diff --git a/datamodel/low/arazzo/step.go b/datamodel/low/arazzo/step.go index 43ea94125..02ec43d9d 100644 --- a/datamodel/low/arazzo/step.go +++ b/datamodel/low/arazzo/step.go @@ -14,19 +14,24 @@ import ( ) // Step represents a low-level Arazzo Step Object. -// https://spec.openapis.org/arazzo/v1.0.1#step-object +// https://spec.openapis.org/arazzo/v1.1.0#step-object type Step struct { StepId low.NodeReference[string] Description low.NodeReference[string] OperationId low.NodeReference[string] OperationPath low.NodeReference[string] + ChannelPath low.NodeReference[string] + Action low.NodeReference[string] WorkflowId low.NodeReference[string] + CorrelationId low.NodeReference[string] + Timeout low.NodeReference[int64] + DependsOn low.NodeReference[[]low.ValueReference[string]] Parameters low.NodeReference[[]low.ValueReference[*Parameter]] RequestBody low.NodeReference[*RequestBody] SuccessCriteria low.NodeReference[[]low.ValueReference[*Criterion]] OnSuccess low.NodeReference[[]low.ValueReference[*SuccessAction]] OnFailure low.NodeReference[[]low.ValueReference[*FailureAction]] - Outputs low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[string]]] + Outputs low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*OutputValue]]] Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] KeyNode *yaml.Node RootNode *yaml.Node @@ -79,6 +84,30 @@ func (s *Step) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.S Context: &s.context, }, ctx, keyNode, root, idx) + // Reject a wrong node kind on the Arazzo 1.1 additions rather than silently + // dropping the value; see requireNodeKind. + if err := requireScalar(ChannelPathLabel, "a scalar channel reference", root); err != nil { + return err + } + if err := requireScalar(ActionLabel, "a scalar action name", root); err != nil { + return err + } + if err := requireScalar(CorrelationIdLabel, "a scalar string", root); err != nil { + return err + } + if err := requireInteger(TimeoutLabel, "an integer number of milliseconds", root); err != nil { + return err + } + if err := requireSequence(DependsOnLabel, "a sequence of step identifiers", root); err != nil { + return err + } + + dependsOn, err := extractStringArray(DependsOnLabel, root) + if err != nil { + return err + } + s.DependsOn = dependsOn + params, err := extractStepParameters(ctx, ParametersLabel, root, idx) if err != nil { return err @@ -109,7 +138,11 @@ func (s *Step) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.S } s.OnFailure = onFailure - s.Outputs = extractExpressionsMap(OutputsLabel, root) + outputs, err := extractOutputValuesMap(ctx, OutputsLabel, root, idx) + if err != nil { + return err + } + s.Outputs = outputs return nil } @@ -138,10 +171,31 @@ func (s *Step) Hash() uint64 { h.WriteString(s.OperationPath.Value) h.WriteByte(low.HASH_PIPE) } + if !s.ChannelPath.IsEmpty() { + h.WriteString(s.ChannelPath.Value) + h.WriteByte(low.HASH_PIPE) + } + if !s.Action.IsEmpty() { + h.WriteString(s.Action.Value) + h.WriteByte(low.HASH_PIPE) + } if !s.WorkflowId.IsEmpty() { h.WriteString(s.WorkflowId.Value) h.WriteByte(low.HASH_PIPE) } + if !s.CorrelationId.IsEmpty() { + h.WriteString(s.CorrelationId.Value) + h.WriteByte(low.HASH_PIPE) + } + if !s.Timeout.IsEmpty() { + low.HashInt64(h, s.Timeout.Value) + } + if !s.DependsOn.IsEmpty() { + for _, dependency := range s.DependsOn.Value { + h.WriteString(dependency.Value) + h.WriteByte(low.HASH_PIPE) + } + } if !s.Parameters.IsEmpty() { for _, p := range s.Parameters.Value { low.HashUint64(h, p.Value.Hash()) @@ -169,8 +223,7 @@ func (s *Step) Hash() uint64 { for pair := s.Outputs.Value.First(); pair != nil; pair = pair.Next() { h.WriteString(pair.Key().Value) h.WriteByte(low.HASH_PIPE) - h.WriteString(pair.Value().Value) - h.WriteByte(low.HASH_PIPE) + low.HashUint64(h, pair.Value().Value.Hash()) } } hashExtensionsInto(h, s.Extensions) diff --git a/datamodel/low/arazzo/success_action.go b/datamodel/low/arazzo/success_action.go index 093bfce8e..3c5286ccd 100644 --- a/datamodel/low/arazzo/success_action.go +++ b/datamodel/low/arazzo/success_action.go @@ -15,24 +15,26 @@ import ( // SuccessAction represents a low-level Arazzo Success Action Object. // A success action can be a full definition or a Reusable Object with a $components reference. -// https://spec.openapis.org/arazzo/v1.0.1#success-action-object +// https://spec.openapis.org/arazzo/v1.1.0#success-action-object type SuccessAction struct { - Name low.NodeReference[string] - Type low.NodeReference[string] - WorkflowId low.NodeReference[string] - StepId low.NodeReference[string] - Criteria low.NodeReference[[]low.ValueReference[*Criterion]] + Name low.NodeReference[string] + Type low.NodeReference[string] + WorkflowId low.NodeReference[string] + StepId low.NodeReference[string] + Criteria low.NodeReference[[]low.ValueReference[*Criterion]] + Parameters low.NodeReference[[]low.ValueReference[*Parameter]] ComponentRef low.NodeReference[string] - Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] - KeyNode *yaml.Node - RootNode *yaml.Node - index *index.SpecIndex - context context.Context + Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] + KeyNode *yaml.Node + RootNode *yaml.Node + index *index.SpecIndex + context context.Context *low.Reference low.NodeMap } var extractSuccessActionCriteria = extractArray[Criterion] +var extractSuccessActionParameters = extractArray[Parameter] // IsReusable returns true if this success action is a Reusable Object (has a reference field). func (s *SuccessAction) IsReusable() bool { @@ -86,6 +88,15 @@ func (s *SuccessAction) Build(ctx context.Context, keyNode, root *yaml.Node, idx return err } s.Criteria = criteria + + if err := requireSequence(ParametersLabel, "a sequence of Parameter Objects", root); err != nil { + return err + } + parameters, err := extractSuccessActionParameters(ctx, ParametersLabel, root, idx) + if err != nil { + return err + } + s.Parameters = parameters return nil } @@ -122,6 +133,11 @@ func (s *SuccessAction) Hash() uint64 { low.HashUint64(h, c.Value.Hash()) } } + if !s.Parameters.IsEmpty() { + for _, parameter := range s.Parameters.Value { + low.HashUint64(h, parameter.Value.Hash()) + } + } hashExtensionsInto(h, s.Extensions) return h.Sum64() }) diff --git a/datamodel/low/arazzo/workflow.go b/datamodel/low/arazzo/workflow.go index c2efff229..5db7a64b6 100644 --- a/datamodel/low/arazzo/workflow.go +++ b/datamodel/low/arazzo/workflow.go @@ -14,7 +14,7 @@ import ( ) // Workflow represents a low-level Arazzo Workflow Object. -// https://spec.openapis.org/arazzo/v1.0.1#workflow-object +// https://spec.openapis.org/arazzo/v1.1.0#workflow-object type Workflow struct { WorkflowId low.NodeReference[string] Summary low.NodeReference[string] @@ -24,7 +24,7 @@ type Workflow struct { Steps low.NodeReference[[]low.ValueReference[*Step]] SuccessActions low.NodeReference[[]low.ValueReference[*SuccessAction]] FailureActions low.NodeReference[[]low.ValueReference[*FailureAction]] - Outputs low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[string]]] + Outputs low.NodeReference[*orderedmap.Map[low.KeyReference[string], low.ValueReference[*OutputValue]]] Parameters low.NodeReference[[]low.ValueReference[*Parameter]] Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] KeyNode *yaml.Node @@ -78,7 +78,11 @@ func (w *Workflow) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind }, ctx, keyNode, root, idx) w.Inputs = extractRawNode(InputsLabel, root) // raw node: JSON Schema - w.DependsOn = extractStringArray(DependsOnLabel, root) + dependsOn, err := extractStringArray(DependsOnLabel, root) + if err != nil { + return err + } + w.DependsOn = dependsOn steps, err := extractArray[Step](ctx, StepsLabel, root, idx) if err != nil { @@ -98,7 +102,11 @@ func (w *Workflow) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind } w.FailureActions = failureActions - w.Outputs = extractExpressionsMap(OutputsLabel, root) + outputs, err := extractOutputValuesMap(ctx, OutputsLabel, root, idx) + if err != nil { + return err + } + w.Outputs = outputs params, err := extractWorkflowParameters(ctx, ParametersLabel, root, idx) if err != nil { @@ -157,8 +165,7 @@ func (w *Workflow) Hash() uint64 { for pair := w.Outputs.Value.First(); pair != nil; pair = pair.Next() { h.WriteString(pair.Key().Value) h.WriteByte(low.HASH_PIPE) - h.WriteString(pair.Value().Value) - h.WriteByte(low.HASH_PIPE) + low.HashUint64(h, pair.Value().Value.Hash()) } } if !w.Parameters.IsEmpty() { diff --git a/datamodel/low/base/circ_check.go b/datamodel/low/base/circ_check.go index c72af1e5b..3f425224c 100644 --- a/datamodel/low/base/circ_check.go +++ b/datamodel/low/base/circ_check.go @@ -3,6 +3,8 @@ package base +import "slices" + // CheckSchemaProxyForCircularRefs checks if the provided SchemaProxy has any circular references, extracted from // The rolodex attached to the index. func CheckSchemaProxyForCircularRefs(s *SchemaProxy) bool { @@ -13,7 +15,11 @@ func CheckSchemaProxyForCircularRefs(s *SchemaProxy) bool { allCircs := rolo.GetRootIndex().GetCircularReferences() safeCircularRefs := rolo.GetSafeCircularReferences() ignoredCircularRefs := rolo.GetIgnoredCircularReferences() - combinedCircularRefs := append(safeCircularRefs, ignoredCircularRefs...) + // these getters hand back the index and rolodex's own slices, which carry spare capacity from + // the appends the resolver builds them with. clone before extending, or this writes into memory + // shared with every other reader. + combinedCircularRefs := slices.Clone(safeCircularRefs) + combinedCircularRefs = append(combinedCircularRefs, ignoredCircularRefs...) combinedCircularRefs = append(combinedCircularRefs, allCircs...) dup := make(map[string]struct{}) for _, ref := range combinedCircularRefs { diff --git a/datamodel/low/base/resolve_exclusive_test.go b/datamodel/low/base/resolve_exclusive_test.go new file mode 100644 index 000000000..3b3d1539c --- /dev/null +++ b/datamodel/low/base/resolve_exclusive_test.go @@ -0,0 +1,95 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package base + +import ( + "testing" + + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +// resolveExclusive decides how to read exclusiveMinimum/exclusiveMaximum, which mean different +// things per version: 3.0 treats the keyword as a boolean modifier on minimum/maximum, 3.1 +// treats it as the bound itself. These cases exercise the paths no document fixture reaches, +// because a real parser only ever produces 2.0, 3.0 or 3.1 as a version. +func TestResolveExclusive_KnownVersions(t *testing.T) { + scalar := func(tag, value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value} + } + + t.Run("nil value node yields nothing", func(t *testing.T) { + assert.Nil(t, resolveExclusive(3.1, true, nil)) + }) + + t.Run("3.1 reads the value as the bound", func(t *testing.T) { + got := resolveExclusive(3.1, true, scalar("!!int", "5")) + require.NotNil(t, got) + assert.Equal(t, 1, got.N) + assert.Equal(t, float64(5), got.B) + }) + + t.Run("3.0 reads the value as a boolean modifier", func(t *testing.T) { + got := resolveExclusive(3.0, true, scalar("!!bool", "true")) + require.NotNil(t, got) + assert.Equal(t, 0, got.N) + assert.True(t, got.A) + }) + + // A value written in the other version's form is deliberately coerced rather than rejected: + // the parse error is discarded, so a 3.0 boolean under 3.1 becomes the bound 0. + t.Run("3.1 coerces a boolean to a zero bound", func(t *testing.T) { + got := resolveExclusive(3.1, true, scalar("!!bool", "true")) + require.NotNil(t, got) + assert.Equal(t, 1, got.N) + assert.Equal(t, float64(0), got.B) + }) + + t.Run("3.0 coerces a number to false", func(t *testing.T) { + got := resolveExclusive(3.0, true, scalar("!!int", "5")) + require.NotNil(t, got) + assert.Equal(t, 0, got.N) + assert.False(t, got.A) + }) + + // A version strictly between 3.0 and 3.1 matches neither form. No parser emits one, but the + // value arrives from SpecInfo, so the function must not guess. + t.Run("a version between 3.0 and 3.1 yields nothing", func(t *testing.T) { + assert.Nil(t, resolveExclusive(3.05, true, scalar("!!int", "5"))) + }) +} + +// With no version to coerce towards, the shape of the value decides: a boolean can only be the +// 3.0 form, a number can only be the 3.1 form, and anything else is left absent rather than +// recorded as a bound of zero the author never wrote. +func TestResolveExclusive_UnknownVersion(t *testing.T) { + scalar := func(tag, value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value} + } + + t.Run("boolean is read as the 3.0 form", func(t *testing.T) { + got := resolveExclusive(0, false, scalar("!!bool", "true")) + require.NotNil(t, got) + assert.Equal(t, 0, got.N) + assert.True(t, got.A) + }) + + t.Run("number is read as the 3.1 form", func(t *testing.T) { + got := resolveExclusive(0, false, scalar("!!float", "2.5")) + require.NotNil(t, got) + assert.Equal(t, 1, got.N) + assert.Equal(t, 2.5, got.B) + }) + + // Tagged as a boolean but not parseable as one. The YAML 1.2 core schema will not produce + // this, but a hand-built or foreign-tagged node can, and it must not be recorded. + t.Run("unparseable boolean yields nothing", func(t *testing.T) { + assert.Nil(t, resolveExclusive(0, false, scalar("!!bool", "yes"))) + }) + + t.Run("non-numeric value yields nothing", func(t *testing.T) { + assert.Nil(t, resolveExclusive(0, false, scalar("!!str", "not-a-number"))) + }) +} diff --git a/datamodel/low/base/schema.go b/datamodel/low/base/schema.go index 4b18f6683..36074ac30 100644 --- a/datamodel/low/base/schema.go +++ b/datamodel/low/base/schema.go @@ -138,10 +138,28 @@ type Schema struct { // Parent Proxy refers back to the low level SchemaProxy that is proxying this schema. ParentProxy *SchemaProxy - // Index is a reference to the SpecIndex that was used to build this schema. - Index *index.SpecIndex - RootNode *yaml.Node - index *index.SpecIndex + // Index is a reference to the SpecIndex that owns the resolved content of this schema. When the + // schema was reached through a reference, this is the file the reference points at, so the index + // agrees with the line and column of the schema's own keyword fields. + // + // Two things do not follow it, both only for a component level schema built straight from a $ref + // node, where the reference resolves during Build rather than before it. RootNode stays the + // authored $ref node in the referring file. Nodes holds the referring file's node for the $ref + // itself alongside the resolved file's nodes for everything under it, so it spans both. + // + // A child schema (a property, an allOf member, and so on) has its reference resolved before the + // schema is built, so none of that applies and everything names one file. + Index *index.SpecIndex + RootNode *yaml.Node + index *index.SpecIndex + + // refIndex is the index of the file RootNode actually lives in. It equals Index except + // for a component level schema built from a $ref node, where Index is re-attributed to + // the resolved file while RootNode stays behind in the referring one. Anything keyed on + // RootNode's position must pair it with this index, not Index, or the pair names two + // different files and stops identifying the node uniquely. + refIndex *index.SpecIndex + context context.Context nodeStore sync.Map reference low.Reference diff --git a/datamodel/low/base/schema_build.go b/datamodel/low/base/schema_build.go index 597763a73..86526a601 100644 --- a/datamodel/low/base/schema_build.go +++ b/datamodel/low/base/schema_build.go @@ -60,6 +60,8 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde s.RootNode = root s.context = ctx s.index = idx + // Captured before any reference swap below, so it keeps naming the file RootNode is in. + s.refIndex = idx isTransformed := false if s.ParentProxy != nil && s.ParentProxy.TransformedRef != nil { @@ -68,17 +70,28 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde if !isTransformed { if h, _, _ := utils.IsNodeRefValue(root); h { - ref, _, err, fctx := low.LocateRefNodeWithContext(ctx, root, idx) + ref, foundIdx, err, fctx := low.LocateRefNodeWithContext(ctx, root, idx) if ref != nil { root = ref if fctx != nil { ctx = fctx + s.context = ctx } if err != nil { + // circular policy is read from the referring index, before the swap below. if !idx.AllowCircularReferenceResolving() { return fmt.Errorf("build schema failed: %s", err.Error()) } } + // re-attribute this schema to the index that owns the resolved nodes. everything built + // from here down (properties, allOf, items, and every other child) inherits it, so the + // index and the line/column of the content it describes finally agree on one file. + // RootNode is deliberately left alone: it stays the authored $ref node. + if foundIdx != nil { + idx = foundIdx + s.Index = idx + s.index = idx + } } else { return fmt.Errorf("build schema failed: reference cannot be found: '%s', line %d, col %d", root.Content[1].Value, root.Content[1].Line, root.Content[1].Column) @@ -130,79 +143,24 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde } _, exMinLabel, exMinValue := utils.FindKeyNodeFullTop(ExclusiveMinimumLabel, root.Content) - if exMinValue != nil { - if idx != nil { - if idx.GetConfig().SpecInfo.VersionNumeric >= 3.1 { - val, _ := strconv.ParseFloat(exMinValue.Value, 64) - s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMinLabel, - ValueNode: exMinValue, - Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, - } - } - if idx.GetConfig().SpecInfo.VersionNumeric <= 3.0 { - val, _ := strconv.ParseBool(exMinValue.Value) - s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMinLabel, - ValueNode: exMinValue, - Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, - } - } - } else { - if utils.IsNodeBoolValue(exMinValue) { - val, _ := strconv.ParseBool(exMinValue.Value) - s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMinLabel, - ValueNode: exMinValue, - Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, - } - } - if utils.IsNodeIntValue(exMinValue) { - val, _ := strconv.ParseFloat(exMinValue.Value, 64) - s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMinLabel, - ValueNode: exMinValue, - Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, - } - } - } - } - _, exMaxLabel, exMaxValue := utils.FindKeyNodeFullTop(ExclusiveMaximumLabel, root.Content) - if exMaxValue != nil { - if idx != nil { - if idx.GetConfig().SpecInfo.VersionNumeric >= 3.1 { - val, _ := strconv.ParseFloat(exMaxValue.Value, 64) - s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMaxLabel, - ValueNode: exMaxValue, - Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, - } - } - if idx.GetConfig().SpecInfo.VersionNumeric <= 3.0 { - val, _ := strconv.ParseBool(exMaxValue.Value) - s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMaxLabel, - ValueNode: exMaxValue, - Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, - } + if exMinValue != nil || exMaxValue != nil { + // the version is resolved from the document, not from idx directly. idx may have been swapped + // to an external file during reference resolution, and a bare schema fragment has no version. + docVersion, versionKnown := idx.ResolveDocumentVersion() + + if value := resolveExclusive(docVersion, versionKnown, exMinValue); value != nil { + s.ExclusiveMinimum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMinLabel, + ValueNode: exMinValue, + Value: value, } - } else { - if utils.IsNodeBoolValue(exMaxValue) { - val, _ := strconv.ParseBool(exMaxValue.Value) - s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMaxLabel, - ValueNode: exMaxValue, - Value: &SchemaDynamicValue[bool, float64]{N: 0, A: val}, - } - } - if utils.IsNodeIntValue(exMaxValue) { - val, _ := strconv.ParseFloat(exMaxValue.Value, 64) - s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ - KeyNode: exMaxLabel, - ValueNode: exMaxValue, - Value: &SchemaDynamicValue[bool, float64]{N: 1, B: val}, - } + } + if value := resolveExclusive(docVersion, versionKnown, exMaxValue); value != nil { + s.ExclusiveMaximum = low.NodeReference[*SchemaDynamicValue[bool, float64]]{ + KeyNode: exMaxLabel, + ValueNode: exMaxValue, + Value: value, } } } diff --git a/datamodel/low/base/schema_build_helpers.go b/datamodel/low/base/schema_build_helpers.go index 03328453f..69418c70c 100644 --- a/datamodel/low/base/schema_build_helpers.go +++ b/datamodel/low/base/schema_build_helpers.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strconv" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -101,6 +102,49 @@ func (s *Schema) extractExtensions(root *yaml.Node) { s.Extensions = low.ExtractExtensions(root) } +// resolveExclusive reads an exclusiveMinimum or exclusiveMaximum value. 3.0 reads the keyword as a +// boolean modifier on minimum/maximum, 3.1 reads it as the bound itself, so the document version +// decides the shape. nil means there is nothing to record and the keyword stays absent. +// +// When the version is known the parse error is deliberately ignored, which is what coerces a value +// written in the other version's form into the one this document uses: a 3.0 style boolean under +// 3.1 becomes the bound 0, and a 3.1 style bound under 3.0 becomes false. +// +// When the version is unknown there is nothing to coerce towards, so a boolean is read as the 3.0 +// form because it cannot be a bound, anything numeric is read as 3.1, and anything else is left +// absent rather than recorded as a bound of zero the author never wrote. +func resolveExclusive(docVersion float32, versionKnown bool, valueNode *yaml.Node) *SchemaDynamicValue[bool, float64] { + if valueNode == nil { + return nil + } + + if versionKnown { + if docVersion >= 3.1 { + parsed, _ := strconv.ParseFloat(valueNode.Value, 64) + return &SchemaDynamicValue[bool, float64]{N: 1, B: parsed} + } + if docVersion <= 3.0 { + parsed, _ := strconv.ParseBool(valueNode.Value) + return &SchemaDynamicValue[bool, float64]{N: 0, A: parsed} + } + return nil + } + + if utils.IsNodeBoolValue(valueNode) { + parsed, err := strconv.ParseBool(valueNode.Value) + if err != nil { + return nil + } + return &SchemaDynamicValue[bool, float64]{N: 0, A: parsed} + } + + parsed, err := strconv.ParseFloat(valueNode.Value, 64) + if err != nil { + return nil + } + return &SchemaDynamicValue[bool, float64]{N: 1, B: parsed} +} + // buildSchemaProxy builds out a SchemaProxy for a single node. func buildSchemaProxy(ctx context.Context, idx *index.SpecIndex, kn, vn, scopeNode, rf *yaml.Node, transformed *transformedSiblingRef, refLocation string) low.ValueReference[*SchemaProxy] { sp := new(SchemaProxy) diff --git a/datamodel/low/base/schema_hash.go b/datamodel/low/base/schema_hash.go index 34064b2c7..8b0883cf1 100644 --- a/datamodel/low/base/schema_hash.go +++ b/datamodel/low/base/schema_hash.go @@ -520,7 +520,14 @@ func writeSchemaExtensions(sb *strings.Builder, ext *orderedmap.Map[low.KeyRefer } func (s *Schema) quickHashKey() string { - idx := s.GetIndex() + // The key identifies a node by file plus position, so the path must come from the file + // RootNode is in. GetIndex() may have been re-attributed to the file a $ref resolves to, + // which would pair a resolved-file path with a referring-file line and column: two + // different $refs at the same position in different files pointing into one shared file + // would then collide and return each other's hash. + // refIndex is assigned unconditionally in Build alongside Index, so it is nil only when + // the schema was never built, in which case Index is nil too and there is no path either way. + idx := s.refIndex path := "" if idx != nil { path = idx.GetSpecAbsolutePath() diff --git a/datamodel/low/base/schema_proxy.go b/datamodel/low/base/schema_proxy.go index 17f1fbeda..be246c3a7 100644 --- a/datamodel/low/base/schema_proxy.go +++ b/datamodel/low/base/schema_proxy.go @@ -404,6 +404,12 @@ func (sp *SchemaProxy) AddNode(key int, node *yaml.Node) { } // GetIndex will return the index.SpecIndex pointer that was passed to the SchemaProxy during build. +// This is the index owning the proxy's own nodes (the key node and the value node). +// +// For a child proxy (a property, an allOf member, and so on) the reference is resolved before the +// proxy is built, so this is already the referenced file. For a component level proxy holding an +// unresolved $ref node it is the file the $ref was written in, not the file it points at, and +// Schema().GetIndex() is the one that names the file the content came from. func (sp *SchemaProxy) GetIndex() *index.SpecIndex { return sp.idx } diff --git a/datamodel/low/v3/paths.go b/datamodel/low/v3/paths.go index d8828dd4f..148242838 100644 --- a/datamodel/low/v3/paths.go +++ b/datamodel/low/v3/paths.go @@ -182,18 +182,25 @@ func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIn cNode := value.currentNode foundContext := ctx + // pathIdx is a closure local on purpose. idx is captured and shared by every goroutine + // this slice is translated across, so it must never be assigned to. + pathIdx := idx var isRef bool var refNode *yaml.Node if ok, _, _ := utils.IsNodeRefValue(pNode); ok { isRef = true refNode = pNode - r, _, err, fCtx := low.LocateRefNodeWithContext(ctx, pNode, idx) + r, fIdx, err, fCtx := low.LocateRefNodeWithContext(ctx, pNode, idx) if r != nil { pNode = r foundContext = fCtx if err != nil && !idx.AllowCircularReferenceResolving() { return buildResult{}, fmt.Errorf("path item build failed: %s", err.Error()) } + // attribute the path item to the file that owns the resolved node. + if fIdx != nil { + pathIdx = fIdx + } } else { return buildResult{}, fmt.Errorf("path item build failed: cannot find reference: '%s' at line %d, col %d", pNode.Content[1].Value, pNode.Content[1].Line, pNode.Content[1].Column) @@ -202,7 +209,7 @@ func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIn path := new(PathItem) _ = low.BuildModel(pNode, path) - err := path.Build(foundContext, cNode, pNode, idx) + err := path.Build(foundContext, cNode, pNode, pathIdx) if isRef { path.SetReference(refNode.Content[1].Value, refNode) diff --git a/datamodel/low/v3/schema_index_attribution_test.go b/datamodel/low/v3/schema_index_attribution_test.go new file mode 100644 index 000000000..74ac716b9 --- /dev/null +++ b/datamodel/low/v3/schema_index_attribution_test.go @@ -0,0 +1,544 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package v3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/libopenapi/datamodel/low" + lowbase "github.com/pb33f/libopenapi/datamodel/low/base" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +const attributionSpecDir = "../../../test_specs/index_attribution" + +func buildAttributionDoc(t *testing.T, file string) *Document { + t.Helper() + + specPath := filepath.Join(attributionSpecDir, file) + data, err := os.ReadFile(specPath) + require.NoError(t, err) + + info, err := datamodel.ExtractSpecInfo(data) + require.NoError(t, err) + + doc, err := CreateDocumentFromConfig(info, &datamodel.DocumentConfiguration{ + BasePath: attributionSpecDir, + AllowFileReferences: true, + AllowRemoteReferences: false, + IgnorePolymorphicCircularReferences: true, + IgnoreArrayCircularReferences: true, + }) + require.NoError(t, err) + return doc +} + +func attributionSchema(t *testing.T, doc *Document, name string) *lowbase.Schema { + t.Helper() + + found := doc.Components.Value.FindSchema(name) + require.NotNilf(t, found, "component schema %s not found", name) + schema := found.Value.Schema() + require.NotNilf(t, schema, "component schema %s did not build", name) + return schema +} + +// assertOwnedBy is the regression invariant. The index attached to a schema must name the file that +// physically holds the schema's nodes, otherwise line and column numbers cannot be trusted to be +// unique when paired with the index. +func assertOwnedBy(t *testing.T, schema *lowbase.Schema, wantFile string) { + t.Helper() + + require.NotNil(t, schema.GetIndex(), "schema has no index") + assert.Same(t, schema.Index, schema.GetIndex(), "Index and index must never diverge") + assert.Truef(t, strings.HasSuffix(schema.GetIndex().GetSpecAbsolutePath(), wantFile), + "expected schema to be owned by %s, got %s", wantFile, schema.GetIndex().GetSpecAbsolutePath()) +} + +// assertNodeOriginAgrees is the property doctor depends on: the index and the rolodex agree on which +// file a node came from, so (index, line, column) is a unique identity across a multi file document. +func assertNodeOriginAgrees(t *testing.T, schema *lowbase.Schema, node *yaml.Node) { + t.Helper() + + if node == nil { + return + } + rolodex := schema.GetIndex().GetRolodex() + require.NotNil(t, rolodex) + + origin := rolodex.FindNodeOrigin(node) + require.NotNil(t, origin, "no origin found for node at %d:%d", node.Line, node.Column) + assert.Equal(t, origin.AbsoluteLocation, schema.GetIndex().GetSpecAbsolutePath(), + "node origin and schema index disagree on the owning file") +} + +func TestSchemaIndexAttribution_ExternalRefAndChildren(t *testing.T) { + doc := buildAttributionDoc(t, "root.yaml") + direct := attributionSchema(t, doc, "Direct") + + // the schema reached through the external reference is owned by the file it points at. + assertOwnedBy(t, direct, "level1.yaml") + assertNodeOriginAgrees(t, direct, direct.Type.ValueNode) + + // every inline property below the reference inherits the same owning file. this is the + // primary regression: before the fix these reported the referring file, so two identical + // external files collided on (index, line, column). + t.Run("properties", func(t *testing.T) { + require.NotNil(t, direct.Properties.Value) + count := 0 + for pair := direct.Properties.Value.First(); pair != nil; pair = pair.Next() { + property := pair.Value().Value.Schema() + require.NotNil(t, property) + count++ + // 'deep' references a third file and is covered by the chain test below. + if pair.Key().Value == "deep" { + continue + } + assertOwnedBy(t, property, "level1.yaml") + assertNodeOriginAgrees(t, property, property.Type.ValueNode) + } + assert.Equal(t, 3, count) + }) + + // one case per child building call site in schema_build.go, all of which receive the index + // that Build resolved. + singles := map[string]*lowbase.Schema{ + "not": safeSchema(direct.Not.Value), + "if": safeSchema(direct.If.Value), + "then": safeSchema(direct.Then.Value), + "else": safeSchema(direct.Else.Value), + "items": safeDynamicSchema(direct.Items.Value), + "contains": safeSchema(direct.Contains.Value), + "propertyNames": safeSchema(direct.PropertyNames.Value), + "unevaluatedItems": safeSchema(direct.UnevaluatedItems.Value), + "unevaluatedProperties": safeDynamicSchema(direct.UnevaluatedProperties.Value), + "additionalProperties": safeDynamicSchema(direct.AdditionalProperties.Value), + "contentSchema": safeSchema(direct.ContentSchema.Value), + } + for label, schema := range singles { + t.Run(label, func(t *testing.T) { + require.NotNilf(t, schema, "%s did not build", label) + assertOwnedBy(t, schema, "level1.yaml") + }) + } + + lists := map[string][]*lowbase.Schema{ + "allOf": collectSchemas(direct.AllOf.Value), + "anyOf": collectSchemas(direct.AnyOf.Value), + "oneOf": collectSchemas(direct.OneOf.Value), + "prefixItems": collectSchemas(direct.PrefixItems.Value), + } + for label, schemas := range lists { + t.Run(label, func(t *testing.T) { + require.NotEmptyf(t, schemas, "%s did not build", label) + for _, schema := range schemas { + assertOwnedBy(t, schema, "level1.yaml") + } + }) + } + + maps := map[string]*lowbase.Schema{ + "patternProperties": firstMapSchema(direct.PatternProperties.Value), + "dependentSchemas": firstMapSchema(direct.DependentSchemas.Value), + "$defs": firstMapSchema(direct.Defs.Value), + } + for label, schema := range maps { + t.Run(label, func(t *testing.T) { + require.NotNilf(t, schema, "%s did not build", label) + assertOwnedBy(t, schema, "level1.yaml") + }) + } + + t.Run("externalDocs and xml still build", func(t *testing.T) { + require.NotNil(t, direct.ExternalDocs.Value) + assert.Equal(t, "https://pb33f.io", direct.ExternalDocs.Value.URL.Value) + require.NotNil(t, direct.XML.Value) + assert.Equal(t, "l1", direct.XML.Value.Name.Value) + }) +} + +// attributionDeepSchema walks root.yaml -> level1.yaml -> level2.yaml. The hop into level2 is a +// property reference, which resolves through schema_build_helpers rather than through Build, so it +// was already attributed correctly before this fix. +func attributionDeepSchema(t *testing.T, doc *Document) *lowbase.Schema { + t.Helper() + + direct := attributionSchema(t, doc, "Direct") + require.NotNil(t, direct.Properties.Value) + + for pair := direct.Properties.Value.First(); pair != nil; pair = pair.Next() { + if pair.Key().Value != "deep" { + continue + } + deep := pair.Value().Value.Schema() + require.NotNil(t, deep) + return deep + } + require.FailNow(t, "deep property not found on L1") + return nil +} + +// TestSchemaIndexAttribution_ThreeFileChain is a lock-in rather than a regression test. Property +// level references were always attributed correctly, and this pins that the swap in Build did not +// disturb them while carrying attribution across a third file. +func TestSchemaIndexAttribution_ThreeFileChain(t *testing.T) { + doc := buildAttributionDoc(t, "root.yaml") + deep := attributionDeepSchema(t, doc) + + // the schema lands on the file that actually holds it, two files away from the root. + assertOwnedBy(t, deep, "level2.yaml") + + require.NotNil(t, deep.Properties.Value) + require.Equal(t, 1, deep.Properties.Value.Len()) + for pair := deep.Properties.Value.First(); pair != nil; pair = pair.Next() { + property := pair.Value().Value.Schema() + require.NotNil(t, property) + assertOwnedBy(t, property, "level2.yaml") + } +} + +// TestSchemaIndexAttribution_ExclusiveMinimumSurvivesSwap guards the silent corruption that a naive +// index swap introduces. level2.yaml has no openapi key, so its own SpecInfo reports version 0. If +// the version were read from the swapped index, exclusiveMinimum would be parsed with 3.0 boolean +// semantics and the numeric 3 would be lost, and no other test in this repo would notice. +func TestSchemaIndexAttribution_ExclusiveMinimumSurvivesSwap(t *testing.T) { + doc := buildAttributionDoc(t, "root.yaml") + deep := attributionDeepSchema(t, doc) + + assertOwnedBy(t, deep, "level2.yaml") + + require.NotNil(t, deep.GetIndex().GetConfig()) + require.NotNil(t, deep.GetIndex().GetConfig().SpecInfo) + assert.Zero(t, deep.GetIndex().GetConfig().SpecInfo.VersionNumeric, + "fixture must keep level2.yaml versionless for this test to mean anything") + + require.NotNil(t, deep.ExclusiveMinimum.Value) + assert.Equal(t, 1, deep.ExclusiveMinimum.Value.N, "expected 3.1 numeric form") + assert.Equal(t, float64(3), deep.ExclusiveMinimum.Value.B) + assert.False(t, deep.ExclusiveMinimum.Value.A) +} + +func TestSchemaIndexAttribution_ExclusiveMinimumBooleanUnder30(t *testing.T) { + doc := buildAttributionDoc(t, "root_30.yaml") + ext := attributionSchema(t, doc, "Ext") + + assertOwnedBy(t, ext, "frag30.yaml") + + require.NotNil(t, ext.ExclusiveMinimum.Value) + assert.Equal(t, 0, ext.ExclusiveMinimum.Value.N, "expected 3.0 boolean form") + assert.True(t, ext.ExclusiveMinimum.Value.A) +} + +// TestSchemaIndexAttribution_LocalAndInlineUnchanged locks in the blast radius. Schemas that do not +// travel through an external reference must be attributed exactly as they were before. +func TestSchemaIndexAttribution_LocalAndInlineUnchanged(t *testing.T) { + doc := buildAttributionDoc(t, "root.yaml") + + local := attributionSchema(t, doc, "Local") + assertOwnedBy(t, local, "root.yaml") + + plain := attributionSchema(t, doc, "Plain") + assertOwnedBy(t, plain, "root.yaml") + + require.NotNil(t, plain.Properties.Value) + require.Equal(t, 1, plain.Properties.Value.Len()) + for pair := plain.Properties.Value.First(); pair != nil; pair = pair.Next() { + property := pair.Value().Value.Schema() + require.NotNil(t, property) + assertOwnedBy(t, property, "root.yaml") + } +} + +// TestSchemaIndexAttribution_ProxyAndRootNodeUnchanged locks in the two things the fix deliberately +// leaves alone, because consumers key caches and circular detection on them. +func TestSchemaIndexAttribution_ProxyAndRootNodeUnchanged(t *testing.T) { + doc := buildAttributionDoc(t, "root.yaml") + + found := doc.Components.Value.FindSchema("Direct") + require.NotNil(t, found) + proxy := found.Value + + // the proxy owns the $ref node, which lives in the referring file. + require.NotNil(t, proxy.GetIndex()) + assert.True(t, strings.HasSuffix(proxy.GetIndex().GetSpecAbsolutePath(), "root.yaml"), + "SchemaProxy.GetIndex must keep naming the referring file, got %s", + proxy.GetIndex().GetSpecAbsolutePath()) + + schema := proxy.Schema() + require.NotNil(t, schema) + + // RootNode stays the authored $ref node, it does not follow the index. + require.NotNil(t, schema.RootNode) + isRef := false + for i := 0; i < len(schema.RootNode.Content)-1; i += 2 { + if schema.RootNode.Content[i].Value == "$ref" { + isRef = true + } + } + assert.True(t, isRef, "RootNode must remain the authored $ref node") + + // the built schema is where the content's owning file is available. + require.NotNil(t, schema.GetIndex()) + assert.True(t, strings.HasSuffix(schema.GetIndex().GetSpecAbsolutePath(), "level1.yaml"), + "the built schema must name the referenced file, got %s", + schema.GetIndex().GetSpecAbsolutePath()) +} + +func TestSchemaIndexAttribution_ExternalPathItem(t *testing.T) { + doc := buildAttributionDoc(t, "root.yaml") + + var external *PathItem + for pair := doc.Paths.Value.PathItems.First(); pair != nil; pair = pair.Next() { + if pair.Key().Value == "/external" { + external = pair.Value().Value + } + } + require.NotNil(t, external, "external path item not found") + + require.NotNil(t, external.GetIndex()) + assert.True(t, strings.HasSuffix(external.GetIndex().GetSpecAbsolutePath(), "level1.yaml"), + "external path item must be owned by level1.yaml, got %s", + external.GetIndex().GetSpecAbsolutePath()) +} + +func TestSchemaIndexAttribution_CircularExternalRefs(t *testing.T) { + doc := buildAttributionDoc(t, "circ_root.yaml") + start := attributionSchema(t, doc, "Start") + + // a loop that crosses files still builds, and the schema is attributed to the file holding it. + assertOwnedBy(t, start, "circ_a.yaml") + require.NotNil(t, start.Properties.Value) + require.Equal(t, 1, start.Properties.Value.Len()) + + // the loop is registered on the rolodex, never on the external file this schema is now + // attributed to. a consumer reading circular references off the schema's own index finds + // nothing, which is why the guards have to combine the rolodex sets with the root index rather + // than trusting whatever index the schema arrived with. + rolodex := start.GetIndex().GetRolodex() + require.NotNil(t, rolodex) + require.NotSame(t, rolodex.GetRootIndex(), start.GetIndex(), + "fixture must attribute the schema away from the root for this to prove anything") + require.Empty(t, start.GetIndex().GetCircularReferences(), + "the owning file's index is not where loops live") + + discovered := len(rolodex.GetRootIndex().GetCircularReferences()) + + len(rolodex.GetSafeCircularReferences()) + + len(rolodex.GetIgnoredCircularReferences()) + require.Positive(t, discovered, "the cross-file loop must be discoverable through the rolodex") +} + +// TestResolveDocumentVersion covers the helper directly, including the paths that no fixture can +// reach through a real document build. +func TestResolveDocumentVersion(t *testing.T) { + t.Run("nil index", func(t *testing.T) { + var idx *index.SpecIndex + version, ok := idx.ResolveDocumentVersion() + assert.Zero(t, version) + assert.False(t, ok) + }) + + t.Run("no spec info anywhere", func(t *testing.T) { + idx := index.NewSpecIndexWithConfig(&yaml.Node{Kind: yaml.MappingNode}, + index.CreateOpenAPIIndexConfig()) + version, ok := idx.ResolveDocumentVersion() + assert.Zero(t, version) + assert.False(t, ok) + }) + + t.Run("own spec info when no rolodex root", func(t *testing.T) { + config := index.CreateOpenAPIIndexConfig() + config.SpecInfo = &datamodel.SpecInfo{VersionNumeric: 3.1} + idx := index.NewSpecIndexWithConfig(&yaml.Node{Kind: yaml.MappingNode}, config) + version, ok := idx.ResolveDocumentVersion() + assert.Equal(t, float32(3.1), version) + assert.True(t, ok) + }) + + t.Run("rolodex root wins over versionless external file", func(t *testing.T) { + doc := buildAttributionDoc(t, "root.yaml") + deep := attributionDeepSchema(t, doc) + + // the external file itself reports nothing useful. + require.NotNil(t, deep.GetIndex().GetConfig()) + require.NotNil(t, deep.GetIndex().GetConfig().SpecInfo) + assert.Zero(t, deep.GetIndex().GetConfig().SpecInfo.VersionNumeric) + + // but the document version resolves through the rolodex root. + version, ok := deep.GetIndex().ResolveDocumentVersion() + assert.True(t, ok) + assert.Equal(t, float32(3.1), version) + }) +} + +// TestExclusiveWithoutDocumentVersion covers the branch taken when no SpecInfo is reachable, which +// a caller hand-building an index will hit. An absent keyword must stay absent rather than become a +// bound of zero the author never wrote. +func TestExclusiveWithoutDocumentVersion(t *testing.T) { + cases := []struct { + name string + value string + present bool + numeric bool + boolValue bool + number float64 + }{ + {name: "integer reads as a 3.1 bound", value: "3", present: true, numeric: true, number: 3}, + {name: "float reads as a 3.1 bound", value: "3.5", present: true, numeric: true, number: 3.5}, + {name: "boolean reads as the 3.0 form", value: "true", present: true, boolValue: true}, + {name: "string is not a bound", value: "'abc'"}, + {name: "null is not a bound", value: "null"}, + {name: "sequence is not a bound", value: "[1, 2]"}, + } + + // both keywords share one implementation, so both are driven to keep the wiring honest. + keywords := []struct { + name string + read func(*lowbase.Schema) *lowbase.SchemaDynamicValue[bool, float64] + build func(string) string + }{ + { + name: "exclusiveMinimum", + read: func(s *lowbase.Schema) *lowbase.SchemaDynamicValue[bool, float64] { return s.ExclusiveMinimum.Value }, + build: func(v string) string { return "type: number\nexclusiveMinimum: " + v }, + }, + { + name: "exclusiveMaximum", + read: func(s *lowbase.Schema) *lowbase.SchemaDynamicValue[bool, float64] { return s.ExclusiveMaximum.Value }, + build: func(v string) string { return "type: number\nexclusiveMaximum: " + v }, + }, + } + + for _, keyword := range keywords { + for _, testCase := range cases { + t.Run(keyword.name+"/"+testCase.name, func(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(keyword.build(testCase.value)), &root)) + + schema := new(lowbase.Schema) + // a nil index means no SpecInfo is reachable, so the version is unknown. + require.NoError(t, schema.Build(t.Context(), root.Content[0], nil)) + + built := keyword.read(schema) + if !testCase.present { + require.Nil(t, built, "an unreadable value must not become a bound") + return + } + + require.NotNil(t, built) + if testCase.numeric { + require.Equal(t, 1, built.N) + require.Equal(t, testCase.number, built.B) + return + } + require.Equal(t, 0, built.N) + require.Equal(t, testCase.boolValue, built.A) + }) + } + } +} + +// TestExclusiveWithNilSpecInfo covers an index that exists but carries no SpecInfo. Reading the +// version straight off such an index panics, and the reference swap made that reachable by handing +// schema building an external file's index. +func TestExclusiveWithNilSpecInfo(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("type: number\nexclusiveMinimum: 3"), &root)) + + config := index.CreateOpenAPIIndexConfig() + require.Nil(t, config.SpecInfo, "fixture assumes a config with no SpecInfo") + idx := index.NewSpecIndexWithConfig(root.Content[0], config) + + schema := new(lowbase.Schema) + require.NotPanics(t, func() { + require.NoError(t, schema.Build(t.Context(), root.Content[0], idx)) + }) + + require.NotNil(t, schema.ExclusiveMinimum.Value) + require.Equal(t, 1, schema.ExclusiveMinimum.Value.N) + require.Equal(t, float64(3), schema.ExclusiveMinimum.Value.B) +} + +func TestExclusiveWithZeroSpecVersionInfersNumericShape(t *testing.T) { + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("type: number\nexclusiveMinimum: 3"), &root)) + + config := index.CreateOpenAPIIndexConfig() + config.SpecInfo = &datamodel.SpecInfo{VersionNumeric: 0} + idx := index.NewSpecIndexWithConfig(root.Content[0], config) + + schema := new(lowbase.Schema) + require.NoError(t, schema.Build(t.Context(), root.Content[0], idx)) + require.NotNil(t, schema.ExclusiveMinimum.Value) + assert.Equal(t, 1, schema.ExclusiveMinimum.Value.N) + assert.Equal(t, float64(3), schema.ExclusiveMinimum.Value.B) +} + +func safeSchema(proxy *lowbase.SchemaProxy) *lowbase.Schema { + if proxy == nil { + return nil + } + return proxy.Schema() +} + +func safeDynamicSchema[T any](dynamic *lowbase.SchemaDynamicValue[*lowbase.SchemaProxy, T]) *lowbase.Schema { + if dynamic == nil { + return nil + } + return safeSchema(dynamic.A) +} + +func collectSchemas(refs []low.ValueReference[*lowbase.SchemaProxy]) []*lowbase.Schema { + schemas := make([]*lowbase.Schema, 0, len(refs)) + for _, ref := range refs { + if schema := safeSchema(ref.Value); schema != nil { + schemas = append(schemas, schema) + } + } + return schemas +} + +func firstMapSchema( + entries *orderedmap.Map[low.KeyReference[string], low.ValueReference[*lowbase.SchemaProxy]], +) *lowbase.Schema { + if entries == nil { + return nil + } + if pair := entries.First(); pair != nil { + return safeSchema(pair.Value().Value) + } + return nil +} + +// A referenced file that declares its own version describes how its own keywords are written, +// whatever version referenced it. exclusiveMinimum is the keyword that notices: 3.0 reads it as +// a boolean modifier, 3.1 reads it as the bound itself. +func TestResolveDocumentVersionPrefersFilesOwnVersion(t *testing.T) { + doc := buildAttributionDoc(t, "root_30_ext31.yaml") + schema := attributionSchema(t, doc, "Ext") + + require.NotNil(t, schema.ExclusiveMinimum.Value) + assert.Equal(t, 1, schema.ExclusiveMinimum.Value.N, + "a 3.1 file's numeric bound must be read as a number, not coerced through the 3.0 boolean form") + assert.Equal(t, float64(5), schema.ExclusiveMinimum.Value.B) +} + +// A bare fragment declares no version of its own, so it inherits the document being built. +// This is the case the rolodex-root fallback exists for and must keep working. +func TestResolveDocumentVersionFragmentInheritsRoot(t *testing.T) { + doc := buildAttributionDoc(t, "root_30.yaml") + schema := attributionSchema(t, doc, "Ext") + + require.NotNil(t, schema.ExclusiveMinimum.Value) + assert.Equal(t, 0, schema.ExclusiveMinimum.Value.N, + "a versionless fragment under a 3.0 root keeps the 3.0 boolean form") + assert.True(t, schema.ExclusiveMinimum.Value.A) +} diff --git a/index/index_model.go b/index/index_model.go index 5e159d31f..fb7fe3741 100644 --- a/index/index_model.go +++ b/index/index_model.go @@ -459,6 +459,37 @@ func (index *SpecIndex) GetConfig() *SpecIndexConfig { return index.config } +// ResolveDocumentVersion returns the numeric spec version of the document this index takes part in. +// +// A file that declares its own version is believed first: it may be a complete document with an +// openapi or swagger key of its own, and that key describes how its keywords are written no matter +// which document referenced it. +// +// The rolodex root is the fallback because an external file's own SpecInfo is not always populated. +// The two indexing paths disagree: Rolodex.indexNode copies the config wholesale and keeps the root +// SpecInfo, while the file loader nils it so the file gets one built from its own bytes, which +// leaves VersionNumeric at zero for a bare schema fragment carrying no version key. A fragment has +// no version of its own to state, so inheriting the document being built is the right reading. +// +// ok is false when no version is reachable at all, so callers can decline to make a version +// dependent decision rather than read a zero as 'this is not 3.1'. +func (index *SpecIndex) ResolveDocumentVersion() (float32, bool) { + if index == nil { + return 0, false + } + if cfg := index.GetConfig(); cfg != nil && cfg.SpecInfo != nil && cfg.SpecInfo.VersionNumeric > 0 { + return cfg.SpecInfo.VersionNumeric, true + } + if rolodex := index.GetRolodex(); rolodex != nil { + if root := rolodex.GetRootIndex(); root != nil { + if cfg := root.GetConfig(); cfg != nil && cfg.SpecInfo != nil && cfg.SpecInfo.VersionNumeric > 0 { + return cfg.SpecInfo.VersionNumeric, true + } + } + } + return 0, false +} + // GetNodeMap returns the line-to-column-to-node map built during indexing. // The map is materialized from the internal line index on first call and cached. // diff --git a/index/resolve_document_version_test.go b/index/resolve_document_version_test.go new file mode 100644 index 000000000..aec7e893b --- /dev/null +++ b/index/resolve_document_version_test.go @@ -0,0 +1,111 @@ +// Copyright 2022-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package index + +import ( + "testing" + + "github.com/pb33f/libopenapi/datamodel" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" +) + +func versionTestIndex(t *testing.T, version float32) *SpecIndex { + t.Helper() + var rootNode yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("openapi: 3.1.0"), &rootNode)) + + config := CreateOpenAPIIndexConfig() + if version > 0 { + config.SpecInfo = &datamodel.SpecInfo{VersionNumeric: version} + } + return NewSpecIndexWithConfig(&rootNode, config) +} + +// ResolveDocumentVersion decides how version-sensitive keywords are read, so its precedence +// matters: a file that states its own version describes its own keywords, and only a file with +// no version of its own should inherit the document being built. +func TestSpecIndex_ResolveDocumentVersion(t *testing.T) { + t.Run("nil index reports nothing", func(t *testing.T) { + var idx *SpecIndex + version, ok := idx.ResolveDocumentVersion() + assert.False(t, ok) + assert.Zero(t, version) + }) + + t.Run("no spec info anywhere reports nothing", func(t *testing.T) { + idx := versionTestIndex(t, 0) + version, ok := idx.ResolveDocumentVersion() + assert.False(t, ok) + assert.Zero(t, version) + }) + + t.Run("own version is used when there is no rolodex", func(t *testing.T) { + idx := versionTestIndex(t, 3.1) + version, ok := idx.ResolveDocumentVersion() + assert.True(t, ok) + assert.Equal(t, float32(3.1), version) + }) + + // A file declaring its own version wins: it may be a complete document referenced from a + // document of a different version, and its own key describes how its keywords are written. + t.Run("own version wins over the rolodex root", func(t *testing.T) { + root := versionTestIndex(t, 3.0) + child := versionTestIndex(t, 3.1) + + rolodex := NewRolodex(CreateOpenAPIIndexConfig()) + rolodex.SetRootIndex(root) + child.SetRolodex(rolodex) + + version, ok := child.ResolveDocumentVersion() + assert.True(t, ok) + assert.Equal(t, float32(3.1), version, + "a file that declares its own version must not inherit the root's") + }) + + // A bare fragment carries no version key, so its own SpecInfo leaves VersionNumeric at zero. + // Inheriting the document being built is the right reading for it. + t.Run("versionless file inherits the rolodex root", func(t *testing.T) { + root := versionTestIndex(t, 3.0) + fragment := versionTestIndex(t, 0) + + rolodex := NewRolodex(CreateOpenAPIIndexConfig()) + rolodex.SetRootIndex(root) + fragment.SetRolodex(rolodex) + + version, ok := fragment.ResolveDocumentVersion() + assert.True(t, ok) + assert.Equal(t, float32(3.0), version) + }) + + // A numeric zero means the file did not declare a version. It must remain unknown so + // version-sensitive schema keywords can infer their representation from the node shape. + t.Run("zero remains unknown when the rolodex root has no spec info", func(t *testing.T) { + rootNoInfo := versionTestIndex(t, 0) + rootNoInfo.GetConfig().SpecInfo = nil + + fragment := versionTestIndex(t, 0) + fragment.GetConfig().SpecInfo = &datamodel.SpecInfo{VersionNumeric: 0} + + rolodex := NewRolodex(CreateOpenAPIIndexConfig()) + rolodex.SetRootIndex(rootNoInfo) + fragment.SetRolodex(rolodex) + + version, ok := fragment.ResolveDocumentVersion() + assert.False(t, ok) + assert.Zero(t, version) + }) + + // A rolodex with no root index at all must not panic. + t.Run("rolodex without a root index falls through", func(t *testing.T) { + idx := versionTestIndex(t, 0) + idx.GetConfig().SpecInfo = &datamodel.SpecInfo{VersionNumeric: 0} + idx.SetRolodex(NewRolodex(CreateOpenAPIIndexConfig())) + + version, ok := idx.ResolveDocumentVersion() + assert.False(t, ok) + assert.Zero(t, version) + }) +} diff --git a/json/json_test.go b/json/json_test.go index 819cda79e..b337e54d3 100644 --- a/json/json_test.go +++ b/json/json_test.go @@ -241,3 +241,28 @@ func TestHandleScalarNode_DecodeError(t *testing.T) { _, err := json.YAMLNodeToJSON(node, " ") assert.Error(t, err) } + +// A non-string mapping key is marshalled to JSON so it can be used as an object key. NaN has +// no JSON representation, so the marshal fails and the error must surface rather than produce +// a malformed document. The branch is reachable, despite reading as defensive. +func TestYAMLNodeToJSON_UnmarshalableMappingKey(t *testing.T) { + for _, src := range []string{"? .nan\n: value\n", "{.nan: value}"} { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &node)) + + out, err := json.YAMLNodeToJSON(&node, " ") + require.Error(t, err, "input %q", src) + assert.Contains(t, err.Error(), "unsupported value: NaN") + assert.Nil(t, out) + } +} + +// A numeric key that does have a JSON representation is stringified rather than rejected. +func TestYAMLNodeToJSON_NumericMappingKey(t *testing.T) { + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("? 1.5\n: value\n"), &node)) + + out, err := json.YAMLNodeToJSON(&node, "") + require.NoError(t, err) + assert.JSONEq(t, `{"1.5":"value"}`, string(out)) +} diff --git a/orderedmap/accessors_test.go b/orderedmap/accessors_test.go new file mode 100644 index 000000000..204e4c73d --- /dev/null +++ b/orderedmap/accessors_test.go @@ -0,0 +1,108 @@ +// Copyright 2023-2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package orderedmap_test + +import ( + "reflect" + "testing" + + "github.com/pb33f/libopenapi/orderedmap" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +// GetKeyType and GetValueType report the reflection types the map was instantiated with, +// which is how reflection-driven callers discover what a Map holds. +func TestMap_GetKeyAndValueType(t *testing.T) { + m := orderedmap.New[string, int]() + + assert.Equal(t, reflect.TypeOf(new(string)), m.GetKeyType()) + assert.Equal(t, reflect.TypeOf(new(int)), m.GetValueType()) + + pointers := orderedmap.New[string, *testValue]() + assert.Equal(t, reflect.TypeOf(new(*testValue)), pointers.GetValueType()) +} + +type testValue struct{ name string } + +// GetOrZero substitutes the value type's zero value for a missing key, so callers can read +// without a comma-ok dance. +func TestMap_GetOrZero(t *testing.T) { + m := orderedmap.New[string, int]() + m.Set("present", 42) + + assert.Equal(t, 42, m.GetOrZero("present")) + assert.Equal(t, 0, m.GetOrZero("absent"), "a missing key yields the zero value") + + pointers := orderedmap.New[string, *testValue]() + assert.Nil(t, pointers.GetOrZero("absent"), "the zero value of a pointer type is nil") + + strings := orderedmap.New[string, string]() + assert.Equal(t, "", strings.GetOrZero("absent")) +} + +// IsZero backs the omitempty tag: an empty or nil map must not be rendered. +func TestMap_IsZero(t *testing.T) { + empty := orderedmap.New[string, int]() + assert.True(t, empty.IsZero()) + + populated := orderedmap.New[string, int]() + populated.Set("k", 1) + assert.False(t, populated.IsZero()) + + var nilMap *orderedmap.Map[string, int] + assert.True(t, nilMap.IsZero(), "a nil map is empty for rendering purposes") +} + +// Cast recovers a typed Map from an any, returning nil rather than panicking when the value +// is absent or is some other type. +func TestCast(t *testing.T) { + m := orderedmap.New[string, int]() + m.Set("k", 1) + + got := orderedmap.Cast[string, int](m) + require.NotNil(t, got) + assert.Equal(t, 1, got.GetOrZero("k")) + + assert.Nil(t, orderedmap.Cast[string, int](nil), "nil input yields nil") + assert.Nil(t, orderedmap.Cast[string, int]("not a map"), "a mismatched type yields nil") + assert.Nil(t, orderedmap.Cast[string, int](orderedmap.New[string, string]()), + "a map with different type parameters yields nil") +} + +// SortAlpha returns a new map ordered by key, and tolerates a nil input. +func TestSortAlpha(t *testing.T) { + assert.Nil(t, orderedmap.SortAlpha[string, int](nil)) + + m := orderedmap.New[string, int]() + m.Set("charlie", 3) + m.Set("alpha", 1) + m.Set("bravo", 2) + + sorted := orderedmap.SortAlpha(m) + require.NotNil(t, sorted) + + var keys []string + for pair := sorted.First(); pair != nil; pair = pair.Next() { + keys = append(keys, pair.Key()) + } + assert.Equal(t, []string{"alpha", "bravo", "charlie"}, keys) + + // The original ordering is left alone. + assert.Equal(t, "charlie", m.First().Key()) +} + +// First is nil-safe at both the method and package-function level. +func TestFirst_NilSafe(t *testing.T) { + var nilMap *orderedmap.Map[string, int] + assert.Nil(t, nilMap.First()) + assert.Nil(t, orderedmap.First(nilMap)) + + m := orderedmap.New[string, int]() + assert.Nil(t, m.First(), "an empty map has no first pair") + + m.Set("k", 1) + require.NotNil(t, orderedmap.First(m)) + assert.Equal(t, "k", orderedmap.First(m).Key()) +} diff --git a/orderedmap/builder_test.go b/orderedmap/builder_test.go index 7af92e83c..bc7ce5117 100644 --- a/orderedmap/builder_test.go +++ b/orderedmap/builder_test.go @@ -138,3 +138,43 @@ func TestOrderedMap_FindValueUntyped(t *testing.T) { }) } } + +// lowWithValueNode supplies the original YAML node that ToYamlNode consults to recover the +// authored quoting style of each key. +type lowWithValueNode struct{ node *yaml.Node } + +func (l lowWithValueNode) GetValueNode() *yaml.Node { return l.node } + +// ToYamlNode looks each key up in the original node to carry its style across. A key that is +// not present there (one added to the high model after parsing) simply has no authored style, +// and must render with the default rather than fail the lookup. +func TestOrderedMap_ToYamlNode_KeyAbsentFromOriginalNode(t *testing.T) { + // The original document quoted "kept" but knows nothing about "added". + var original yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(`"kept": one`), &original)) + require.NotNil(t, original.Content) + + om := orderedmap.New[string, string]() + om.Set("kept", "one") + om.Set("added", "two") + + node := om.ToYamlNode(new(high.NodeBuilder), lowWithValueNode{node: original.Content[0]}) + rendered, err := yaml.Marshal(node) + require.NoError(t, err) + + // The quoted key keeps its style; the one with no counterpart renders plainly. + require.Equal(t, `"kept": one +added: two +`, string(rendered)) +} + +// A low model that carries no value node at all leaves every key without an authored style. +func TestOrderedMap_ToYamlNode_NilValueNode(t *testing.T) { + om := orderedmap.New[string, string]() + om.Set("one", "two") + + node := om.ToYamlNode(new(high.NodeBuilder), lowWithValueNode{node: nil}) + rendered, err := yaml.Marshal(node) + require.NoError(t, err) + require.Equal(t, "one: two\n", string(rendered)) +} diff --git a/schema_quickhash_attribution_test.go b/schema_quickhash_attribution_test.go new file mode 100644 index 000000000..883263677 --- /dev/null +++ b/schema_quickhash_attribution_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package libopenapi + +import ( + "os" + "testing" + + "github.com/pb33f/libopenapi/datamodel" + lowbase "github.com/pb33f/libopenapi/datamodel/low/base" + "github.com/pb33f/testify/assert" + "github.com/pb33f/testify/require" +) + +// The quick-hash cache key identifies a schema by file plus the position of its RootNode. +// Building a schema through a $ref re-attributes its Index to the file the reference resolves +// to, while RootNode stays behind in the referring file. Pairing the resolved file's path with +// the referring file's line and column would stop identifying the node: two $refs sitting at +// the same position in different files, both pointing into one shared file, would produce the +// same key and be handed each other's hash. +// +// The fixtures put the two $refs at exactly the same line and column on purpose. +func TestQuickHashKeyDistinguishesSamePositionRefs(t *testing.T) { + lowbase.ClearSchemaQuickHashMap() + + specBytes, err := os.ReadFile("test_specs/index_attribution/root_collide.yaml") + require.NoError(t, err) + + doc, err := NewDocumentWithConfiguration(specBytes, &datamodel.DocumentConfiguration{ + BasePath: "test_specs/index_attribution", + AllowFileReferences: true, + UseSchemaQuickHash: true, + }) + require.NoError(t, err) + + model, errs := doc.BuildV3Model() + require.Nil(t, errs) + + schemaAt := func(path string) *lowbase.Schema { + item, ok := model.Model.Paths.PathItems.Get(path) + require.Truef(t, ok, "path %s missing", path) + response, ok := item.Get.Responses.Codes.Get("200") + require.True(t, ok) + mediaType, ok := response.Content.Get("application/json") + require.True(t, ok) + return mediaType.Schema.GoLow().Schema() + } + + fromA := schemaAt("/a") // resolves to shared_two.yaml#/components/schemas/A, an object + fromB := schemaAt("/b") // resolves to shared_two.yaml#/components/schemas/B, an integer + + // Preconditions. Without these the test would pass for the wrong reason. + require.Equal(t, fromA.RootNode.Line, fromB.RootNode.Line, + "fixtures must place both $ref nodes on the same line") + require.Equal(t, fromA.RootNode.Column, fromB.RootNode.Column, + "fixtures must place both $ref nodes in the same column") + require.Equal(t, fromA.Index.GetSpecAbsolutePath(), fromB.Index.GetSpecAbsolutePath(), + "both must be re-attributed to the same resolved file") + + assert.NotEqual(t, fromA.QuickHash(), fromB.QuickHash(), + "an object schema and an integer schema must not share a quick hash") +} diff --git a/test_specs/index_attribution/circ_a.yaml b/test_specs/index_attribution/circ_a.yaml new file mode 100644 index 000000000..9caa4860a --- /dev/null +++ b/test_specs/index_attribution/circ_a.yaml @@ -0,0 +1,5 @@ +A: + type: object + properties: + toB: + $ref: "./circ_b.yaml#/B" diff --git a/test_specs/index_attribution/circ_b.yaml b/test_specs/index_attribution/circ_b.yaml new file mode 100644 index 000000000..ec1595662 --- /dev/null +++ b/test_specs/index_attribution/circ_b.yaml @@ -0,0 +1,5 @@ +B: + type: object + properties: + toA: + $ref: "./circ_a.yaml#/A" diff --git a/test_specs/index_attribution/circ_root.yaml b/test_specs/index_attribution/circ_root.yaml new file mode 100644 index 000000000..c4b3fd030 --- /dev/null +++ b/test_specs/index_attribution/circ_root.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: circular + version: 1.0.0 +paths: {} +components: + schemas: + Start: + $ref: './circ_a.yaml#/A' diff --git a/test_specs/index_attribution/ext31.yaml b/test_specs/index_attribution/ext31.yaml new file mode 100644 index 000000000..af8b92fd7 --- /dev/null +++ b/test_specs/index_attribution/ext31.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: external document declaring its own 3.1 version + version: 1.0.0 +paths: {} +components: + schemas: + Num: + type: number + exclusiveMinimum: 5 diff --git a/test_specs/index_attribution/frag30.yaml b/test_specs/index_attribution/frag30.yaml new file mode 100644 index 000000000..1cca2273b --- /dev/null +++ b/test_specs/index_attribution/frag30.yaml @@ -0,0 +1,3 @@ +Bool: + type: number + exclusiveMinimum: true diff --git a/test_specs/index_attribution/level1.yaml b/test_specs/index_attribution/level1.yaml new file mode 100644 index 000000000..2317edc6d --- /dev/null +++ b/test_specs/index_attribution/level1.yaml @@ -0,0 +1,71 @@ +openapi: 3.1.0 +info: + title: level one + version: 1.0.0 +paths: {} +pathItems: + PI: + get: + operationId: externalOp + parameters: + - name: q + in: query + schema: + type: string + responses: + '200': + description: ok +components: + schemas: + L1: + type: object + externalDocs: + url: https://pb33f.io + xml: + name: l1 + properties: + alpha: + type: string + beta: + type: integer + deep: + $ref: './level2.yaml#/Deep' + patternProperties: + '^x-': + type: string + dependentSchemas: + alpha: + type: object + additionalProperties: + type: boolean + propertyNames: + type: string + unevaluatedProperties: + type: boolean + unevaluatedItems: + type: string + contentSchema: + type: string + not: + type: 'null' + if: + type: object + then: + type: object + else: + type: object + allOf: + - type: object + anyOf: + - type: object + oneOf: + - type: object + prefixItems: + - type: string + contains: + type: string + items: + type: string + $defs: + Helper: + type: string diff --git a/test_specs/index_attribution/level2.yaml b/test_specs/index_attribution/level2.yaml new file mode 100644 index 000000000..0cac54416 --- /dev/null +++ b/test_specs/index_attribution/level2.yaml @@ -0,0 +1,6 @@ +Deep: + type: number + exclusiveMinimum: 3 + properties: + deepProp: + type: string diff --git a/test_specs/index_attribution/refs_a.yaml b/test_specs/index_attribution/refs_a.yaml new file mode 100644 index 000000000..59012bde3 --- /dev/null +++ b/test_specs/index_attribution/refs_a.yaml @@ -0,0 +1,3 @@ +description: refs A +schema: + $ref: './shared_two.yaml#/components/schemas/A' diff --git a/test_specs/index_attribution/refs_b.yaml b/test_specs/index_attribution/refs_b.yaml new file mode 100644 index 000000000..7240165e0 --- /dev/null +++ b/test_specs/index_attribution/refs_b.yaml @@ -0,0 +1,3 @@ +description: refs B +schema: + $ref: './shared_two.yaml#/components/schemas/B' diff --git a/test_specs/index_attribution/root.yaml b/test_specs/index_attribution/root.yaml new file mode 100644 index 000000000..a05d51e71 --- /dev/null +++ b/test_specs/index_attribution/root.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: index attribution + version: 1.0.0 +paths: + /external: + $ref: './level1.yaml#/pathItems/PI' +components: + schemas: + Direct: + $ref: './level1.yaml#/components/schemas/L1' + Local: + $ref: '#/components/schemas/Plain' + Plain: + type: object + properties: + localProp: + type: string diff --git a/test_specs/index_attribution/root_30.yaml b/test_specs/index_attribution/root_30.yaml new file mode 100644 index 000000000..b97f7b511 --- /dev/null +++ b/test_specs/index_attribution/root_30.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.3 +info: + title: index attribution 3.0 + version: 1.0.0 +paths: {} +components: + schemas: + Ext: + $ref: './frag30.yaml#/Bool' diff --git a/test_specs/index_attribution/root_30_ext31.yaml b/test_specs/index_attribution/root_30_ext31.yaml new file mode 100644 index 000000000..afa7a642c --- /dev/null +++ b/test_specs/index_attribution/root_30_ext31.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.3 +info: + title: 3.0 root referencing a 3.1 document + version: 1.0.0 +paths: {} +components: + schemas: + Ext: + $ref: './ext31.yaml#/components/schemas/Num' diff --git a/test_specs/index_attribution/root_collide.yaml b/test_specs/index_attribution/root_collide.yaml new file mode 100644 index 000000000..4ac78c54a --- /dev/null +++ b/test_specs/index_attribution/root_collide.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: same-position refs into one shared file + version: 1.0.0 +paths: + /a: + get: + responses: + '200': + content: + application/json: + schema: + $ref: './refs_a.yaml#/schema' + /b: + get: + responses: + '200': + content: + application/json: + schema: + $ref: './refs_b.yaml#/schema' diff --git a/test_specs/index_attribution/shared_two.yaml b/test_specs/index_attribution/shared_two.yaml new file mode 100644 index 000000000..15654d1eb --- /dev/null +++ b/test_specs/index_attribution/shared_two.yaml @@ -0,0 +1,10 @@ +components: + schemas: + A: + type: object + properties: + alpha: + type: string + B: + type: integer + minimum: 42 diff --git a/what-changed/model/breaking_rules_test.go b/what-changed/model/breaking_rules_test.go index e078ad6cd..40be200dc 100644 --- a/what-changed/model/breaking_rules_test.go +++ b/what-changed/model/breaking_rules_test.go @@ -659,12 +659,15 @@ func BenchmarkMerge(b *testing.B) { override := &BreakingRulesConfig{ Schema: &SchemaRules{Type: rule(false, false, false)}, } + config := GenerateDefaultBreakingRules() b.ResetTimer() for i := 0; i < b.N; i++ { - config := GenerateDefaultBreakingRules() - // create a copy since we can't mutate the singleton - configCopy := *config + // Build an independent config without copying the singleton's sync.Once. + b.StopTimer() + configCopy := &BreakingRulesConfig{} + configCopy.Merge(config) + b.StartTimer() configCopy.Merge(override) } } diff --git a/what-changed/model/change_types.go b/what-changed/model/change_types.go index 668423eba..2fafb4b30 100644 --- a/what-changed/model/change_types.go +++ b/what-changed/model/change_types.go @@ -169,7 +169,7 @@ func (c *Change) MarshalJSON() ([]byte, error) { // PropertyChanges holds a slice of Change pointers type PropertyChanges struct { RenderPropertiesOnly bool `json:"-" yaml:"-"` - ChangeReference string `json:"changeReference,omitempty""` + ChangeReference string `json:"changeReference,omitempty"` Changes []*Change `json:"changes,omitempty" yaml:"changes,omitempty"` } diff --git a/what-changed/model/schema.go b/what-changed/model/schema.go index 77a497bae..cce62cd40 100644 --- a/what-changed/model/schema.go +++ b/what-changed/model/schema.go @@ -5,6 +5,7 @@ package model import ( "fmt" + "reflect" "slices" "sort" "strings" @@ -1828,7 +1829,7 @@ func mergeSimpleAllOfObjectSchemaView(schema *base.Schema) (*base.Schema, bool) if schema == nil { return nil, false } - merged := *schema + merged := copySchemaPublicFields(schema) typeRef, ok := mergeSimpleAllOfObjectType(schema) if !ok { @@ -1858,7 +1859,22 @@ func mergeSimpleAllOfObjectSchemaView(schema *base.Schema) (*base.Schema, bool) merged.Properties = propertiesRef merged.Required = requiredRef - return &merged, true + return merged, true +} + +// copySchemaPublicFields creates a shallow comparison view without copying the +// schema's internal sync.Map. Public model values intentionally retain their +// existing pointers because the view is read-only. +func copySchemaPublicFields(schema *base.Schema) *base.Schema { + source := reflect.ValueOf(schema).Elem() + destination := reflect.ValueOf(&base.Schema{}).Elem() + schemaType := source.Type() + for i := 0; i < source.NumField(); i++ { + if schemaType.Field(i).PkgPath == "" { + destination.Field(i).Set(source.Field(i)) + } + } + return destination.Addr().Interface().(*base.Schema) } func mergeSimpleAllOfObjectType(schema *base.Schema) (low.NodeReference[base.SchemaDynamicValue[string, []low.ValueReference[string]]], bool) { diff --git a/what-changed/model/schema_test.go b/what-changed/model/schema_test.go index a81971437..b81f58bfe 100644 --- a/what-changed/model/schema_test.go +++ b/what-changed/model/schema_test.go @@ -4279,9 +4279,8 @@ func setSchemaProxyRendered(proxy *base.SchemaProxy, schema *base.Schema) { func markSchemaProxyBuilt(proxy *base.SchemaProxy) { field := reflect.ValueOf(proxy).Elem().FieldByName("schemaOnce") - done := sync.Once{} + done := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Interface().(*sync.Once) done.Do(func() {}) - reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(done)) } func TestSchemaCompositionEntryStableKey(t *testing.T) { From 72612b9a6a85d8153cfbcfd92ab8f97f41dcab78 Mon Sep 17 00:00:00 2001 From: quobix Date: Sun, 26 Jul 2026 17:48:18 -0400 Subject: [PATCH 2/3] fix pipeline --- arazzo/engine_coverage_test.go | 9 ++++--- arazzo/resolve.go | 43 ++++++++++++++++++++++++++++++---- arazzo_test.go | 5 +++- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/arazzo/engine_coverage_test.go b/arazzo/engine_coverage_test.go index 4b8437452..cd834c88f 100644 --- a/arazzo/engine_coverage_test.go +++ b/arazzo/engine_coverage_test.go @@ -1550,9 +1550,12 @@ func TestEnsureResolvedPathWithinRoots_PathOutsideRoots(t *testing.T) { assert.Contains(t, err.Error(), "outside configured roots") } -func TestEnsureResolvedPathWithinRoots_EvalSymlinksNotExist(t *testing.T) { - // If the path doesn't exist, EvalSymlinks returns ErrNotExist => return nil - err := ensureResolvedPathWithinRoots("/nonexistent/path/file.yaml", []string{"/some/root"}) +func TestEnsureResolvedPathWithinRoots_MissingPathInsideRoot(t *testing.T) { + root := t.TempDir() + canonicalRoot, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + path := filepath.Join(root, "missing", "file.yaml") + err = ensureResolvedPathWithinRoots(path, []string{canonicalRoot}) assert.NoError(t, err) } diff --git a/arazzo/resolve.go b/arazzo/resolve.go index 8c4eb31b1..f8a51a0ca 100644 --- a/arazzo/resolve.go +++ b/arazzo/resolve.go @@ -481,11 +481,8 @@ func canonicalizeRoots(roots []string) []string { } func ensureResolvedPathWithinRoots(path string, roots []string) error { - resolvedPath, err := filepath.EvalSymlinks(path) + resolvedPath, err := resolvePathForContainment(path) if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } return err } if !isPathWithinRoots(resolvedPath, roots) { @@ -494,6 +491,44 @@ func ensureResolvedPathWithinRoots(path string, roots []string) error { return nil } +// resolvePathForContainment resolves every existing path component, preserving any +// missing tail for the final containment check. filepath.EvalSymlinks returns +// os.ErrNotExist for the whole path when its final component is missing, which would +// otherwise hide a symlinked parent that escapes the configured roots. +func resolvePathForContainment(path string) (string, error) { + candidate := filepath.Clean(path) + var missing []string + + for { + resolved, err := filepath.EvalSymlinks(candidate) + if err == nil { + if len(missing) > 0 { + info, statErr := os.Stat(resolved) + if statErr != nil { + return "", statErr + } + if !info.IsDir() { + return "", fmt.Errorf("path component %q is not a directory", candidate) + } + } + for i := len(missing) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, missing[i]) + } + return resolved, nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + parent := filepath.Dir(candidate) + if parent == candidate { + return "", err + } + missing = append(missing, filepath.Base(candidate)) + candidate = parent + } +} + func containsFold(values []string, value string) bool { for _, v := range values { if strings.EqualFold(v, value) { diff --git a/arazzo_test.go b/arazzo_test.go index f032d8be3..78c52b908 100644 --- a/arazzo_test.go +++ b/arazzo_test.go @@ -10,6 +10,7 @@ import ( "log/slog" "os" "reflect" + "strings" "sync" "testing" "unsafe" @@ -313,7 +314,9 @@ func TestNewArazzoDocument_Arazzo10GoldenRenderCompatibility(t *testing.T) { require.NoError(t, err) rendered, err := document.Render() require.NoError(t, err) - assert.Equal(t, string(fixture), string(rendered)) + // Git may check the fixture out with CRLF on Windows, while yaml.Marshal + // deliberately emits LF. Compare the serialized content, not checkout policy. + assert.Equal(t, strings.ReplaceAll(string(fixture), "\r\n", "\n"), string(rendered)) assert.NotContains(t, string(rendered), "$self") assert.NotContains(t, string(rendered), "channelPath") assert.NotContains(t, string(rendered), "targetSelectorType") From da69ee7c03cec4fed1abf8d09f59ab785585de0a Mon Sep 17 00:00:00 2001 From: quobix Date: Sun, 26 Jul 2026 20:36:21 -0400 Subject: [PATCH 3/3] bump coverage --- arazzo/engine_coverage_test.go | 51 +++++++++++++++++++ arazzo/execution_preflight_test.go | 29 ++++++++++- arazzo/expression/version_grammar_test.go | 2 + arazzo/resolve.go | 12 ++++- datamodel/high/arazzo/model_rendering_test.go | 4 ++ 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/arazzo/engine_coverage_test.go b/arazzo/engine_coverage_test.go index cd834c88f..eab7512ac 100644 --- a/arazzo/engine_coverage_test.go +++ b/arazzo/engine_coverage_test.go @@ -1559,6 +1559,57 @@ func TestEnsureResolvedPathWithinRoots_MissingPathInsideRoot(t *testing.T) { assert.NoError(t, err) } +func TestResolvePathForContainment_ErrorBranches(t *testing.T) { + t.Run("existing prefix is not a directory", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "file.yaml") + require.NoError(t, os.WriteFile(file, []byte("content"), 0o600)) + path := filepath.Join(file, "child.yaml") + + evalSymlinks := func(candidate string) (string, error) { + if candidate == path { + return "", os.ErrNotExist + } + return candidate, nil + } + + _, err := resolvePathForContainmentWith(path, evalSymlinks, os.Stat) + require.Error(t, err) + assert.Contains(t, err.Error(), "is not a directory") + }) + + t.Run("existing prefix disappears before stat", func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "missing.yaml") + sentinel := errors.New("prefix disappeared") + + evalSymlinks := func(candidate string) (string, error) { + if candidate == path { + return "", os.ErrNotExist + } + return candidate, nil + } + stat := func(string) (os.FileInfo, error) { return nil, sentinel } + + _, err := resolvePathForContainmentWith(path, evalSymlinks, stat) + assert.ErrorIs(t, err, sentinel) + }) + + t.Run("non-missing evaluation error", func(t *testing.T) { + sentinel := errors.New("evaluation failed") + evalSymlinks := func(string) (string, error) { return "", sentinel } + + _, err := resolvePathForContainmentWith("any", evalSymlinks, os.Stat) + assert.ErrorIs(t, err, sentinel) + }) + + t.Run("filesystem root never resolves", func(t *testing.T) { + evalSymlinks := func(string) (string, error) { return "", os.ErrNotExist } + + _, err := resolvePathForContainmentWith(".", evalSymlinks, os.Stat) + assert.ErrorIs(t, err, os.ErrNotExist) + }) +} + func TestEnsureResolvedPathWithinRoots_EvalSymlinksOtherError(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Windows does not support Unix-style directory execute permissions") diff --git a/arazzo/execution_preflight_test.go b/arazzo/execution_preflight_test.go index 9453461a0..290d9e602 100644 --- a/arazzo/execution_preflight_test.go +++ b/arazzo/execution_preflight_test.go @@ -266,7 +266,7 @@ func TestEngine_PreflightExecutionBoundaries(t *testing.T) { assert.NoError(t, engine.preflightExecution("missing")) document := &high.Arazzo{ - Workflows: []*high.Workflow{{ + Workflows: []*high.Workflow{nil, { WorkflowId: "cycle", Steps: []*high.Step{nil, {StepId: "self", WorkflowId: "cycle"}}, }}, @@ -295,6 +295,10 @@ func TestEngine_PreflightsDeferredExecutionFeaturesBeforeExecutor(t *testing.T) step.ChannelPath = "/orders" step.Action = "send" }}, + {name: "action", field: "action", configure: func(_ *high.Workflow, step *high.Step) { + step.OperationId = "" + step.Action = "send" + }}, {name: "correlation id", field: "correlationId", configure: func(_ *high.Workflow, step *high.Step) { step.CorrelationId = "$message.header.X-Correlation-ID" }}, @@ -316,8 +320,23 @@ func TestEngine_PreflightsDeferredExecutionFeaturesBeforeExecutor(t *testing.T) {name: "selector replacement target", field: "requestBody.replacements.targetSelectorType", configure: func(_ *high.Workflow, step *high.Step) { step.RequestBody = &high.RequestBody{Replacements: []*high.PayloadReplacement{{TargetSelectorType: "jsonpath"}}} }}, + {name: "selector replacement value", field: "requestBody.replacements.value", configure: func(_ *high.Workflow, step *high.Step) { + step.RequestBody = &high.RequestBody{Replacements: []*high.PayloadReplacement{nil, {Value: selectorValueNode()}}} + }}, {name: "action parameters", field: "successAction.parameters", configure: func(_ *high.Workflow, step *high.Step) { - step.OnSuccess = []*high.SuccessAction{{Name: "end", Type: "end", Parameters: []*high.Parameter{{Name: "id"}}}} + step.OnSuccess = []*high.SuccessAction{nil, {Name: "end", Type: "end", Parameters: []*high.Parameter{{Name: "id"}}}} + }}, + {name: "failure action parameters", field: "failureAction.parameters", configure: func(_ *high.Workflow, step *high.Step) { + step.OnFailure = []*high.FailureAction{nil, {Name: "end", Type: "end", Parameters: []*high.Parameter{{Name: "id"}}}} + }}, + {name: "workflow selector parameter", field: "parameters.value", configure: func(workflow *high.Workflow, _ *high.Step) { + workflow.Parameters = []*high.Parameter{nil, {Name: "id", Value: selectorValueNode()}} + }}, + {name: "workflow success action parameters", field: "successAction.parameters", configure: func(workflow *high.Workflow, _ *high.Step) { + workflow.SuccessActions = []*high.SuccessAction{nil, {Name: "end", Type: "end", Parameters: []*high.Parameter{{Name: "id"}}}} + }}, + {name: "workflow failure action parameters", field: "failureAction.parameters", configure: func(workflow *high.Workflow, _ *high.Step) { + workflow.FailureActions = []*high.FailureAction{nil, {Name: "end", Type: "end", Parameters: []*high.Parameter{{Name: "id"}}}} }}, } @@ -336,6 +355,12 @@ func TestEngine_PreflightsDeferredExecutionFeaturesBeforeExecutor(t *testing.T) var typed *UnsupportedExecutionFeatureError require.ErrorAs(t, err, &typed) assert.Equal(t, test.field, typed.Field) + assert.Contains(t, typed.Error(), `workflow "root"`) + if typed.StepId == "" { + assert.NotContains(t, typed.Error(), " step ") + } else { + assert.Contains(t, typed.Error(), `step "call"`) + } assert.Empty(t, executor.operationIDs) }) } diff --git a/arazzo/expression/version_grammar_test.go b/arazzo/expression/version_grammar_test.go index 7de902d87..5c6f9797f 100644 --- a/arazzo/expression/version_grammar_test.go +++ b/arazzo/expression/version_grammar_test.go @@ -98,6 +98,8 @@ func TestParseWithVersion_Arazzo10GeneralFormStillValidated(t *testing.T) { "$components.inputs.", "$components.bad type.name", "$components.inputs.bad name", + "$steps.", + "$steps..outputs.value", } for _, input := range tests { diff --git a/arazzo/resolve.go b/arazzo/resolve.go index f8a51a0ca..d489b25cd 100644 --- a/arazzo/resolve.go +++ b/arazzo/resolve.go @@ -496,14 +496,22 @@ func ensureResolvedPathWithinRoots(path string, roots []string) error { // os.ErrNotExist for the whole path when its final component is missing, which would // otherwise hide a symlinked parent that escapes the configured roots. func resolvePathForContainment(path string) (string, error) { + return resolvePathForContainmentWith(path, filepath.EvalSymlinks, os.Stat) +} + +func resolvePathForContainmentWith( + path string, + evalSymlinks func(string) (string, error), + stat func(string) (os.FileInfo, error), +) (string, error) { candidate := filepath.Clean(path) var missing []string for { - resolved, err := filepath.EvalSymlinks(candidate) + resolved, err := evalSymlinks(candidate) if err == nil { if len(missing) > 0 { - info, statErr := os.Stat(resolved) + info, statErr := stat(resolved) if statErr != nil { return "", statErr } diff --git a/datamodel/high/arazzo/model_rendering_test.go b/datamodel/high/arazzo/model_rendering_test.go index acb564bb3..dec3dfdcf 100644 --- a/datamodel/high/arazzo/model_rendering_test.go +++ b/datamodel/high/arazzo/model_rendering_test.go @@ -233,6 +233,10 @@ func TestSelector_ScalarAndObjectRendering(t *testing.T) { } func TestOutputValue_AllVariantsAndInvalidStates(t *testing.T) { + var nilOutput *OutputValue + nilOutput.SetExpression("$statusCode") + nilOutput.SetSelector(&Selector{}) + expression := NewExpressionOutputValue("$statusCode") value, ok := expression.GetExpression() require.True(t, ok)