Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ test-operation.yaml
.idea/
*.iml
.zed/
/arazzz-me.md
53 changes: 51 additions & 2 deletions arazzo.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,41 @@ package libopenapi
import (
gocontext "context"
"fmt"
"log/slog"

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

// 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)
Expand All @@ -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
}
84 changes: 84 additions & 0 deletions arazzo/arazzo_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
46 changes: 35 additions & 11 deletions arazzo/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
Expand All @@ -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://")
Expand All @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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))
}
Expand All @@ -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")
}
Expand All @@ -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")
}
Expand All @@ -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))
}
Expand All @@ -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")
}
Expand All @@ -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")
}
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
39 changes: 34 additions & 5 deletions arazzo/criterion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading