diff --git a/.github/scripts/README.md b/.github/scripts/README.md index 496ae82a..f7574239 100644 --- a/.github/scripts/README.md +++ b/.github/scripts/README.md @@ -4,18 +4,19 @@ This directory contains scripts used by GitHub Actions workflows. ## format-coverage.sh -Formats Go coverage output as a markdown table with color-coded indicators. +Formats Go statement coverage as a markdown table with color-coded indicators. Uses `normalize-coverage.sh` to filter and merge the profile before calculating package totals. Codecov measures line coverage separately, so its percentage can differ. ### Usage ```bash -./format-coverage.sh [current-coverage] [main-coverage] +./format-coverage.sh [current-coverage] [main-coverage] [main-status] ``` **Arguments:** - `coverage-file`: Path to the Go coverage file (typically `coverage.out`) -- `current-coverage`: (optional) Overall coverage percentage for display (e.g., "74.3%") -- `main-coverage`: (optional) Main branch coverage percentage for comparison (e.g., "74.0%") +- `current-coverage`: (optional) Overall statement coverage percentage for display (e.g., "89.2%"), calculated from the normalised profile +- `main-coverage`: (optional) Main branch statement coverage percentage for comparison (e.g., "85.3%"), calculated from the same normalised scope +- `main-status`: (optional) Reason main coverage is unavailable (e.g., "tests timed out after 900 seconds"). Pass an empty `main-coverage` argument to display this reason and omit the comparison, rather than treating a failed measurement as 0%. ### Examples @@ -42,11 +43,16 @@ COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}') ./.github/scripts/format-coverage.sh coverage.out "75.2%" "74.3%" ``` +**When main coverage is unavailable:** +```bash +./.github/scripts/format-coverage.sh coverage.out "89.2%" "" "tests timed out after 900 seconds" +``` + ### Output Format The script generates a markdown report with: - Overall coverage statistics -- Coverage comparison (if main branch coverage provided) +- Coverage comparison (if main branch coverage provided), or an unavailable-baseline reason when `main-status` is provided - Table of coverage by package with color-coded indicators: - ๐ŸŸข Green: โ‰ฅ90% coverage - ๐ŸŸก Yellow: โ‰ฅ75% coverage @@ -69,4 +75,19 @@ mise test-coverage Or save to a file for inspection: ```bash -./.github/scripts/format-coverage.sh coverage.out "74.3%" > coverage-report.md \ No newline at end of file +./.github/scripts/format-coverage.sh coverage.out "74.3%" > coverage-report.md +``` + +## normalize-coverage.sh + +Filters `/cmd/` and `/tests/` files from a Go coverage profile and merges repeated blocks from cross-package instrumentation by summing their hit counts. The coverage task, formatter, and main-baseline workflow share this helper so they measure the same library scope. + +```bash +bash ./.github/scripts/normalize-coverage.sh coverage.out > coverage.normalized.out +``` + +Use a different output file to avoid truncating the input profile. Calculate statement coverage from the normalised profile: + +```bash +go tool cover -func=coverage.normalized.out | awk '/^total:/ {print $NF}' +``` \ No newline at end of file diff --git a/.github/scripts/format-coverage.sh b/.github/scripts/format-coverage.sh index 2c33283a..66253cd3 100755 --- a/.github/scripts/format-coverage.sh +++ b/.github/scripts/format-coverage.sh @@ -2,27 +2,33 @@ set -euo pipefail # Script to format Go coverage output as a markdown table -# Usage: ./format-coverage.sh coverage.out [current-coverage] [main-coverage] +# Usage: ./format-coverage.sh coverage.out [current-coverage] [main-coverage] [main-status] COVERAGE_FILE="${1:-coverage.out}" CURRENT_COVERAGE="${2:-}" MAIN_COVERAGE="${3:-}" +MAIN_STATUS="${4:-}" if [ ! -f "$COVERAGE_FILE" ]; then echo "Error: Coverage file '$COVERAGE_FILE' not found" exit 1 fi +NORMALIZED_FILE=$(mktemp) +trap 'rm -f "$NORMALIZED_FILE"' EXIT +bash "$(dirname "$0")/normalize-coverage.sh" "$COVERAGE_FILE" > "$NORMALIZED_FILE" +COVERAGE_FILE="$NORMALIZED_FILE" + # Start markdown output echo "## ๐Ÿ“Š Test Coverage Report" echo "" # Show current and main coverage if provided if [ -n "$CURRENT_COVERAGE" ]; then - echo "**Current Coverage:** \`$CURRENT_COVERAGE\`" + echo "**Current Statement Coverage:** \`$CURRENT_COVERAGE\`" if [ -n "$MAIN_COVERAGE" ]; then - echo "**Main Branch Coverage:** \`$MAIN_COVERAGE\`" + echo "**Main Branch Statement Coverage:** \`$MAIN_COVERAGE\`" echo "" # Calculate difference @@ -41,51 +47,32 @@ if [ -n "$CURRENT_COVERAGE" ]; then echo "**Coverage Change:** โœ… No change" fi fi + elif [ -n "$MAIN_STATUS" ]; then + echo "**Main Branch Statement Coverage:** unavailable ($MAIN_STATUS). Comparison omitted." fi fi echo "" -echo "### Coverage by Package" +echo "### Statement Coverage by Package" +echo "" +echo "Go measures covered statements. Codecov measures fully covered lines, so its percentage can differ." echo "" # Create table header echo "| Package | Coverage |" echo "|---------|----------|" -# Parse coverage and group by package -go tool cover -func="$COVERAGE_FILE" | grep -E '\.go:[0-9]+:' | \ -awk -F: '{ - # Extract package path from filename - split($1, parts, "/"); - pkg = ""; - for(i=1; i 1 { + pkg = $1; + sub(/\/[^\/]+$/, "", pkg); + totals[pkg] += $2; + if($3 > 0) covered[pkg] += $2; } END { - for(pkg in packages) { - # Calculate average coverage for package - split(packages[pkg], covs, ","); - sum = 0; - count = 0; - for(i in covs) { - gsub(/%/, "", covs[i]); - sum += covs[i]; - count++; - } - avg = (count > 0) ? sum/count : 0; + for(pkg in totals) { + avg = (totals[pkg] > 0) ? 100 * covered[pkg] / totals[pkg] : 0; # Format package name (remove common prefix) display_pkg = pkg; @@ -100,7 +87,7 @@ END { # Output with coverage value for sorting printf "%.1f|`%s`|%s\n", avg, display_pkg, emoji; } -}' | sort -n | awk -F'|' '{ +}' "$COVERAGE_FILE" | sort -n | awk -F'|' '{ # Re-format after sorting by coverage printf "| %s | %s %.1f%% |\n", $2, $3, $1; }' diff --git a/.github/scripts/normalize-coverage.sh b/.github/scripts/normalize-coverage.sh new file mode 100644 index 00000000..b273cb2f --- /dev/null +++ b/.github/scripts/normalize-coverage.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Keep the library scope and merge blocks repeated by cross-package coverage. +# Usage: bash normalize-coverage.sh profile.out > normalized.out +awk ' +NR == 1 { mode = $0; next } +$1 !~ /\/cmd\/|\/tests\// { + statements[$1] = $2; + hits[$1] += $3; +} +END { + print mode; + for (block in statements) print block, statements[block], hits[block]; +}' "${1:?Coverage profile required}" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6e14e272..af0556b8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -151,45 +151,53 @@ jobs: if: github.event_name == 'pull_request' id: main-coverage run: | - # Store current working directory CURRENT_DIR=$(pwd) - - # Fetch main branch with shallow clone for speed + MAIN_DIR="$RUNNER_TEMP/main-branch" git fetch --depth=1 origin main:main + git worktree add "$MAIN_DIR" main + trap 'cd "$CURRENT_DIR"; git worktree remove --force "$MAIN_DIR"' EXIT + cd "$MAIN_DIR" + + if ! git submodule update --init --recursive; then + echo "::warning::Main coverage unavailable: submodule initialization failed" + echo "main-status=submodule initialization failed" >> "$GITHUB_OUTPUT" + exit 0 + fi - # Checkout main branch in a temporary directory - git worktree add /tmp/main-branch main - - # Initialize submodules in the main branch worktree - cd /tmp/main-branch - git submodule update --init --recursive || echo "Submodule initialization failed, continuing without submodules" - cd "$CURRENT_DIR" - - # Run tests on main branch to get coverage (with timeout) - cd /tmp/main-branch - timeout 300 go test -coverprofile=main-coverage.out -covermode=atomic ./... > /dev/null 2>&1 || echo "Main branch tests failed or timed out" - - if [ -f main-coverage.out ]; then - # Filter out cmd and tests folders from main branch coverage (same as current branch) - grep -v -E '/cmd/|/tests/' main-coverage.out > main-coverage.filtered.out || true - mv main-coverage.filtered.out main-coverage.out - - MAIN_COVERAGE=$(go tool cover -func=main-coverage.out | grep total | awk '{print $3}' || echo "0.0%") - echo "main-coverage=$MAIN_COVERAGE" >> $GITHUB_OUTPUT - echo "Main branch coverage: $MAIN_COVERAGE" - - # Copy main coverage file back to current directory - cp main-coverage.out "$CURRENT_DIR/main-coverage.out" + if timeout 900 go test -race -coverpkg=./... -coverprofile=main-coverage.out -covermode=atomic ./... ./jsonschema/oas3/tests/... ./openapi/linter/customrules/... ./openapi/linter/converter/tests/... > "$CURRENT_DIR/main-coverage.log" 2>&1; then + TEST_STATUS=0 else - echo "main-coverage=0.0%" >> $GITHUB_OUTPUT - echo "Could not get main branch coverage" + TEST_STATUS=$? fi - # Return to original directory - cd "$CURRENT_DIR" + if [ "$TEST_STATUS" -ne 0 ]; then + if [ "$TEST_STATUS" -eq 124 ]; then + REASON="tests timed out after 900 seconds" + else + REASON="tests failed with exit code $TEST_STATUS" + fi + echo "::warning::Main coverage unavailable: $REASON" + echo "main-status=$REASON" >> "$GITHUB_OUTPUT" + tail -n 100 "$CURRENT_DIR/main-coverage.log" + exit 0 + fi - # Clean up worktree (force removal to handle modified files) - git worktree remove --force /tmp/main-branch || rm -rf /tmp/main-branch + if [ ! -s main-coverage.out ]; then + echo "::warning::Main coverage unavailable: no coverage profile generated" + echo "main-status=no coverage profile generated" >> "$GITHUB_OUTPUT" + exit 0 + fi + + bash "$CURRENT_DIR/.github/scripts/normalize-coverage.sh" main-coverage.out > main-coverage.filtered.out + mv main-coverage.filtered.out main-coverage.out + if MAIN_COVERAGE=$(go tool cover -func=main-coverage.out | awk '/^total:/ {print $NF}') && [ -n "$MAIN_COVERAGE" ]; then + echo "main-coverage=$MAIN_COVERAGE" >> "$GITHUB_OUTPUT" + echo "Main branch coverage: $MAIN_COVERAGE" + cp main-coverage.out "$CURRENT_DIR/main-coverage.out" + else + echo "::warning::Main coverage unavailable: coverage profile could not be read" + echo "main-status=coverage profile could not be read" >> "$GITHUB_OUTPUT" + fi - name: Generate coverage summary id: coverage-summary @@ -198,7 +206,8 @@ jobs: CURRENT_COV="${{ steps.coverage.outputs.coverage }}" MAIN_COV="${{ steps.main-coverage.outputs.main-coverage }}" - ./.github/scripts/format-coverage.sh coverage.out "$CURRENT_COV" "$MAIN_COV" > coverage-summary.md + MAIN_STATUS="${{ steps.main-coverage.outputs.main-status }}" + ./.github/scripts/format-coverage.sh coverage.out "$CURRENT_COV" "$MAIN_COV" "$MAIN_STATUS" > coverage-summary.md - name: Upload coverage artifact uses: actions/upload-artifact@v7 @@ -208,12 +217,21 @@ jobs: coverage.out coverage.html coverage-summary.md + main-coverage.log + + - name: Setup Python for Codecov + uses: actions/setup-python@v6 + with: + python-version: "3.13" - name: Upload coverage to Codecov uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.out + disable_search: true + fail_ci_if_error: true + use_pypi: true # Build jobs (run in parallel) build-ubuntu: diff --git a/.mise-tasks/test-coverage b/.mise-tasks/test-coverage index 1f14c425..aae99629 100755 --- a/.mise-tasks/test-coverage +++ b/.mise-tasks/test-coverage @@ -1,136 +1,27 @@ #!/usr/bin/env bash -set -uo pipefail +set -euo pipefail -# Use provided packages or default to all packages -PACKAGES="${@:-./...}" - -echo "๐Ÿงช Running tests with coverage using gotestsum..." -if ! gotestsum --format testname -- -race -coverprofile=coverage.out -covermode=atomic ${PACKAGES}; then - echo "โŒ Tests failed!" - exit 1 -fi - -# Filter out cmd and tests folders from coverage report -if [ -f coverage.out ]; then - echo "๐Ÿ”ง Filtering cmd and tests folders from coverage report..." - grep -v -E '/cmd/|/tests/' coverage.out > coverage.filtered.out || true - # Keep original for reference, use filtered for reporting - mv coverage.filtered.out coverage.out +PACKAGES=("$@") +if [ ${#PACKAGES[@]} -eq 0 ]; then + PACKAGES=(./... ./jsonschema/oas3/tests/... ./openapi/linter/customrules/... ./openapi/linter/converter/tests/...) fi -echo "" -echo "## ๐Ÿ“Š Test Coverage Report" -echo "" - -echo "### Coverage by Package" -echo "\`\`\`" -go tool cover -func=coverage.out -echo "\`\`\`" -echo "" - -echo "### ๐ŸŽฏ Functions with 0% Coverage" -echo "" -echo "| File:Function | Coverage |" -echo "|------------------------------------------------------------------------|----------|" - -# Show functions with 0% coverage -go tool cover -func=coverage.out | awk ' -/^github\.com/ && !/total/ { - # Extract file and function name - split($1, parts, ":") - file_func = parts[1] ":" parts[2] - - # Extract coverage percentage from last field - gsub(/%/, "", $NF) - coverage = $NF + 0 - - # Show functions with 0% coverage - if (coverage == 0) { - printf "| %-70s | %7.1f%% |\n", file_func, coverage - } -}' | head -30 - -echo "" -echo "### ๐Ÿ” Functions with Low Coverage (< 50%)" -echo "" -echo "| File:Function | Coverage |" -echo "|------------------------------------------------------------------------|----------|" - -# Show functions with low coverage (>0% and <50%) -go tool cover -func=coverage.out | awk ' -/^github\.com/ && !/total/ { - # Extract file and function name - split($1, parts, ":") - file_func = parts[1] ":" parts[2] - - # Extract coverage percentage from last field - gsub(/%/, "", $NF) - coverage = $NF + 0 - - # Show functions with less than 50% coverage (but not 0%) - if (coverage > 0 && coverage < 50) { - printf "| %-70s | %7.1f%% |\n", file_func, coverage - } -}' | head -30 - -echo "" -echo "### ๐Ÿ“‹ Packages Needing Improvement (< 80%)" -echo "" -echo "| Package | Coverage | Priority |" -echo "|---------------------------------------------------------------|----------|-----------------|" +echo "๐Ÿงช Running tests with coverage using gotestsum..." +# Count library code exercised by tests in other packages, not just its own tests. +gotestsum --format testname -- -race -coverpkg=./... -coverprofile=coverage.out -covermode=atomic "${PACKAGES[@]}" -# Show packages that need improvement -go tool cover -func=coverage.out | awk ' -/^github\.com/ && !/total/ { - # Extract package name - split($1, parts, "/") - pkg_path = "" - for (i = 1; i <= length(parts); i++) { - if (i > 1) pkg_path = pkg_path "/" - pkg_path = pkg_path parts[i] - if (parts[i+1] ~ /\.go:/) break - } - - # Extract coverage percentage from last field - gsub(/%/, "", $NF) - coverage = $NF + 0 - - # Accumulate coverage for each package - if (pkg_path in pkg_total) { - pkg_total[pkg_path] += coverage - pkg_count[pkg_path]++ - } else { - pkg_total[pkg_path] = coverage - pkg_count[pkg_path] = 1 - } -} -END { - for (pkg in pkg_total) { - avg_coverage = pkg_total[pkg] / pkg_count[pkg] - if (avg_coverage < 80) { - priority = "๐Ÿ”ด High" - if (avg_coverage > 60) priority = "๐ŸŸก Medium" - if (avg_coverage > 40) priority = "๐ŸŸ  Medium-High" - if (avg_coverage == 0) priority = "๐Ÿšจ Critical" - printf "| %-61s | %7.1f%% | %-14s |\n", pkg, avg_coverage, priority - } - } -}' | sort -k4,4n | head -15 +# Keep the existing library scope and merge repeated cross-package blocks. +echo "๐Ÿ”ง Filtering cmd and tests folders and merging coverage blocks..." +bash ./.github/scripts/normalize-coverage.sh coverage.out > coverage.filtered.out +mv coverage.filtered.out coverage.out +CURRENT_COV=$(go tool cover -func=coverage.out | awk '/^total:/ {print $NF}') echo "" -echo "---" -echo "" - -# Extract and display total coverage at the bottom for visibility -CURRENT_COV=$(go tool cover -func=coverage.out | tail -1 | awk '{print $NF}') -echo "## ๐ŸŽฏ **TOTAL COVERAGE: \`$CURRENT_COV\`**" +./.github/scripts/format-coverage.sh coverage.out "$CURRENT_COV" echo "" -echo "- ๐Ÿงช All tests completed" -echo "- ๐Ÿ“ˆ Full coverage report available in coverage.html" -echo "- ๐Ÿ“„ Function-level details in coverage.out" +echo "## ๐ŸŽฏ **TOTAL STATEMENT COVERAGE: \`$CURRENT_COV\`**" echo "" -# Generate HTML report go tool cover -html=coverage.out -o coverage.html echo "๐Ÿ“Š Coverage report generated: coverage.html" -echo "โœ… Tests with coverage completed!" \ No newline at end of file +echo "โœ… Tests with coverage completed!" diff --git a/AGENTS.md b/AGENTS.md index cdf8528f..a67fc316 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,10 @@ Avoid using `min`, `max`, `new`, `len`, `cap`, `copy`, `delete`, `error`, `any` - Convert `if-else if` chains to `switch` statements when comparing the same variable. +### thelper: Mark Test Helper Closures + +Test helper functions accepting `*testing.T` must start with `t.Helper()`. This includes anonymous helper closures stored in table-driven test cases, even when they only call another helper. Top-level `TestXxx` functions and `t.Run` callbacks are test entry points, not helpers, and do not need `t.Helper()`. + ## Testing Follow these testing conventions when writing Go tests in this project. Run newly added or modified test immediately after changes to make sure they work as expected before continuing with more work. diff --git a/arazzo/walk_test.go b/arazzo/walk_test.go index 56bedcc9..8ebae2c0 100644 --- a/arazzo/walk_test.go +++ b/arazzo/walk_test.go @@ -1,7 +1,10 @@ package arazzo_test import ( + "bytes" "errors" + "os" + "strconv" "testing" "github.com/speakeasy-api/openapi/arazzo" @@ -13,6 +16,34 @@ import ( "github.com/stretchr/testify/require" ) +func TestWalk_StopAtEachNode_Success(t *testing.T) { + t.Parallel() + + input, err := os.ReadFile("testdata/ordering/input.arazzo.yaml") + require.NoError(t, err, "walk fixture should load") + doc, _, err := arazzo.Unmarshal(t.Context(), bytes.NewReader(input)) + require.NoError(t, err, "walk fixture should unmarshal") + var locations []string + for item := range arazzo.Walk(t.Context(), doc) { + locations = append(locations, item.Location.ToJSONPointer().String()) + } + require.NotEmpty(t, locations, "fixture should contain traversal nodes") + + for stopAt, location := range locations { + t.Run(strconv.Itoa(stopAt)+":"+location, func(t *testing.T) { + t.Parallel() + freshDoc, _, err := arazzo.Unmarshal(t.Context(), bytes.NewReader(input)) + require.NoError(t, err, "each traversal should have its own document") + var visited []string + arazzo.Walk(t.Context(), freshDoc)(func(item arazzo.WalkItem) bool { + visited = append(visited, item.Location.ToJSONPointer().String()) + return len(visited) <= stopAt + }) + assert.Equal(t, locations[:stopAt+1], visited, "a false yield must stop every ancestor without visiting later nodes") + }) + } +} + func TestWalk_Success(t *testing.T) { t.Parallel() ctx := t.Context() diff --git a/jsonschema/oas3/inline_test.go b/jsonschema/oas3/inline_test.go index bcb447b6..380790f8 100644 --- a/jsonschema/oas3/inline_test.go +++ b/jsonschema/oas3/inline_test.go @@ -1419,6 +1419,68 @@ func extractSchemaFromOpenAPI(openAPIDoc *openapi.OpenAPI, pointer string) (*oas return schema, nil } +func TestInline_AdditionalKeywordReferences_Success(t *testing.T) { + t.Parallel() + + input := `{ + "type": "object", + "properties": { + "matches": {"contains": {"$ref": "#/$defs/Text"}}, + "prefix": {"prefixItems": [{"$ref": "#/$defs/Text"}]}, + "conditional": { + "if": {"$ref": "#/$defs/Text"}, + "then": {"$ref": "#/$defs/Text"}, + "else": {"$ref": "#/$defs/Text"} + }, + "dependent": {"dependentSchemas": {"enabled": {"$ref": "#/$defs/Text"}}}, + "patterned": {"patternProperties": {"^x-": {"$ref": "#/$defs/Text"}}}, + "names": {"propertyNames": {"$ref": "#/$defs/Text"}}, + "unevaluated": { + "unevaluatedItems": {"$ref": "#/$defs/Text"}, + "unevaluatedProperties": {"$ref": "#/$defs/Text"} + }, + "negated": {"not": {"$ref": "#/$defs/Text"}} + }, + "$defs": {"Text": {"type": "string", "minLength": 1}} + }` + expected := `{ + "type": "object", + "properties": { + "matches": {"contains": {"type": "string", "minLength": 1}}, + "prefix": {"prefixItems": [{"type": "string", "minLength": 1}]}, + "conditional": { + "if": {"type": "string", "minLength": 1}, + "then": {"type": "string", "minLength": 1}, + "else": {"type": "string", "minLength": 1} + }, + "dependent": {"dependentSchemas": {"enabled": {"type": "string", "minLength": 1}}}, + "patterned": {"patternProperties": {"^x-": {"type": "string", "minLength": 1}}}, + "names": {"propertyNames": {"type": "string", "minLength": 1}}, + "unevaluated": { + "unevaluatedItems": {"type": "string", "minLength": 1}, + "unevaluatedProperties": {"type": "string", "minLength": 1} + }, + "negated": {"not": {"type": "string", "minLength": 1}} + } + }` + + schema, err := parseJSONToSchema(t.Context(), input) + require.NoError(t, err, "failed to parse input schema") + + inlined, err := oas3.Inline(t.Context(), schema, oas3.InlineOptions{ + ResolveOptions: oas3.ResolveOptions{ + TargetLocation: "schema.json", + RootDocument: schema, + }, + RemoveUnusedDefs: true, + }) + require.NoError(t, err, "inlining should succeed") + + actual, err := schemaToJSON(t.Context(), inlined) + require.NoError(t, err, "failed to marshal inlined schema") + assert.JSONEq(t, expected, actual, "references in schema-bearing keywords should be inlined") +} + func TestInline_EmailParser_PagerDuty_Success(t *testing.T) { t.Parallel() diff --git a/jsonschema/oas3/jsonschema_test.go b/jsonschema/oas3/jsonschema_test.go new file mode 100644 index 00000000..2837618d --- /dev/null +++ b/jsonschema/oas3/jsonschema_test.go @@ -0,0 +1,65 @@ +package oas3_test + +import ( + "testing" + + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/pointer" + "github.com/stretchr/testify/assert" +) + +func TestJSONSchema_DocumentMetadata_Success(t *testing.T) { + t.Parallel() + parent := &oas3.Schema{} + doc := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{ID: pointer.From("https://example.com/root.json#anchor")}) + assert.Equal(t, "https://example.com/root.json", doc.GetDocumentBaseURI(), "document base should come from the root ID without its fragment") + registry := doc.GetSchemaRegistry() + assert.NotNil(t, registry, "standalone documents should lazily create their registry") + assert.Same(t, registry, doc.GetSchemaRegistry(), "repeated registry lookups should return the same registry") + doc.SetDocumentBaseURI("https://example.com/override.json#fragment") + assert.Equal(t, "https://example.com/override.json", doc.GetDocumentBaseURI(), "an explicit document base should override the root ID") + replacement := oas3.NewSchemaRegistry("https://example.com/override.json") + doc.SetSchemaRegistry(replacement) + assert.Same(t, replacement, doc.GetSchemaRegistry(), "explicit registry replacement should be retained") + doc.SetEnclosingSchema(parent) + assert.Same(t, parent, doc.GetEnclosingSchema(), "enclosing schema should preserve its identity") + doc.SetEnclosingSchema(nil) + assert.Nil(t, doc.GetEnclosingSchema(), "enclosing schema should be clearable") + doc.SetDocumentBaseURI("") + assert.Equal(t, "https://example.com/root.json", doc.GetDocumentBaseURI(), "clearing the explicit base should restore the root ID") + assert.Empty(t, oas3.NewJSONSchemaFromBool(true).GetDocumentBaseURI(), "boolean schemas should have no implicit document base") +} + +func TestJSONSchema_NilMetadata_ReturnsDefaults(t *testing.T) { + t.Parallel() + var doc *oas3.JSONSchema[oas3.Referenceable] + doc.SetSchemaRegistry(oas3.NewSchemaRegistry("https://example.com")) + doc.SetDocumentBaseURI("https://example.com") + doc.SetEnclosingSchema(&oas3.Schema{}) + assert.Nil(t, doc.GetSchemaRegistry(), "nil schemas should not allocate registries") + assert.Empty(t, doc.GetDocumentBaseURI(), "nil schemas should have no base URI") + assert.Nil(t, doc.GetEnclosingSchema(), "nil schemas should have no enclosing schema") +} + +func TestSchema_OwningDocument_Success(t *testing.T) { + t.Parallel() + schema := &oas3.Schema{} + assert.Nil(t, schema.GetOwningDocument(), "new schemas should not have an owning document") + doc := oas3.NewJSONSchemaFromBool(true) + schema.SetOwningDocument(doc) + assert.Same(t, doc, schema.GetOwningDocument(), "document providers should retain their identity") + assert.Same(t, doc.GetSchemaRegistry(), schema.GetSchemaRegistry(), "child schemas should use their owning document registry") + schema.SetOwningDocument("not a document provider") + assert.Same(t, doc, schema.GetOwningDocument(), "unsupported providers should leave the existing owner unchanged") + schema.SetOwningDocument(nil) + assert.Nil(t, schema.GetOwningDocument(), "nil should explicitly clear document ownership") + assert.Nil(t, schema.GetSchemaRegistry(), "cleared ownership should no longer expose a registry") + + var absent *oas3.Schema + absent.SetOwningDocument(doc) + absent.SetEffectiveBaseURI("https://example.com") + absent.SetParent(doc) + assert.Nil(t, absent.GetOwningDocument(), "nil schemas should ignore document setters") + assert.Empty(t, absent.GetEffectiveBaseURI(), "nil schemas should ignore base URI setters") + assert.Nil(t, absent.GetParent(), "nil schemas should ignore parent setters") +} diff --git a/jsonschema/oas3/schema_getters_test.go b/jsonschema/oas3/schema_getters_test.go index d7af313e..064e7929 100644 --- a/jsonschema/oas3/schema_getters_test.go +++ b/jsonschema/oas3/schema_getters_test.go @@ -13,6 +13,18 @@ import ( "gopkg.in/yaml.v3" ) +func TestSchema_NilOptionalGetters_ReturnDefaults(t *testing.T) { + t.Parallel() + var schema *oas3.Schema + assert.Nil(t, schema.GetContentSchema(), "nil schemas should have no content schema") + assert.Nil(t, schema.GetMinProperties(), "nil schemas should have no property count constraint") + assert.Nil(t, schema.GetRequired(), "nil schemas should have no required properties") + assert.Nil(t, schema.GetEnum(), "nil schemas should have no enum values") + assert.False(t, schema.GetReadOnly(), "nil schemas should not be read-only") + assert.False(t, schema.GetWriteOnly(), "nil schemas should not be write-only") + assert.False(t, schema.GetDeprecated(), "nil schemas should not be deprecated") +} + func TestSchema_GetExclusiveMaximum_Success(t *testing.T) { t.Parallel() @@ -809,3 +821,53 @@ func TestSchema_GetMaxProperties_Success(t *testing.T) { schemaWithMaxProps := &oas3.Schema{MaxProperties: &maxProps} assert.Equal(t, &maxProps, schemaWithMaxProps.GetMaxProperties()) } + +func TestSchema_IsReferenceOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + schema *oas3.Schema + expected bool + }{ + {name: "nil schema", schema: nil}, + {name: "schema without reference", schema: &oas3.Schema{Title: pointer.From("title")}}, + {name: "reference only", schema: &oas3.Schema{Ref: pointer.From(references.Reference("#/components/schemas/User"))}, expected: true}, + {name: "reference with sibling field", schema: &oas3.Schema{Ref: pointer.From(references.Reference("#/components/schemas/User")), Title: pointer.From("User")}}, + {name: "reference with populated map", schema: &oas3.Schema{Ref: pointer.From(references.Reference("#/components/schemas/User")), Properties: sequencedmap.New(sequencedmap.NewElem("name", oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{})))}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, tt.schema.IsReferenceOnly(), "reference-only result should match schema contents") + }) + } +} + +func TestSchema_GetParent_Success(t *testing.T) { + t.Parallel() + + var nilSchema *oas3.Schema + assert.Nil(t, nilSchema.GetParent()) + + schema := &oas3.Schema{} + assert.Nil(t, schema.GetParent()) + + parent := oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{Title: pointer.From("parent")}) + schema.SetParent(parent) + assert.Same(t, parent, schema.GetParent(), "getter should return the parent set on the schema") +} + +func TestSchema_GetEffectiveBaseURI_Success(t *testing.T) { + t.Parallel() + + var nilSchema *oas3.Schema + assert.Empty(t, nilSchema.GetEffectiveBaseURI()) + + schema := &oas3.Schema{} + assert.Empty(t, schema.GetEffectiveBaseURI()) + + schema.SetEffectiveBaseURI("https://example.com/schemas/user") + assert.Equal(t, "https://example.com/schemas/user", schema.GetEffectiveBaseURI()) +} diff --git a/jsonschema/oas3/schema_isequal_test.go b/jsonschema/oas3/schema_isequal_test.go index 5966a8cc..53abf8f0 100644 --- a/jsonschema/oas3/schema_isequal_test.go +++ b/jsonschema/oas3/schema_isequal_test.go @@ -338,6 +338,103 @@ func TestSchema_IsEqual_Error(t *testing.T) { } } +func TestSchema_IsEqual_MismatchFields(t *testing.T) { + t.Parallel() + + child := func(title string) *JSONSchema[Referenceable] { + return NewJSONSchemaFromSchema[Referenceable](&Schema{Title: pointer.From(title)}) + } + value := func(text string) values.Value { + return &yaml.Node{Kind: yaml.ScalarNode, Value: text} + } + + tests := []struct { + name string + left *Schema + right *Schema + }{ + {name: "exclusive maximum", left: &Schema{ExclusiveMaximum: NewExclusiveMaximumFromFloat64(1)}, right: &Schema{ExclusiveMaximum: NewExclusiveMaximumFromFloat64(2)}}, + {name: "exclusive minimum", left: &Schema{ExclusiveMinimum: NewExclusiveMinimumFromFloat64(1)}, right: &Schema{ExclusiveMinimum: NewExclusiveMinimumFromFloat64(2)}}, + {name: "all of", left: &Schema{AllOf: []*JSONSchema[Referenceable]{child("left")}}, right: &Schema{AllOf: []*JSONSchema[Referenceable]{child("right")}}}, + {name: "one of", left: &Schema{OneOf: []*JSONSchema[Referenceable]{child("left")}}, right: &Schema{OneOf: []*JSONSchema[Referenceable]{child("right")}}}, + {name: "any of", left: &Schema{AnyOf: []*JSONSchema[Referenceable]{child("left")}}, right: &Schema{AnyOf: []*JSONSchema[Referenceable]{child("right")}}}, + {name: "prefix items", left: &Schema{PrefixItems: []*JSONSchema[Referenceable]{child("left")}}, right: &Schema{PrefixItems: []*JSONSchema[Referenceable]{child("right")}}}, + {name: "contains", left: &Schema{Contains: child("left")}, right: &Schema{Contains: child("right")}}, + {name: "if", left: &Schema{If: child("left")}, right: &Schema{If: child("right")}}, + {name: "else", left: &Schema{Else: child("left")}, right: &Schema{Else: child("right")}}, + {name: "then", left: &Schema{Then: child("left")}, right: &Schema{Then: child("right")}}, + {name: "not", left: &Schema{Not: child("left")}, right: &Schema{Not: child("right")}}, + {name: "property names", left: &Schema{PropertyNames: child("left")}, right: &Schema{PropertyNames: child("right")}}, + {name: "unevaluated items", left: &Schema{UnevaluatedItems: child("left")}, right: &Schema{UnevaluatedItems: child("right")}}, + {name: "unevaluated properties", left: &Schema{UnevaluatedProperties: child("left")}, right: &Schema{UnevaluatedProperties: child("right")}}, + {name: "items", left: &Schema{Items: child("left")}, right: &Schema{Items: child("right")}}, + {name: "additional properties", left: &Schema{AdditionalProperties: child("left")}, right: &Schema{AdditionalProperties: child("right")}}, + {name: "dependent schemas", left: &Schema{DependentSchemas: sequencedmap.New(sequencedmap.NewElem("key", child("left")))}, right: &Schema{DependentSchemas: sequencedmap.New(sequencedmap.NewElem("key", child("right")))}}, + {name: "pattern properties", left: &Schema{PatternProperties: sequencedmap.New(sequencedmap.NewElem("key", child("left")))}, right: &Schema{PatternProperties: sequencedmap.New(sequencedmap.NewElem("key", child("right")))}}, + {name: "properties", left: &Schema{Properties: sequencedmap.New(sequencedmap.NewElem("key", child("left")))}, right: &Schema{Properties: sequencedmap.New(sequencedmap.NewElem("key", child("right")))}}, + {name: "defs", left: &Schema{Defs: sequencedmap.New(sequencedmap.NewElem("key", child("left")))}, right: &Schema{Defs: sequencedmap.New(sequencedmap.NewElem("key", child("right")))}}, + {name: "minimum contains", left: &Schema{MinContains: pointer.From(int64(1))}, right: &Schema{MinContains: pointer.From(int64(2))}}, + {name: "maximum contains", left: &Schema{MaxContains: pointer.From(int64(1))}, right: &Schema{MaxContains: pointer.From(int64(2))}}, + {name: "anchor", left: &Schema{Anchor: pointer.From("left")}, right: &Schema{Anchor: pointer.From("right")}}, + {name: "multiple of", left: &Schema{MultipleOf: pointer.From(1.0)}, right: &Schema{MultipleOf: pointer.From(2.0)}}, + {name: "maximum", left: &Schema{Maximum: pointer.From(1.0)}, right: &Schema{Maximum: pointer.From(2.0)}}, + {name: "maximum length", left: &Schema{MaxLength: pointer.From(int64(1))}, right: &Schema{MaxLength: pointer.From(int64(2))}}, + {name: "minimum length", left: &Schema{MinLength: pointer.From(int64(1))}, right: &Schema{MinLength: pointer.From(int64(2))}}, + {name: "pattern", left: &Schema{Pattern: pointer.From("left")}, right: &Schema{Pattern: pointer.From("right")}}, + {name: "content encoding", left: &Schema{ContentEncoding: pointer.From("left")}, right: &Schema{ContentEncoding: pointer.From("right")}}, + {name: "content media type", left: &Schema{ContentMediaType: pointer.From("left")}, right: &Schema{ContentMediaType: pointer.From("right")}}, + {name: "content schema", left: &Schema{ContentSchema: child("left")}, right: &Schema{ContentSchema: child("right")}}, + {name: "format", left: &Schema{Format: pointer.From("left")}, right: &Schema{Format: pointer.From("right")}}, + {name: "maximum items", left: &Schema{MaxItems: pointer.From(int64(1))}, right: &Schema{MaxItems: pointer.From(int64(2))}}, + {name: "minimum items", left: &Schema{MinItems: pointer.From(int64(1))}, right: &Schema{MinItems: pointer.From(int64(2))}}, + {name: "unique items", left: &Schema{UniqueItems: pointer.From(true)}, right: &Schema{UniqueItems: pointer.From(false)}}, + {name: "maximum properties", left: &Schema{MaxProperties: pointer.From(int64(1))}, right: &Schema{MaxProperties: pointer.From(int64(2))}}, + {name: "minimum properties", left: &Schema{MinProperties: pointer.From(int64(1))}, right: &Schema{MinProperties: pointer.From(int64(2))}}, + {name: "read only", left: &Schema{ReadOnly: pointer.From(true)}, right: &Schema{ReadOnly: pointer.From(false)}}, + {name: "write only", left: &Schema{WriteOnly: pointer.From(true)}, right: &Schema{WriteOnly: pointer.From(false)}}, + {name: "deprecated", left: &Schema{Deprecated: pointer.From(true)}, right: &Schema{Deprecated: pointer.From(false)}}, + {name: "schema dialect", left: &Schema{Schema: pointer.From("left")}, right: &Schema{Schema: pointer.From("right")}}, + {name: "examples", left: &Schema{Examples: []values.Value{value("left")}}, right: &Schema{Examples: []values.Value{value("right")}}}, + {name: "enum", left: &Schema{Enum: []values.Value{value("left")}}, right: &Schema{Enum: []values.Value{value("right")}}}, + {name: "default", left: &Schema{Default: value("left")}, right: &Schema{Default: value("right")}}, + {name: "const", left: &Schema{Const: value("left")}, right: &Schema{Const: value("right")}}, + {name: "example", left: &Schema{Example: value("left")}, right: &Schema{Example: value("right")}}, + {name: "discriminator", left: &Schema{Discriminator: &Discriminator{PropertyName: "left"}}, right: &Schema{Discriminator: &Discriminator{PropertyName: "right"}}}, + {name: "XML", left: &Schema{XML: &XML{Name: pointer.From("left")}}, right: &Schema{XML: &XML{Name: pointer.From("right")}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.False(t, tt.left.IsEqual(tt.right), "schemas differing in %s should not be equal", tt.name) + }) + } +} + +func TestSchema_ShallowCopy_PopulatedCollections(t *testing.T) { + t.Parallel() + + child := NewJSONSchemaFromSchema[Referenceable](&Schema{Title: pointer.From("child")}) + property := NewJSONSchemaFromSchema[Referenceable](&Schema{Title: pointer.From("property")}) + schema := &Schema{ + AllOf: []*JSONSchema[Referenceable]{child}, + Required: []string{"name"}, + DependentSchemas: sequencedmap.New(sequencedmap.NewElem("dependency", child)), + Properties: sequencedmap.New(sequencedmap.NewElem("name", property)), + } + + copied := schema.ShallowCopy() + assert.NotSame(t, schema, copied) + assert.Same(t, child, copied.AllOf[0], "slice elements remain shared in a shallow copy") + copied.AllOf[0] = property + copied.Required[0] = "changed" + copied.Properties.Set("other", child) + assert.Same(t, child, schema.AllOf[0], "slice backing storage should be independent") + assert.Equal(t, []string{"name"}, schema.Required, "copied string slices should be independent") + assert.Equal(t, 1, schema.Properties.Len(), "copied maps should be independent") + assert.Equal(t, 2, copied.Properties.Len()) +} + func TestSchema_IsEqual_WithComplexTypes(t *testing.T) { t.Parallel() diff --git a/jsonschema/oas3/walk_test.go b/jsonschema/oas3/walk_test.go index e211a4c9..5e283b91 100644 --- a/jsonschema/oas3/walk_test.go +++ b/jsonschema/oas3/walk_test.go @@ -1,6 +1,7 @@ package oas3 import ( + "strconv" "testing" "github.com/speakeasy-api/openapi/extensions" @@ -10,6 +11,54 @@ import ( "github.com/stretchr/testify/require" ) +func TestWalk_AllKeywordsAndStops_Success(t *testing.T) { + t.Parallel() + + child := NewJSONSchemaFromBool(true) + schema := NewJSONSchemaFromSchema[Referenceable](&Schema{ + AllOf: []*JSONSchemaReferenceable{child}, OneOf: []*JSONSchemaReferenceable{child}, AnyOf: []*JSONSchemaReferenceable{child}, + Discriminator: &Discriminator{PropertyName: "kind", Extensions: extensions.New()}, + PrefixItems: []*JSONSchemaReferenceable{child}, Contains: child, If: child, Then: child, Else: child, + DependentSchemas: sequencedmap.New(sequencedmap.NewElem("id", child)), + PatternProperties: sequencedmap.New(sequencedmap.NewElem("^x-", child)), + PropertyNames: child, UnevaluatedItems: child, UnevaluatedProperties: child, + Items: child, ContentSchema: child, Not: child, + Properties: sequencedmap.New(sequencedmap.NewElem("name", child)), + Defs: sequencedmap.New(sequencedmap.NewElem("Pet", child)), AdditionalProperties: child, + ExternalDocs: &ExternalDocumentation{URL: "https://example.com", Extensions: extensions.New()}, + XML: &XML{Name: pointer.From("pet"), Extensions: extensions.New()}, Extensions: extensions.New(), + }) + want := []string{ + "/", "/allOf/0", "/oneOf/0", "/anyOf/0", "/discriminator", "/discriminator", + "/prefixItems/0", "/contains", "/if", "/then", "/else", "/dependentSchemas/id", "/patternProperties/^x-", + "/propertyNames", "/unevaluatedItems", "/unevaluatedProperties", "/items", "/contentSchema", "/not", + "/properties/name", "/$defs/Pet", "/additionalProperties", "/externalDocs", "/externalDocs", "/xml", "/xml", "/", + } + var visited []string + for item := range Walk(t.Context(), schema) { + visited = append(visited, item.Location.ToJSONPointer().String()) + assert.Same(t, schema, item.Schema, "nested items should retain the root schema") + err := item.Match(SchemaMatcher{Any: func(model any) error { + assert.NotNil(t, model, "each walk item should match its model") + return nil + }}) + require.NoError(t, err, "matching a walk item should succeed") + } + assert.Equal(t, want, visited, "all schema keywords and metadata should have exact traversal locations") + + for stopAt, location := range want { + t.Run(strconv.Itoa(stopAt)+":"+location, func(t *testing.T) { + t.Parallel() + var prefix []string + Walk(t.Context(), schema)(func(item SchemaWalkItem) bool { + prefix = append(prefix, item.Location.ToJSONPointer().String()) + return len(prefix) <= stopAt + }) + assert.Equal(t, want[:stopAt+1], prefix, "stopping at any keyword or metadata node should stop the entire walk") + }) + } +} + func TestWalk_Success(t *testing.T) { t.Parallel() // Create a simple schema for testing diff --git a/linter/doc_test.go b/linter/doc_test.go index 5ebfb605..9f0d0f8c 100644 --- a/linter/doc_test.go +++ b/linter/doc_test.go @@ -3,6 +3,8 @@ package linter_test import ( "bytes" "encoding/json" + "errors" + "strconv" "testing" "github.com/speakeasy-api/openapi/linter" @@ -255,6 +257,72 @@ func TestDocGenerator_WriteMarkdown_WithVersions(t *testing.T) { assert.Contains(t, output, "**Applies to:** 3.1.0, 3.2.0") } +func TestDocGenerator_WriteMarkdown_OptionalSectionsAndErrors(t *testing.T) { + t.Parallel() + + registry := linter.NewRegistry[*MockDoc]() + registry.Register(&configurableDocumentedRule{documentedMockRule: documentedMockRule{ + mockRule: mockRule{ + id: "full-rule", category: "style", summary: "Rule summary", description: "Rule description", + link: "https://example.com/rule", defaultSeverity: validation.SeverityWarning, versions: []string{"3.1.0"}, + }, + goodExample: "good: true", badExample: "bad: true", rationale: "Rule rationale", fixAvailable: true, + }}) + generator := linter.NewDocGenerator(registry) + writeErr := errors.New("documentation writer failed") + complete := &docFailureWriter{err: writeErr} + require.NoError(t, generator.WriteMarkdown(complete), "fully documented rules should render") + for _, section := range []string{"**Auto-fix available:** Yes", "#### Rationale", "Rule rationale", "#### โŒ Incorrect", "bad: true", "#### โœ… Correct", "good: true", "#### Configuration", "| Option | Type | Default | Description |", "[Documentation โ†’](https://example.com/rule)"} { + assert.Contains(t, complete.String(), section, "optional section should be included") + } + + for failAt := 1; failAt <= complete.calls; failAt++ { + t.Run("write_"+strconv.Itoa(failAt), func(t *testing.T) { + t.Parallel() + writer := &docFailureWriter{failAt: failAt, err: writeErr} + err := generator.WriteMarkdown(writer) + require.ErrorIs(t, err, writeErr, "each failed write should preserve the original error") + assert.Equal(t, failAt, writer.calls, "documentation generation should stop immediately after a write failure") + }) + } +} + +func TestDocGenerator_WriteJSON_Error(t *testing.T) { + t.Parallel() + + generator := linter.NewDocGenerator(linter.NewRegistry[*MockDoc]()) + writeErr := errors.New("JSON writer failed") + writer := &docFailureWriter{failAt: 1, err: writeErr} + require.ErrorIs(t, generator.WriteJSON(writer), writeErr, "JSON generation should preserve writer errors") +} + +type docFailureWriter struct { + bytes.Buffer + calls int + failAt int + err error +} + +func (w *docFailureWriter) Write(data []byte) (int, error) { + w.calls++ + if w.calls == w.failAt { + return 0, w.err + } + return w.Buffer.Write(data) +} + +type configurableDocumentedRule struct { + documentedMockRule +} + +func (r *configurableDocumentedRule) ConfigSchema() map[string]any { + return map[string]any{"enabled": map[string]any{"type": "boolean"}} +} + +func (r *configurableDocumentedRule) ConfigDefaults() map[string]any { + return map[string]any{"enabled": true} +} + // documentedMockRule implements DocumentedRule interface type documentedMockRule struct { mockRule diff --git a/marshaller/populator_test.go b/marshaller/populator_test.go index 3a78b79d..f26ed688 100644 --- a/marshaller/populator_test.go +++ b/marshaller/populator_test.go @@ -6,9 +6,88 @@ import ( "github.com/speakeasy-api/openapi/marshaller" "github.com/speakeasy-api/openapi/marshaller/tests" "github.com/speakeasy-api/openapi/marshaller/tests/core" + "github.com/speakeasy-api/openapi/yml" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) +func TestPopulateWithContext_YAMLNode_Success(t *testing.T) { + t.Parallel() + + t.Run("node value to pointer", func(t *testing.T) { + t.Parallel() + source := yml.CreateStringNode("value") + var target *yaml.Node + require.NoError(t, marshaller.PopulateWithContext(*source, &target, nil), "node values should populate pointer targets") + assert.Equal(t, source, target, "node metadata should be copied") + assert.NotSame(t, source, target, "a value source should produce a distinct node") + }) + t.Run("node pointer to value", func(t *testing.T) { + t.Parallel() + source := yml.CreateStringNode("value") + var target yaml.Node + require.NoError(t, marshaller.PopulateWithContext(source, &target, nil), "node pointers should populate value targets") + assert.Equal(t, *source, target, "node metadata should be copied") + }) + t.Run("node pointer to double pointer", func(t *testing.T) { + t.Parallel() + source := yml.CreateStringNode("value") + var target **yaml.Node + require.NoError(t, marshaller.PopulateWithContext(source, &target, nil), "double pointer targets should be supported") + require.NotNil(t, target, "target pointer should be initialised") + assert.Same(t, source, *target, "pointer sources should retain the node identity") + }) +} + +func TestPopulateWithContext_NilValues_Success(t *testing.T) { + t.Parallel() + + t.Run("nil pointer clears scalar", func(t *testing.T) { + t.Parallel() + var source *string + target := "old value" + require.NoError(t, marshaller.PopulateWithContext(source, &target, nil), "nil pointers should reset scalar targets") + assert.Empty(t, target, "nil sources should clear stale scalar values") + }) + t.Run("nil node value clears scalar", func(t *testing.T) { + t.Parallel() + target := "old value" + require.NoError(t, marshaller.PopulateWithContext(marshaller.Node[any]{}, &target, nil), "nil node values should be supported") + assert.Empty(t, target, "nil node values should clear stale scalar values") + }) + t.Run("nil model source leaves target untouched", func(t *testing.T) { + t.Parallel() + var source *struct{ Value string } + target := struct{ Value string }{Value: "retained"} + require.NoError(t, marshaller.PopulateModelWithContext(source, &target, nil), "absent model sources should be ignored") + assert.Equal(t, "retained", target.Value, "absent model sources should not alter the target") + }) +} + +func TestPopulateWithContext_Conversion_Error(t *testing.T) { + t.Parallel() + + t.Run("incompatible scalar", func(t *testing.T) { + t.Parallel() + var target int + err := marshaller.PopulateWithContext(map[string]string{"key": "value"}, &target, nil) + require.ErrorContains(t, err, "cannot convert", "incompatible source types should be reported") + }) + t.Run("incompatible slice element", func(t *testing.T) { + t.Parallel() + var target []int + err := marshaller.PopulateWithContext([]map[string]string{{"key": "value"}}, &target, nil) + require.ErrorContains(t, err, "cannot convert", "slice element conversion errors should propagate") + }) + t.Run("model source is not a struct", func(t *testing.T) { + t.Parallel() + var target struct{ Value string } + err := marshaller.PopulateModelWithContext(42, &target, nil) + require.ErrorContains(t, err, "expected `struct`, got `int`", "model population should reject scalar sources") + }) +} + func TestPopulation_PrimitiveTypes_Success(t *testing.T) { t.Parallel() diff --git a/marshaller/unmarshaller_test.go b/marshaller/unmarshaller_test.go new file mode 100644 index 00000000..97fd240f --- /dev/null +++ b/marshaller/unmarshaller_test.go @@ -0,0 +1,123 @@ +package marshaller_test + +import ( + "errors" + "io" + "strings" + "testing" + "testing/iotest" + + "github.com/speakeasy-api/openapi/marshaller" + testmodels "github.com/speakeasy-api/openapi/marshaller/tests" + "github.com/speakeasy-api/openapi/yml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestUnmarshal_DocumentInput_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reader io.Reader + want string + }{ + {"empty document", strings.NewReader(""), "empty document"}, + {"reader failure", iotest.ErrReader(errors.New("read failure")), "failed to read document: read failure"}, + {"invalid YAML", strings.NewReader("["), "failed to unmarshal document"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var model testmodels.TestPrimitiveHighModel + validationErrs, err := marshaller.Unmarshal(t.Context(), tt.reader, &model) + require.ErrorContains(t, err, tt.want, "document input errors should be reported") + assert.Empty(t, validationErrs, "input failures should not be reported as model validation errors") + }) + } +} + +func TestUnmarshalCore_DocumentCardinality_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + node *yaml.Node + want string + }{ + {"no root", &yaml.Node{Kind: yaml.DocumentNode}, "expected 1 node, got `0`"}, + {"multiple roots", &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{yml.CreateStringNode("a"), yml.CreateStringNode("b")}}, "expected 1 node, got `2`"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var output string + _, err := marshaller.UnmarshalCore(t.Context(), "test", tt.node, &output) + require.ErrorContains(t, err, tt.want, "a document should contain exactly one root node") + }) + } +} + +func TestUnmarshalModel_InvalidModel_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output any + want string + }{ + {"scalar output", new(int), "expected a struct"}, + {"empty struct", &struct{}{}, "expected embedded CoreModel field"}, + {"wrong first field", &struct{ Value string }{}, "expected embedded CoreModel field to be of type CoreModel"}, + {"missing model tag", &struct{ marshaller.CoreModel }{}, "expected embedded CoreModel field to have a 'model' tag"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := marshaller.UnmarshalModel(t.Context(), parseYAML(t, "{}"), tt.output) + require.ErrorContains(t, err, tt.want, "invalid custom models should return a useful contract error") + }) + } +} + +func TestUnmarshalCore_UnsupportedMaps_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + output any + }{ + {"plain map", "{key: value}", &map[string]string{}}, + {"map in sequence", "[{key: value}]", &[]map[string]string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := marshaller.UnmarshalCore(t.Context(), "test", parseYAML(t, tt.input), tt.output) + require.ErrorContains(t, err, "currently unsupported out kind: `map`", "unsupported maps should return errors, including within slices") + }) + } +} + +func TestUnmarshalCore_RawNode_Success(t *testing.T) { + t.Parallel() + + node := yml.CreateStringNode("value") + var output yaml.Node + validationErrs, err := marshaller.UnmarshalCore(t.Context(), "test", node, &output) + require.NoError(t, err, "raw node targets should be supported") + assert.Empty(t, validationErrs, "raw nodes should not require scalar conversion") + assert.Equal(t, *node, output, "raw node metadata should be preserved") +} + +func TestUnmarshalCore_UnresolvedAlias_Success(t *testing.T) { + t.Parallel() + + output := "unchanged" + validationErrs, err := marshaller.UnmarshalCore(t.Context(), "test", &yaml.Node{Kind: yaml.AliasNode}, &output) + require.NoError(t, err, "aliases without a resolved node should be ignored") + assert.Empty(t, validationErrs, "unresolved aliases should not add validation errors") + assert.Equal(t, "unchanged", output, "an unresolved alias should not overwrite the target") +} diff --git a/openapi/bundle_test.go b/openapi/bundle_test.go index ed428e0d..10884c0c 100644 --- a/openapi/bundle_test.go +++ b/openapi/bundle_test.go @@ -3,6 +3,8 @@ package openapi_test import ( "bytes" "os" + "path/filepath" + "strings" "testing" "github.com/speakeasy-api/openapi/openapi" @@ -51,6 +53,186 @@ func TestBundle_Success(t *testing.T) { assert.Equal(t, string(expectedBytes), string(actualYAML), "Bundled document should match expected output") } +func TestBundle_ExternalComponents_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + dir := t.TempDir() + external := `components: + callbacks: + Event: + '{$request.body#/callbackUrl}': + $ref: '#/components/pathItems/EventPath' + pathItems: + EventPath: + parameters: + - $ref: './parameter.yaml#/Id' + post: + responses: + '204': + description: Accepted + headers: + Trace: + schema: + $ref: './schema.yaml#/Value' + examples: + sample: + $ref: '#/components/examples/Sample' + examples: + Sample: + value: trace-id + links: + Next: + operationId: nextPage + securitySchemes: + Token: + type: apiKey + in: header + name: X-Token + responses: + Result: + description: Result + headers: + X-Trace: + $ref: '#/components/headers/Trace' + content: + application/json: + schema: + $ref: './schema.yaml#/Value' + application/jsonl: + itemSchema: + $ref: './schema.yaml#/Value' + requestBodies: + Input: + content: + application/json: + schema: + $ref: './schema.yaml#/Value' + application/jsonl: + itemSchema: + $ref: './schema.yaml#/Value' +` + require.NoError(t, os.MkdirAll(filepath.Join(dir, "api"), 0o755), "create external document directory") + require.NoError(t, os.WriteFile(filepath.Join(dir, "api/components.yaml"), []byte(external), 0o600), "write external components") + require.NoError(t, os.WriteFile(filepath.Join(dir, "api/schema.yaml"), []byte("Value:\n type: string\n enum: [trace-id]\n"), 0o600), "write source-relative schema") + require.NoError(t, os.WriteFile(filepath.Join(dir, "api/parameter.yaml"), []byte(`Id: + name: id + in: query + schema: + $ref: './schema.yaml#/Value' + examples: + sample: + $ref: './components.yaml#/components/examples/Sample' +`), 0o600), "write source-relative parameter") + + doc, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(`openapi: 3.2.0 +info: + title: External components + version: 1.0.0 +paths: + /events: + $ref: './api/components.yaml#/components/pathItems/EventPath' +components: + callbacks: + ImportedEvent: + $ref: './api/components.yaml#/components/callbacks/Event' + headers: + ImportedTrace: + $ref: './api/components.yaml#/components/headers/Trace' + examples: + ImportedSample: + $ref: './api/components.yaml#/components/examples/Sample' + links: + ImportedNext: + $ref: './api/components.yaml#/components/links/Next' + securitySchemes: + ImportedToken: + $ref: './api/components.yaml#/components/securitySchemes/Token' + responses: + ImportedResult: + $ref: './api/components.yaml#/components/responses/Result' + requestBodies: + ImportedInput: + $ref: './api/components.yaml#/components/requestBodies/Input' +`)) + require.NoError(t, err, "unmarshal root document") + require.Empty(t, validationErrs, "root document should be valid") + err = openapi.Bundle(ctx, doc, openapi.BundleOptions{ + ResolveOptions: openapi.ResolveOptions{RootDocument: doc, TargetLocation: filepath.Join(dir, "openapi.yaml")}, + NamingStrategy: openapi.BundleNamingCounter, + }) + require.NoError(t, err, "bundle external components and nested source-relative references") + + assert.Equal(t, "#/components/pathItems/EventPath", string(doc.Paths.GetOrZero("/events").GetReference()), "path should reference the bundled path item") + assert.Equal(t, "#/components/callbacks/Event", string(doc.Components.Callbacks.GetOrZero("ImportedEvent").GetReference()), "callback should be localized") + assert.Equal(t, "#/components/headers/Trace", string(doc.Components.Headers.GetOrZero("ImportedTrace").GetReference()), "header should be localized") + assert.Equal(t, "#/components/examples/Sample", string(doc.Components.Examples.GetOrZero("ImportedSample").GetReference()), "example should be localized") + assert.Equal(t, "#/components/links/Next", string(doc.Components.Links.GetOrZero("ImportedNext").GetReference()), "link should be localized") + assert.Equal(t, "#/components/securitySchemes/Token", string(doc.Components.SecuritySchemes.GetOrZero("ImportedToken").GetReference()), "security scheme should be localized") + assert.Equal(t, "#/components/responses/Result", string(doc.Components.Responses.GetOrZero("ImportedResult").GetReference()), "response should be localized") + assert.Equal(t, "#/components/requestBodies/Input", string(doc.Components.RequestBodies.GetOrZero("ImportedInput").GetReference()), "request body should be localized") + + callback := doc.Components.Callbacks.GetOrZero("Event").GetObject() + require.NotNil(t, callback, "callback object should be bundled") + assert.Equal(t, "#/components/pathItems/EventPath", string(callback.GetOrZero("{$request.body#/callbackUrl}").GetReference()), "callback path should use a local reference") + pathItem := doc.Components.PathItems.GetOrZero("EventPath").GetObject() + require.NotNil(t, pathItem, "path item should be bundled") + assert.Equal(t, "#/components/parameters/Id", string(pathItem.Parameters[0].GetReference()), "path parameter should resolve relative to its source document") + parameter := doc.Components.Parameters.GetOrZero("Id").GetObject() + require.NotNil(t, parameter, "parameter should be bundled") + assert.Equal(t, "#/components/schemas/Value", string(parameter.Schema.GetRef()), "parameter schema should be localized") + assert.Equal(t, "#/components/examples/Sample", string(parameter.Examples.GetOrZero("sample").GetReference()), "parameter example should be localized") + header := doc.Components.Headers.GetOrZero("Trace").GetObject() + require.NotNil(t, header, "header object should be bundled") + assert.Equal(t, "#/components/schemas/Value", string(header.Schema.GetRef()), "header schema should be localized") + assert.Equal(t, "#/components/examples/Sample", string(header.Examples.GetOrZero("sample").GetReference()), "header example should be localized") + response := doc.Components.Responses.GetOrZero("Result").GetObject() + require.NotNil(t, response, "response object should be bundled") + assert.Equal(t, "#/components/headers/Trace", string(response.Headers.GetOrZero("X-Trace").GetReference()), "response header should be localized") + assert.Equal(t, "#/components/schemas/Value", string(response.Content.GetOrZero("application/json").Schema.GetRef()), "response schema should be localized") + assert.Equal(t, "#/components/schemas/Value", string(response.Content.GetOrZero("application/jsonl").ItemSchema.GetRef()), "response item schema should be localized") + body := doc.Components.RequestBodies.GetOrZero("Input").GetObject() + require.NotNil(t, body, "request body should be bundled") + assert.Equal(t, "#/components/schemas/Value", string(body.Content.GetOrZero("application/json").Schema.GetRef()), "request body schema should be localized") + assert.Equal(t, "#/components/schemas/Value", string(body.Content.GetOrZero("application/jsonl").ItemSchema.GetRef()), "request body item schema should be localized") + assert.Equal(t, 1, doc.Components.Schemas.Len(), "shared source schema should be bundled only once") + example := doc.Components.Examples.GetOrZero("Sample").GetObject() + require.NotNil(t, example, "example object should be bundled") + assert.Equal(t, "trace-id", example.Value.Value, "example content should be preserved") + link := doc.Components.Links.GetOrZero("Next").GetObject() + require.NotNil(t, link, "link object should be bundled") + assert.Equal(t, "nextPage", link.GetOperationID(), "link content should be preserved") + scheme := doc.Components.SecuritySchemes.GetOrZero("Token").GetObject() + require.NotNil(t, scheme, "security scheme object should be bundled") + assert.Equal(t, "X-Token", scheme.GetName(), "security scheme content should be preserved") + + var output bytes.Buffer + require.NoError(t, openapi.Marshal(ctx, doc, &output), "marshal bundled document") + assert.NotContains(t, output.String(), "./api/", "root external references should be removed") + assert.NotContains(t, output.String(), "./schema.yaml", "nested source-relative references should be removed") +} + +func TestBundle_MissingExternalComponent_Error(t *testing.T) { + t.Parallel() + + for _, section := range []string{"schemas", "callbacks", "pathItems", "headers", "links", "examples", "securitySchemes", "responses", "parameters", "requestBodies"} { + t.Run(section, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + doc, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader("openapi: 3.1.0\ninfo:\n title: Missing component\n version: 1.0.0\ncomponents:\n "+section+":\n Missing:\n $ref: './missing.yaml#/Missing'\n")) + require.NoError(t, err, "unmarshal document with unresolved reference") + require.Empty(t, validationErrs, "document should be valid before resolving") + err = openapi.Bundle(ctx, doc, openapi.BundleOptions{ + ResolveOptions: openapi.ResolveOptions{RootDocument: doc, TargetLocation: filepath.Join(t.TempDir(), "openapi.yaml")}, + }) + require.Error(t, err, "missing external component should fail bundling") + assert.Contains(t, err.Error(), "/components/"+section+"/Missing", "error should identify the source reference") + assert.Contains(t, err.Error(), "missing.yaml", "error should identify the missing file") + }) + } +} + func TestBundle_CounterNaming_Success(t *testing.T) { t.Parallel() diff --git a/openapi/index_test.go b/openapi/index_test.go index 3f6b25b2..5e8e9feb 100644 --- a/openapi/index_test.go +++ b/openapi/index_test.go @@ -21,6 +21,104 @@ func unmarshalOpenAPI(t *testing.T, ctx context.Context, yaml string) *openapi.O return o } +func assertExternalIndexNodes[T openapi.ReferenceNode](t *testing.T, nodes []*openapi.IndexNode[T]) { + t.Helper() + require.Len(t, nodes, 1, "index should classify one resolved external object") + assert.Empty(t, nodes[0].Location, "external object should be indexed at the external document root") + assert.False(t, nodes[0].Node.IsReference(), "external category should contain the resolved object, not the reference") +} + +func TestBuildIndex_ExternalComponentAliasChains_Success(t *testing.T) { + t.Parallel() + ctx := t.Context() + vfs := NewMockVirtualFS() + vfs.AddFile("/api/aliases.yaml", ` +Response: {$ref: 'objects.yaml#/Response'} +Parameter: {$ref: 'objects.yaml#/Parameter'} +Example: {$ref: 'objects.yaml#/Example'} +RequestBody: {$ref: 'objects.yaml#/RequestBody'} +Header: {$ref: 'objects.yaml#/Header'} +Link: {$ref: 'objects.yaml#/Link'} +Callback: {$ref: 'objects.yaml#/Callback'} +PathItem: {$ref: 'objects.yaml#/PathItem'} +SecurityScheme: {$ref: 'objects.yaml#/SecurityScheme'} +`) + vfs.AddFile("/api/objects.yaml", ` +Response: {description: external response} +Parameter: + name: id + in: query + schema: {type: integer} +Example: {value: external example} +RequestBody: {content: {application/json: {schema: {type: string}}}} +Header: {schema: {type: string}} +Link: {operationId: externalOperation} +Callback: {} +PathItem: + summary: External path + description: Resolved through alias + get: + operationId: externalOperation + responses: + '200': {description: path response} +SecurityScheme: {type: http, scheme: bearer} +`) + doc := unmarshalOpenAPI(t, ctx, ` +openapi: 3.1.0 +info: {title: External components, version: 1.0.0} +paths: {} +components: + responses: + Shared: {$ref: 'aliases.yaml#/Response'} + parameters: + Shared: {$ref: 'aliases.yaml#/Parameter'} + examples: + Shared: {$ref: 'aliases.yaml#/Example'} + requestBodies: + Shared: {$ref: 'aliases.yaml#/RequestBody'} + headers: + Shared: {$ref: 'aliases.yaml#/Header'} + links: + Shared: {$ref: 'aliases.yaml#/Link'} + callbacks: + Shared: {$ref: 'aliases.yaml#/Callback'} + pathItems: + Shared: {$ref: 'aliases.yaml#/PathItem'} + securitySchemes: + Shared: {$ref: 'aliases.yaml#/SecurityScheme'} +`) + idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{RootDocument: doc, TargetDocument: doc, TargetLocation: "/api/openapi.yaml", VirtualFS: vfs}) + require.Empty(t, idx.GetAllErrors(), "external component alias chains should resolve without errors") + assertExternalIndexNodes(t, idx.ExternalResponses) + assertExternalIndexNodes(t, idx.ExternalParameters) + assertExternalIndexNodes(t, idx.ExternalExamples) + assertExternalIndexNodes(t, idx.ExternalRequestBodies) + assertExternalIndexNodes(t, idx.ExternalHeaders) + assertExternalIndexNodes(t, idx.ExternalLinks) + assertExternalIndexNodes(t, idx.ExternalCallbacks) + assertExternalIndexNodes(t, idx.ExternalPathItems) + assert.Empty(t, idx.ComponentResponses, "referenced components are not local object definitions") + assert.Empty(t, idx.ComponentParameters, "referenced parameters are not local object definitions") + assert.Equal(t, "external response", idx.ExternalResponses[0].Node.GetObject().Description, "index should contain the final response, not its alias") + require.NotNil(t, idx.ExternalExamples[0].Node.GetObject().Value, "resolved example should contain a value") + assert.Equal(t, "external example", idx.ExternalExamples[0].Node.GetObject().Value.Value, "index should contain the final example value") + assert.Equal(t, "externalOperation", idx.ExternalLinks[0].Node.GetObject().GetOperationID(), "index should contain the final link target") + assert.Equal(t, "integer", string(idx.ExternalParameters[0].Node.GetObject().Schema.GetLeft().GetType()[0]), "final parameter should retain its schema") + assert.Equal(t, "bearer", doc.Components.SecuritySchemes.GetOrZero("Shared").GetObject().GetScheme(), "security scheme alias should resolve") + assert.Equal(t, "aliases.yaml#/Response", string(doc.Components.Responses.GetOrZero("Shared").GetReference()), "resolving an alias should preserve the original reference") + + actualRefs := make(map[string]string) + for _, ref := range idx.GetAllReferences() { + actualRefs[ref.Location.ToJSONPointer().String()] = string(ref.Node.GetReference()) + } + for section, target := range map[string]string{ + "responses": "Response", "parameters": "Parameter", "examples": "Example", "requestBodies": "RequestBody", + "headers": "Header", "links": "Link", "callbacks": "Callback", "pathItems": "PathItem", "securitySchemes": "SecurityScheme", + } { + assert.Equal(t, "aliases.yaml#/"+target, actualRefs["/components/"+section+"/Shared"], "index should preserve original reference and its main-document location") + } +} + func TestBuildIndex_EmptyDoc_Success(t *testing.T) { t.Parallel() ctx := t.Context() diff --git a/openapi/join_test.go b/openapi/join_test.go index d7018afe..5d54f90b 100644 --- a/openapi/join_test.go +++ b/openapi/join_test.go @@ -3,11 +3,13 @@ package openapi_test import ( "bytes" "os" + "strings" "testing" "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestJoin_Counter_Success(t *testing.T) { @@ -335,3 +337,60 @@ func TestJoin_ServersSecurityConflicts_Success(t *testing.T) { assert.Equal(t, string(expectedBytes), string(actualYAML), "Joined document with server/security conflicts should match expected output") } + +func TestJoin_ComponentConflictsRewriteLocalReferences_Success(t *testing.T) { + t.Parallel() + + components := []struct { + section string + definition string + }{ + {section: "schemas", definition: "{type: string, description: source}"}, + {section: "responses", definition: "{description: source}"}, + {section: "parameters", definition: "{name: id, in: query, description: source, schema: {type: string}}"}, + {section: "examples", definition: "{description: source, value: example}"}, + {section: "requestBodies", definition: "{description: source, content: {application/json: {schema: {type: string}}}}"}, + {section: "headers", definition: "{description: source, schema: {type: string}}"}, + {section: "links", definition: "{description: source, operationId: getItem}"}, + {section: "callbacks", definition: "{'{$request.query.callbackUrl}': {post: {responses: {'200': {description: source}}}}}"}, + {section: "pathItems", definition: "{description: source, get: {responses: {'200': {description: source}}}}"}, + } + strategies := []struct { + name string + strategy openapi.JoinConflictStrategy + reserved string + renamed string + }{ + {name: "counter", strategy: openapi.JoinConflictCounter, reserved: "Shared_1", renamed: "Shared_2"}, + {name: "filepath", strategy: openapi.JoinConflictFilePath, reserved: "subdir_second_yaml__Shared", renamed: "subdir_second_yaml__Shared_1"}, + {name: "unknown strategy falls back to counter", strategy: openapi.JoinConflictStrategy(99), reserved: "Shared_1", renamed: "Shared_2"}, + } + + for _, component := range components { + for _, strategy := range strategies { + t.Run(component.section+"/"+strategy.name, func(t *testing.T) { + t.Parallel() + ctx := t.Context() + prefix := "openapi: 3.1.0\ninfo: {title: Join API, version: 1.0.0}\npaths: {}\ncomponents:\n " + component.section + ":\n" + mainDefinition := strings.ReplaceAll(component.definition, "source", "main") + main := unmarshalOpenAPI(t, ctx, prefix+" Shared: "+mainDefinition+"\n "+strategy.reserved+": "+mainDefinition+"\n") + source := unmarshalOpenAPI(t, ctx, prefix+" Shared: "+component.definition+"\n Alias: {$ref: '#/components/"+component.section+"/Shared'}\n External: {$ref: 'external.yaml#/Shared'}\n") + + err := openapi.Join(ctx, main, []openapi.JoinDocumentInfo{{Document: source, FilePath: "subdir/second.yaml"}}, openapi.JoinOptions{ConflictStrategy: strategy.strategy}) + require.NoError(t, err, "join conflicting components") + var output bytes.Buffer + require.NoError(t, openapi.Marshal(ctx, main, &output), "marshal joined document") + var joined struct { + Components map[string]map[string]map[string]any `yaml:"components"` + } + require.NoError(t, yaml.Unmarshal(output.Bytes(), &joined), "read joined component values") + section := joined.Components[component.section] + assert.Len(t, section, 5, "retain both originals, renamed source, and aliases") + assert.Contains(t, section, strategy.renamed, "source component should have a unique conflict name") + assert.Equal(t, "#/components/"+component.section+"/"+strategy.renamed, section["Alias"]["$ref"], "local alias should target the renamed source") + assert.Equal(t, "external.yaml#/Shared", section["External"]["$ref"], "external reference should remain untouched") + assert.NotEqual(t, section["Shared"], section[strategy.renamed], "main and source component contents should remain distinct") + }) + } + } +} diff --git a/openapi/linter/rules/oas_schema_check_test.go b/openapi/linter/rules/oas_schema_check_test.go index 61c660ba..5cef673a 100644 --- a/openapi/linter/rules/oas_schema_check_test.go +++ b/openapi/linter/rules/oas_schema_check_test.go @@ -54,22 +54,7 @@ paths: {} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx := t.Context() - - doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(tt.yaml)) - require.NoError(t, err) - - rule := &OASSchemaCheckRule{} - config := &linter.RuleConfig{} - - idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{ - RootDocument: doc, - TargetDocument: doc, - TargetLocation: "test.yaml", - }) - docInfo := linter.NewDocumentInfoWithIndex(doc, "test.yaml", idx) - - errs := rule.Run(ctx, docInfo, config) + errs := runOASSchemaCheckTest(t, tt.yaml) assert.Empty(t, errs) }) } @@ -137,22 +122,7 @@ paths: {} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx := t.Context() - - doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(tt.yaml)) - require.NoError(t, err) - - rule := &OASSchemaCheckRule{} - config := &linter.RuleConfig{} - - idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{ - RootDocument: doc, - TargetDocument: doc, - TargetLocation: "test.yaml", - }) - docInfo := linter.NewDocumentInfoWithIndex(doc, "test.yaml", idx) - - errs := rule.Run(ctx, docInfo, config) + errs := runOASSchemaCheckTest(t, tt.yaml) assert.Len(t, errs, tt.expected) }) } @@ -201,22 +171,7 @@ paths: {} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx := t.Context() - - doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(tt.yaml)) - require.NoError(t, err) - - rule := &OASSchemaCheckRule{} - config := &linter.RuleConfig{} - - idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{ - RootDocument: doc, - TargetDocument: doc, - TargetLocation: "test.yaml", - }) - docInfo := linter.NewDocumentInfoWithIndex(doc, "test.yaml", idx) - - errs := rule.Run(ctx, docInfo, config) + errs := runOASSchemaCheckTest(t, tt.yaml) assert.Empty(t, errs) }) } @@ -268,22 +223,7 @@ paths: {} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx := t.Context() - - doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(tt.yaml)) - require.NoError(t, err) - - rule := &OASSchemaCheckRule{} - config := &linter.RuleConfig{} - - idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{ - RootDocument: doc, - TargetDocument: doc, - TargetLocation: "test.yaml", - }) - docInfo := linter.NewDocumentInfoWithIndex(doc, "test.yaml", idx) - - errs := rule.Run(ctx, docInfo, config) + errs := runOASSchemaCheckTest(t, tt.yaml) assert.Len(t, errs, tt.expected) }) } @@ -336,22 +276,7 @@ paths: {} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx := t.Context() - - doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(tt.yaml)) - require.NoError(t, err) - - rule := &OASSchemaCheckRule{} - config := &linter.RuleConfig{} - - idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{ - RootDocument: doc, - TargetDocument: doc, - TargetLocation: "test.yaml", - }) - docInfo := linter.NewDocumentInfoWithIndex(doc, "test.yaml", idx) - - errs := rule.Run(ctx, docInfo, config) + errs := runOASSchemaCheckTest(t, tt.yaml) assert.Len(t, errs, tt.expected) }) } @@ -407,23 +332,399 @@ paths: {} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - ctx := t.Context() + errs := runOASSchemaCheckTest(t, tt.yaml) + assert.Len(t, errs, tt.expected) + }) + } +} - doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(tt.yaml)) - require.NoError(t, err) +func runOASSchemaCheckTest(t *testing.T, yaml string) []error { + t.Helper() + ctx := t.Context() - rule := &OASSchemaCheckRule{} - config := &linter.RuleConfig{} + doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(yaml)) + require.NoError(t, err) - idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{ - RootDocument: doc, - TargetDocument: doc, - TargetLocation: "test.yaml", - }) - docInfo := linter.NewDocumentInfoWithIndex(doc, "test.yaml", idx) + idx := openapi.BuildIndex(ctx, doc, references.ResolveOptions{ + RootDocument: doc, + TargetDocument: doc, + TargetLocation: "test.yaml", + }) + docInfo := linter.NewDocumentInfoWithIndex(doc, "test.yaml", idx) - errs := rule.Run(ctx, docInfo, config) - assert.Len(t, errs, tt.expected) + return (&OASSchemaCheckRule{}).Run(ctx, docInfo, &linter.RuleConfig{}) +} + +func TestOASSchemaCheck_BooleanAndNullConstraints_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + schema string + expectedMessage string + }{ + { + name: "boolean rejects string constraint", + schema: `type: boolean + minLength: 2`, + expectedMessage: "`minLength` constraint is only applicable to string types, not `boolean`", + }, + { + name: "null rejects array constraint", + schema: `type: "null" + uniqueItems: true`, + expectedMessage: "`uniqueItems` constraint is only applicable to array types, not `null`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + `+tt.schema+` +paths: {} +`) + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], tt.expectedMessage) + }) + } +} + +func TestOASSchemaCheck_NullUnionConstraints_Success(t *testing.T) { + t.Parallel() + + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + type: [array, "null"] + minItems: 1 + uniqueItems: true +paths: {} +`) + assert.Empty(t, errs) +} + +func TestOASSchemaCheck_ConstType_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + typeName string + constValue string + expectedMessage string + }{ + { + name: "string rejects integer constant", + typeName: "string", + constValue: "42", + expectedMessage: "`const` value type does not match schema type [`string`]", + }, + { + name: "integer rejects fractional constant", + typeName: "integer", + constValue: "42.5", + expectedMessage: "`const` value type does not match schema type [`integer`]", + }, + { + name: "boolean rejects string constant", + typeName: "boolean", + constValue: `"true"`, + expectedMessage: "`const` value type does not match schema type [`boolean`]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + type: `+tt.typeName+` + const: `+tt.constValue+` +paths: {} +`) + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], tt.expectedMessage) + }) + } +} + +func TestOASSchemaCheck_ConstTypes_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + schema string + }{ + {name: "integer accepts whole float", schema: `type: integer + const: 42.0`}, + {name: "number accepts integer", schema: `type: number + const: 42`}, + {name: "null accepts null", schema: `type: "null" + const: null`}, + {name: "array accepts sequence", schema: `type: array + const: [one, two]`}, + {name: "object accepts mapping", schema: `type: object + const: {key: value}`}, + {name: "union accepts a matching type", schema: `type: [string, integer] + const: 42`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + `+tt.schema+` +paths: {} +`) + assert.Empty(t, errs) + }) + } +} + +func TestOASSchemaCheck_EnumConst_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enum string + constValue string + expectedMessage string + }{ + { + name: "const excluded from enum", + enum: "[one, two]", + constValue: "three", + expectedMessage: "is not present in `enum` values", + }, + { + name: "single enum value duplicates const", + enum: "[one]", + constValue: "one", + expectedMessage: "schema uses both `enum` with single value and `const` - consider using only `const`", + }, + { + name: "multi-value enum conflicts with const", + enum: "[one, two]", + constValue: "one", + expectedMessage: "schema uses both `enum` and `const` - this is likely an oversight as `const` restricts to a single value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + type: string + enum: `+tt.enum+` + const: `+tt.constValue+` +paths: {} +`) + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], tt.expectedMessage) + }) + } +} + +func TestOASSchemaCheck_Discriminator_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + discriminator string + expectedMessage string + }{ + { + name: "property does not exist", + discriminator: "propertyName: kind", + expectedMessage: "discriminator property `kind` is not defined in schema properties", + }, + { + name: "property name missing", + discriminator: "mapping: {}", + expectedMessage: "discriminator object is missing required `propertyName` field", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + type: object + discriminator: + `+tt.discriminator+` +paths: {} +`) + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], tt.expectedMessage) + }) + } +} + +func TestOASSchemaCheck_DiscriminatorAndRequired_PolymorphicProperty_Success(t *testing.T) { + t.Parallel() + + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + type: object + required: [kind] + discriminator: + propertyName: kind + allOf: + - type: object + properties: + kind: + type: string +paths: {} +`) + assert.Empty(t, errs) +} + +func TestOASSchemaCheck_ArrayAndObjectConstraints_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + schema string + expectedMessage string + }{ + { + name: "array maximum less than minimum", + schema: `type: array + minItems: 3 + maxItems: 1`, + expectedMessage: "`maxItems` should be greater than or equal to `minItems`", + }, + { + name: "array maxContains less than minContains", + schema: `type: array + minContains: 4 + maxContains: 2`, + expectedMessage: "`maxContains` should be greater than or equal to `minContains`", + }, + { + name: "object maximum less than minimum", + schema: `type: object + minProperties: 3 + maxProperties: 1`, + expectedMessage: "`maxProperties` should be greater than or equal to `minProperties`", + }, + { + name: "object rejects array constraint", + schema: `type: object + uniqueItems: true`, + expectedMessage: "`uniqueItems` constraint is only applicable to array types, not `object`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + `+tt.schema+` +paths: {} +`) + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], tt.expectedMessage) + }) + } +} + +func TestOASSchemaCheck_TypeInappropriateConstraints_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + schema string + expectedMessage string + }{ + { + name: "array rejects numeric constraint", + schema: `type: array + minimum: 1`, + expectedMessage: "`minimum` constraint is only applicable to number/integer types, not `array`", + }, + { + name: "string rejects array constraint", + schema: `type: string + minItems: 1`, + expectedMessage: "`minItems` constraint is only applicable to array types, not `string`", + }, + { + name: "integer rejects object constraint", + schema: `type: integer + minProperties: 1`, + expectedMessage: "`minProperties` constraint is only applicable to object types, not `number`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + errs := runOASSchemaCheckTest(t, ` +openapi: 3.1.0 +info: + title: Test + version: 1.0.0 +components: + schemas: + Test: + `+tt.schema+` +paths: {} +`) + require.Len(t, errs, 1) + assert.ErrorContains(t, errs[0], tt.expectedMessage) }) } } diff --git a/openapi/linter/rules/unused_components_test.go b/openapi/linter/rules/unused_components_test.go index 0f41f307..f6149245 100644 --- a/openapi/linter/rules/unused_components_test.go +++ b/openapi/linter/rules/unused_components_test.go @@ -171,6 +171,107 @@ security: assert.ElementsMatch(t, expectedErrors, errMsgs) } +func TestUnusedComponentRule_ComponentKinds_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + component string + definition string + paths string + }{ + { + component: "parameters", + definition: `{"name":"id","in":"query","schema":{"type":"string"}}`, + paths: `{"/pets":{"get":{"parameters":[{"$ref":"#/components/parameters/Test"}],"responses":{"200":{"description":"ok"}}}}}`, + }, + { + component: "responses", + definition: `{"description":"ok"}`, + paths: `{"/pets":{"get":{"responses":{"200":{"$ref":"#/components/responses/Test"}}}}}`, + }, + { + component: "requestBodies", + definition: `{"content":{"application/json":{"schema":{"type":"string"}}}}`, + paths: `{"/pets":{"post":{"requestBody":{"$ref":"#/components/requestBodies/Test"},"responses":{"200":{"description":"ok"}}}}}`, + }, + { + component: "headers", + definition: `{"schema":{"type":"string"}}`, + paths: `{"/pets":{"get":{"responses":{"200":{"description":"ok","headers":{"Test":{"$ref":"#/components/headers/Test"}}}}}}}`, + }, + { + component: "examples", + definition: `{"value":"example"}`, + paths: `{"/pets":{"get":{"responses":{"200":{"description":"ok","content":{"application/json":{"examples":{"Test":{"$ref":"#/components/examples/Test"}}}}}}}}}`, + }, + { + component: "links", + definition: `{"operationId":"getPets"}`, + paths: `{"/pets":{"get":{"operationId":"getPets","responses":{"200":{"description":"ok","links":{"Test":{"$ref":"#/components/links/Test"}}}}}}}`, + }, + { + component: "callbacks", + definition: `{"{$request.body#/url}":{"post":{"responses":{"200":{"description":"ok"}}}}}`, + paths: `{"/pets":{"post":{"callbacks":{"Test":{"$ref":"#/components/callbacks/Test"}},"responses":{"200":{"description":"ok"}}}}}`, + }, + { + component: "pathItems", + definition: `{"get":{"responses":{"200":{"description":"ok"}}}}`, + paths: `{"/pets":{"$ref":"#/components/pathItems/Test"}}`, + }, + { + component: "securitySchemes", + definition: `{"type":"apiKey","in":"header","name":"X-API-Key"}`, + paths: `{"/pets":{"get":{"security":[{"Test":[]}],"responses":{"200":{"description":"ok"}}}}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.component, func(t *testing.T) { + t.Parallel() + + t.Run("unreferenced component is reported", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + input := `{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":{},"components":{"` + tt.component + `":{"Test":` + tt.definition + `}}}` + doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(input)) + require.NoError(t, err, "component document should unmarshal") + docInfo := createDocInfoWithIndexUnusedComponents(t, ctx, doc, "test.json") + errs := (&rules.UnusedComponentRule{}).Run(ctx, docInfo, &linter.RuleConfig{}) + require.Len(t, errs, 1, "the unused component should produce one diagnostic") + assert.Contains(t, errs[0].Error(), "`#/components/"+tt.component+"/Test` is potentially unused", "diagnostic should identify the component") + var diagnostic *validation.Error + require.ErrorAs(t, errs[0], &diagnostic, "diagnostic should expose a validation error") + assert.Equal(t, validation.SeverityWarning, diagnostic.Severity, "unused components should be warnings") + assert.NotNil(t, diagnostic.Fix, "unused components should offer a removal fix") + }) + + t.Run("referenced component is retained", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + input := `{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":` + tt.paths + `,"components":{"` + tt.component + `":{"Test":` + tt.definition + `}}}` + doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(input)) + require.NoError(t, err, "referenced component document should unmarshal") + docInfo := createDocInfoWithIndexUnusedComponents(t, ctx, doc, "test.json") + errs := (&rules.UnusedComponentRule{}).Run(ctx, docInfo, &linter.RuleConfig{}) + assert.Empty(t, errs, "references should mark each component kind as used") + }) + + t.Run("usage extension retains component", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + definition := strings.TrimSuffix(tt.definition, "}") + `,"x-used":true}` + input := `{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":{},"components":{"` + tt.component + `":{"Test":` + definition + `}}}` + doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(input)) + require.NoError(t, err, "explicitly used component document should unmarshal") + docInfo := createDocInfoWithIndexUnusedComponents(t, ctx, doc, "test.json") + errs := (&rules.UnusedComponentRule{}).Run(ctx, docInfo, &linter.RuleConfig{}) + assert.Empty(t, errs, "usage extensions should apply to every component kind") + }) + }) + } +} + func TestUnusedComponentRule_RuleMetadata(t *testing.T) { t.Parallel() diff --git a/openapi/localize_test.go b/openapi/localize_test.go index d0502dd7..adf0ca99 100644 --- a/openapi/localize_test.go +++ b/openapi/localize_test.go @@ -4,12 +4,14 @@ import ( "bytes" "crypto/sha256" "fmt" + "io/fs" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + "testing/fstest" "github.com/speakeasy-api/openapi/openapi" "github.com/speakeasy-api/openapi/system" @@ -17,6 +19,216 @@ import ( "github.com/stretchr/testify/require" ) +type localizeTestFS struct { + fstest.MapFS + openErr error + writeErr error +} + +func (f *localizeTestFS) Open(name string) (fs.File, error) { + if f.openErr != nil { + return nil, f.openErr + } + return f.MapFS.Open(filepath.ToSlash(name)) +} + +func (f *localizeTestFS) MkdirAll(name string, mode fs.FileMode) error { + f.MapFS[filepath.ToSlash(name)] = &fstest.MapFile{Mode: mode | fs.ModeDir} + return nil +} + +func (f *localizeTestFS) WriteFile(name string, content []byte, mode fs.FileMode) error { + if f.writeErr != nil { + return f.writeErr + } + f.MapFS[filepath.ToSlash(name)] = &fstest.MapFile{Data: content, Mode: mode} + return nil +} + +func TestLocalize_MissingReusableObject_Error(t *testing.T) { + t.Parallel() + for _, section := range []string{"responses", "parameters", "requestBodies", "headers", "examples", "links", "callbacks", "pathItems", "securitySchemes"} { + t.Run(section, func(t *testing.T) { + t.Parallel() + doc := unmarshalOpenAPI(t, t.Context(), "openapi: 3.1.0\ninfo: {title: Missing object, version: '1'}\npaths: {}\ncomponents:\n "+section+":\n Missing: {$ref: 'missing.yaml#/Object'}\n") + vfs := &localizeTestFS{MapFS: fstest.MapFS{}} + err := openapi.Localize(t.Context(), doc, openapi.LocalizeOptions{DocumentLocation: "openapi.yaml", TargetDirectory: "output", VirtualFS: vfs}) + require.ErrorContains(t, err, "missing.yaml", "missing reusable objects should report their source rather than panic") + var output bytes.Buffer + require.NoError(t, openapi.Marshal(t.Context(), doc, &output), "failed localization should leave a serializable document") + assert.Contains(t, output.String(), "missing.yaml#/Object", "failed resolution should leave the original reference unchanged") + assert.Empty(t, vfs.MapFS, "failed discovery must not create directories or copy partial files") + }) + } +} + +func TestLocalize_NamingFallbacks_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + strategy openapi.LocalizeNamingStrategy + first string + second string + }{ + {name: "path", strategy: openapi.LocalizeNamingPathBased, first: "shared.yaml", second: "two-shared.yaml"}, + {name: "counter", strategy: openapi.LocalizeNamingCounter, first: "shared.yaml", second: "shared_1.yaml"}, + {name: "custom without function", strategy: openapi.LocalizeNamingCustom, first: "one-shared.yaml", second: "two-shared.yaml"}, + {name: "unknown strategy", strategy: openapi.LocalizeNamingStrategy(99), first: "one-shared.yaml", second: "two-shared.yaml"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + vfs := &localizeTestFS{MapFS: fstest.MapFS{ + "one/shared.yaml": {Data: []byte("Thing:\n type: string\n enum: [first]\n")}, + "two/shared.yaml": {Data: []byte("Thing:\n type: string\n enum: [second]\n")}, + }} + doc, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(`openapi: 3.1.0 +info: + title: Naming conflicts + version: 1.0.0 +components: + schemas: + First: + $ref: './one/shared.yaml#/Thing' + Second: + $ref: './two/shared.yaml#/Thing' + Duplicate: + $ref: './one/shared.yaml#/Thing' + Internal: + $ref: '#/components/schemas/First' +`)) + require.NoError(t, err, "unmarshal document with filename conflicts") + require.Empty(t, validationErrs, "document should be valid") + err = openapi.Localize(ctx, doc, openapi.LocalizeOptions{ + DocumentLocation: "openapi.yaml", + TargetDirectory: "output", + VirtualFS: vfs, + NamingStrategy: tt.strategy, + }) + require.NoError(t, err, "localize distinct files sharing a basename") + assert.Equal(t, tt.first+"#/Thing", string(doc.Components.Schemas.GetOrZero("First").GetRef()), "first file should use the strategy's filename") + assert.Equal(t, tt.second+"#/Thing", string(doc.Components.Schemas.GetOrZero("Second").GetRef()), "second file should use a conflict-free filename") + assert.Equal(t, tt.first+"#/Thing", string(doc.Components.Schemas.GetOrZero("Duplicate").GetRef()), "duplicate reference should reuse the localized file") + assert.Equal(t, "#/components/schemas/First", string(doc.Components.Schemas.GetOrZero("Internal").GetRef()), "internal reference should remain unchanged") + first, err := fs.ReadFile(vfs, "output/"+tt.first) + require.NoError(t, err, "read first localized file") + assert.Contains(t, string(first), "first", "first file content should be preserved") + second, err := fs.ReadFile(vfs, "output/"+tt.second) + require.NoError(t, err, "read second localized file") + assert.Contains(t, string(second), "second", "conflicting file should not overwrite the first") + entries, err := fs.ReadDir(vfs, "output") + require.NoError(t, err, "list localized files") + assert.Len(t, entries, 2, "duplicate and internal references should not create extra files") + }) + } +} + +func TestLocalize_NestedSourceReferences_Success(t *testing.T) { + t.Parallel() + + ctx := t.Context() + vfs := &localizeTestFS{MapFS: fstest.MapFS{ + "schemas/parent.yaml": {Data: []byte(`Parent: + type: object + properties: + nested: + $ref: './child.yaml#/Child' + internal: + $ref: '#/Text' + allOf: + - $ref: './child.yaml#/Child' +Text: + type: string +`)}, + "schemas/child.yaml": {Data: []byte("Child:\n type: object\n description: Nested child\n")}, + }} + doc, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(`openapi: 3.1.0 +info: + title: Nested references + version: 1.0.0 +components: + schemas: + Parent: + $ref: './schemas/parent.yaml#/Parent' +`)) + require.NoError(t, err, "unmarshal root document") + require.Empty(t, validationErrs, "root document should be valid") + err = openapi.Localize(ctx, doc, openapi.LocalizeOptions{DocumentLocation: "openapi.yaml", TargetDirectory: "output", VirtualFS: vfs}) + require.NoError(t, err, "localize nested source-relative references") + assert.Equal(t, "parent.yaml#/Parent", string(doc.Components.Schemas.GetOrZero("Parent").GetRef()), "root schema should reference the copied file") + parent, err := fs.ReadFile(vfs, "output/parent.yaml") + require.NoError(t, err, "read localized parent") + assert.Contains(t, string(parent), "./child.yaml#/Child", "source-relative child reference should remain valid beside the copied parent") + assert.Contains(t, string(parent), "#/Text", "same-document fragment should be preserved") + child, err := fs.ReadFile(vfs, "output/child.yaml") + require.NoError(t, err, "read localized source-relative child") + assert.Contains(t, string(child), "Nested child", "nested child content should be preserved") +} + +func TestLocalize_Filesystem_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files fstest.MapFS + openErr error + writeErr error + section string + message string + }{ + {name: "missing file", files: fstest.MapFS{}, section: "responses", message: "failed to discover external references"}, + {name: "read denied", files: fstest.MapFS{}, openErr: fs.ErrPermission, section: "responses", message: "failed to discover external references"}, + {name: "write denied", files: fstest.MapFS{"source.yaml": {Data: []byte("type: string\n")}}, writeErr: fs.ErrPermission, section: "schemas", message: "failed to write localized file"}, + {name: "invalid source", files: fstest.MapFS{"source.yaml": {Data: []byte("description: [invalid\n")}}, section: "responses", message: "failed to discover external references"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + vfs := &localizeTestFS{MapFS: tt.files, openErr: tt.openErr, writeErr: tt.writeErr} + doc, validationErrs, err := openapi.Unmarshal(ctx, strings.NewReader(`openapi: 3.1.0 +info: + title: Filesystem failure + version: 1.0.0 +components: + `+tt.section+`: + External: + $ref: './source.yaml' +`)) + require.NoError(t, err, "unmarshal document before resolving") + require.Empty(t, validationErrs, "unresolved document should be valid") + err = openapi.Localize(ctx, doc, openapi.LocalizeOptions{DocumentLocation: "openapi.yaml", TargetDirectory: "output", VirtualFS: vfs}) + require.Error(t, err, "filesystem or source failure should prevent localization") + assert.Contains(t, err.Error(), tt.message, "error should identify the failed localization phase") + assert.Contains(t, err.Error(), "source.yaml", "error should identify the source file") + var output bytes.Buffer + require.NoError(t, openapi.Marshal(ctx, doc, &output), "marshal document after failure") + assert.Contains(t, output.String(), "./source.yaml", "root reference should not be rewritten after failure") + }) + } +} + +func TestLocalize_EmptyDocument_Success(t *testing.T) { + t.Parallel() + + require.NoError(t, openapi.Localize(t.Context(), nil, openapi.LocalizeOptions{}), "nil document should be a no-op") + doc := &openapi.OpenAPI{OpenAPI: openapi.Version, Info: openapi.Info{Title: "No references", Version: "1.0.0"}} + require.NoError(t, openapi.Localize(t.Context(), doc, openapi.LocalizeOptions{TargetDirectory: t.TempDir()}), "document without external references should succeed with the default filesystem") + assert.Equal(t, "No references", doc.Info.Title, "document content should remain unchanged") +} + +func TestLocalize_TargetDirectory_Error(t *testing.T) { + t.Parallel() + + err := openapi.Localize(t.Context(), &openapi.OpenAPI{}, openapi.LocalizeOptions{}) + require.Error(t, err, "localization requires a target directory") + assert.EqualError(t, err, "target directory is required", "missing target should produce a clear error") +} + func TestLocalize_Success(t *testing.T) { t.Parallel() diff --git a/openapi/sanitize_test.go b/openapi/sanitize_test.go index 22171fb0..5ed7296c 100644 --- a/openapi/sanitize_test.go +++ b/openapi/sanitize_test.go @@ -2,15 +2,107 @@ package openapi_test import ( "bytes" + "errors" "os" + "path/filepath" + "slices" "strings" "testing" + "testing/iotest" "github.com/speakeasy-api/openapi/openapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestSanitize_ConfigRead_Error(t *testing.T) { + t.Parallel() + readErr := errors.New("config read failed") + _, err := openapi.LoadSanitizeConfig(iotest.ErrReader(readErr)) + require.ErrorIs(t, err, readErr, "config reader failures should preserve their cause") +} + +func TestSanitize_ConfigFileOptions_Success(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "sanitize.yaml") + require.NoError(t, os.WriteFile(path, []byte("keepUnusedComponents: true\nextensionPatterns:\n keep: [x-public-*]\n"), 0o600), "config fixture should be created") + config, err := openapi.LoadSanitizeConfigFromFile(path) + require.NoError(t, err, "config should load from disk") + assert.Equal(t, &openapi.SanitizeOptions{KeepUnusedComponents: true, ExtensionPatterns: &openapi.ExtensionFilter{Keep: []string{"x-public-*"}}}, config, "file options should be parsed exactly") +} + +func TestSanitize_InvalidAndUnmatchedPatterns_Success(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + filter *openapi.ExtensionFilter + keys []string + warnings []string + }{ + {"whitelist", &openapi.ExtensionFilter{Keep: []string{"[", "x-absent", "x-keep"}}, []string{"x-keep"}, []string{"invalid keep pattern '[' was skipped", "keep pattern 'x-absent' did not match any extensions in the document"}}, + {"blacklist", &openapi.ExtensionFilter{Remove: []string{"[", "x-absent", "x-remove"}}, []string{"x-keep", "x-other"}, []string{"invalid remove pattern '[' was skipped", "remove pattern 'x-absent' did not match any extensions in the document"}}, + {"combined", &openapi.ExtensionFilter{Keep: []string{"[", "x-absent", "x-keep"}, Remove: []string{"[", "x-absent", "x-keep", "x-remove"}}, []string{"x-keep", "x-other"}, []string{"invalid keep pattern '[' was skipped", "keep pattern 'x-absent' did not match any extensions in the document", "invalid remove pattern '[' was skipped", "remove pattern 'x-absent' did not match any extensions in the document"}}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + doc := unmarshalOpenAPI(t, t.Context(), "openapi: 3.1.0\ninfo: {title: Filters, version: '1'}\npaths: {}\nx-keep: true\nx-remove: true\nx-other: true\n") + result, err := openapi.Sanitize(t.Context(), doc, &openapi.SanitizeOptions{ExtensionPatterns: tt.filter, KeepUnusedComponents: true, KeepUnknownProperties: true}) + require.NoError(t, err, "invalid patterns should produce warnings without failing sanitization") + assert.ElementsMatch(t, tt.warnings, result.Warnings, "invalid and unmatched patterns should be reported separately") + assert.ElementsMatch(t, tt.keys, slices.Collect(doc.Extensions.Keys()), "keep patterns should override matching remove patterns") + }) + } +} + +func TestSanitize_UnknownPropertiesInReusableObjects_Success(t *testing.T) { + t.Parallel() + const source = `openapi: 3.1.0 +info: {title: Reusable objects, version: '1'} +paths: {} +components: + schemas: + Boolean: true + String: {type: string, rogueField: rogue} + responses: + Object: {description: response, rogueField: rogue} + Alias: {$ref: '#/components/responses/Object'} + parameters: + Object: {name: id, in: query, schema: {type: string}, rogueField: rogue} + Alias: {$ref: '#/components/parameters/Object'} + requestBodies: + Object: {content: {application/json: {schema: {type: string}}}, rogueField: rogue} + Alias: {$ref: '#/components/requestBodies/Object'} + headers: + Object: {schema: {type: string}, rogueField: rogue} + Alias: {$ref: '#/components/headers/Object'} + examples: + Object: {value: example, rogueField: rogue} + Alias: {$ref: '#/components/examples/Object'} + links: + Object: {operationId: getItem, rogueField: rogue} + Alias: {$ref: '#/components/links/Object'} + callbacks: + Object: {} + Alias: {$ref: '#/components/callbacks/Object'} + pathItems: + Object: {summary: Path} + Alias: {$ref: '#/components/pathItems/Object'} + securitySchemes: + Object: {type: http, scheme: bearer, rogueField: rogue} + Alias: {$ref: '#/components/securitySchemes/Object'} +` + doc := unmarshalOpenAPI(t, t.Context(), source) + _, err := openapi.Sanitize(t.Context(), doc, &openapi.SanitizeOptions{KeepUnusedComponents: true}) + require.NoError(t, err, "all reusable object kinds should support unknown-property cleanup") + var output bytes.Buffer + require.NoError(t, openapi.Marshal(t.Context(), doc, &output), "sanitized document should marshal") + assert.NotContains(t, output.String(), "rogue", "unknown fields should be removed from every object") + for _, section := range []string{"responses", "parameters", "requestBodies", "headers", "examples", "links", "callbacks", "pathItems", "securitySchemes"} { + assert.Contains(t, output.String(), "#/components/"+section+"/Object", "aliases should remain untouched while their object definitions are sanitized") + } + assert.Contains(t, output.String(), "Boolean: true", "boolean schemas should survive cleanup") +} + func TestSanitize_RemoveAllExtensions_Success(t *testing.T) { t.Parallel() diff --git a/openapi/walk_test.go b/openapi/walk_test.go index b1ce2231..70eb872c 100644 --- a/openapi/walk_test.go +++ b/openapi/walk_test.go @@ -3,7 +3,9 @@ package openapi_test import ( "context" "errors" + "iter" "os" + "strconv" "testing" "github.com/speakeasy-api/openapi/extensions" @@ -34,6 +36,99 @@ func loadOpenAPIDocument(ctx context.Context) (*openapi.OpenAPI, error) { return o, nil } +func TestWalkOpenAPI_StopAtEachNode_Success(t *testing.T) { + t.Parallel() + + doc, err := loadOpenAPIDocument(t.Context()) + require.NoError(t, err, "walk fixture should load") + var locations []string + for item := range openapi.Walk(t.Context(), doc) { + locations = append(locations, item.Location.ToJSONPointer().String()) + } + require.NotEmpty(t, locations, "fixture should contain traversal nodes") + + for stopAt, location := range locations { + t.Run(strconv.Itoa(stopAt)+":"+location, func(t *testing.T) { + t.Parallel() + freshDoc, err := loadOpenAPIDocument(t.Context()) + require.NoError(t, err, "each traversal should have its own document") + var visited []string + openapi.Walk(t.Context(), freshDoc)(func(item openapi.WalkItem) bool { + visited = append(visited, item.Location.ToJSONPointer().String()) + return len(visited) <= stopAt + }) + assert.Equal(t, locations[:stopAt+1], visited, "a false yield must stop every ancestor without visiting later nodes") + }) + } +} + +func checkDetachedWalk[T any](t *testing.T, root *T, count int) { + t.Helper() + visited := 0 + for item := range openapi.Walk(t.Context(), root) { + visited++ + assert.Nil(t, item.OpenAPI, "detached walks should not claim a containing document") + require.NoError(t, item.Match(openapi.Matcher{}), "detached models and optional extensions should be supported by the matcher") + } + assert.Equal(t, count, visited, "detached walk should visit exactly the root and its children") + stopped := 0 + openapi.Walk(t.Context(), root)(func(item openapi.WalkItem) bool { + stopped++ + assert.Equal(t, "/", item.Location.ToJSONPointer().String(), "the requested model should be the traversal root") + require.NoError(t, item.Match(openapi.Matcher{Any: func(model any) error { + assert.Same(t, root, model, "the first item should be the requested model") + return nil + }}), "the root should match its model") + return false + }) + assert.Equal(t, 1, stopped, "a false yield should stop before any child model") +} + +func TestWalk_DetachedMetadataRoots_Success(t *testing.T) { + t.Parallel() + tests := []struct { + name string + run func(*testing.T) + }{ + {"info with children", func(t *testing.T) { + t.Helper() + checkDetachedWalk(t, &openapi.Info{Contact: &openapi.Contact{}, License: &openapi.License{}}, 6) + }}, + {"contact", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.Contact{}, 1) }}, + {"license", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.License{}, 1) }}, + {"external docs", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &oas3.ExternalDocumentation{}, 2) }}, + {"tag", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.Tag{}, 2) }}, + {"server", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.Server{}, 2) }}, + {"server variable", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.ServerVariable{}, 2) }}, + {"security requirement", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.SecurityRequirement{}, 1) }}, + {"paths", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.Paths{}, 2) }}, + {"operation", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.Operation{}, 4) }}, + {"responses", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.Responses{}, 2) }}, + {"media type", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.MediaType{}, 2) }}, + {"encoding", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.Encoding{}, 2) }}, + {"components", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.Components{}, 2) }}, + {"oauth flows", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.OAuthFlows{}, 2) }}, + {"oauth flow", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &openapi.OAuthFlow{}, 2) }}, + {"extensions", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &extensions.Extensions{}, 1) }}, + {"discriminator", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &oas3.Discriminator{}, 1) }}, + {"xml", func(t *testing.T) { t.Helper(); checkDetachedWalk(t, &oas3.XML{}, 1) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tt.run(t) + }) + } +} + +func TestWalk_NilRoot_ReturnsEmpty(t *testing.T) { + t.Parallel() + var root *openapi.Info + for range openapi.Walk(t.Context(), root) { + t.Fatal("a nil starting point must not yield a model") + } +} + func TestWalkOpenAPI_Success(t *testing.T) { t.Parallel() @@ -1202,3 +1297,103 @@ func TestWalkAdditionalOperations_Success(t *testing.T) { assert.Contains(t, matchedLocations, "/paths/~1custom~1{id}/additionalOperations/PURGE", "Should visit additional PURGE operation") assert.Contains(t, matchedLocations, "/paths/~1standard/get", "Should visit standard operation on path without additionalOperations") } + +func TestWalkComponentStartingPoints_Success(t *testing.T) { + t.Parallel() + + openAPIDoc, err := loadOpenAPIDocument(t.Context()) + require.NoError(t, err) + components := openAPIDoc.Components + require.NotNil(t, components) + + schema, exists := components.Schemas.Get("User") + require.True(t, exists) + response, exists := components.Responses.Get("ErrorResponse") + require.True(t, exists) + parameter, exists := components.Parameters.Get("UserIdParam") + require.True(t, exists) + example, exists := components.Examples.Get("UserExample") + require.True(t, exists) + requestBody, exists := components.RequestBodies.Get("UserRequest") + require.True(t, exists) + header, exists := components.Headers.Get("X-Rate-Limit") + require.True(t, exists) + securityScheme, exists := components.SecuritySchemes.Get("apiKey") + require.True(t, exists) + link, exists := components.Links.Get("GetUserByUserId") + require.True(t, exists) + callback, exists := components.Callbacks.Get("UserCallback") + require.True(t, exists) + pathItem, exists := components.PathItems.Get("UserPath") + require.True(t, exists) + + tests := []struct { + name string + start any + walk func(context.Context) iter.Seq[openapi.WalkItem] + }{ + {name: "schema", start: schema, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, schema) }}, + {name: "response", start: response, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, response) }}, + {name: "parameter", start: parameter, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, parameter) }}, + {name: "example", start: example, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, example) }}, + {name: "request body", start: requestBody, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, requestBody) }}, + {name: "header", start: header, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, header) }}, + {name: "security scheme", start: securityScheme, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, securityScheme) }}, + {name: "link", start: link, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, link) }}, + {name: "callback", start: callback, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, callback) }}, + {name: "path item", start: pathItem, walk: func(ctx context.Context) iter.Seq[openapi.WalkItem] { return openapi.Walk(ctx, pathItem) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + start := tt.start + first := true + visited := 0 + for item := range tt.walk(t.Context()) { + visited++ + assert.Nil(t, item.OpenAPI, "component walks are detached from the containing document") + if first { + first = false + assert.Equal(t, "/", string(item.Location.ToJSONPointer()), "the starting component is the walk root") + err := item.Match(openapi.Matcher{ + Any: func(model any) error { + assert.IsType(t, start, model, "the first yielded model should be the requested component") + return nil + }, + }) + require.NoError(t, err) + } + } + assert.False(t, first, "the component should be yielded") + assert.Positive(t, visited, "the component walk should visit at least its starting node") + }) + } +} + +func TestWalkComponentStartingPoint_TerminateAtNestedSchema(t *testing.T) { + t.Parallel() + + openAPIDoc, err := loadOpenAPIDocument(t.Context()) + require.NoError(t, err) + requestBody, exists := openAPIDoc.Components.RequestBodies.Get("UserRequest") + require.True(t, exists) + + visited := []string{} + for item := range openapi.Walk(t.Context(), requestBody) { + location := string(item.Location.ToJSONPointer()) + visited = append(visited, location) + err := item.Match(openapi.Matcher{ + Schema: func(*oas3.JSONSchema[oas3.Referenceable]) error { + return walk.ErrTerminate + }, + }) + if errors.Is(err, walk.ErrTerminate) { + break + } + require.NoError(t, err) + } + + assert.Equal(t, []string{"/", "/content/application~1json", "/content/application~1json/schema"}, visited) +} diff --git a/oq/exec_test.go b/oq/exec_test.go new file mode 100644 index 00000000..6f9699c9 --- /dev/null +++ b/oq/exec_test.go @@ -0,0 +1,230 @@ +package oq_test + +import ( + "strings" + "testing" + + "github.com/speakeasy-api/openapi/graph" + "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/oq" + "github.com/speakeasy-api/openapi/oq/expr" + "github.com/speakeasy-api/openapi/references" + "github.com/speakeasy-api/openapi/sequencedmap" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecute_PipelineErrors_Error(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + for _, tt := range []struct{ query, message string }{ + {`unknown`, `unknown source: "unknown"`}, + {`schemas | where(name ==)`, `where expression error: unexpected token: ""`}, + {`schemas | let $x = name +`, `let expression error: unexpected token: ""`}, + {`path(Missing, Pet)`, `schema "Missing" not found`}, + {`path(Pet, Missing)`, `schema "Missing" not found`}, + {`schemas | path(Pet, Missing)`, `schema "Missing" not found`}, + {`schemas | shared-refs(2)`, "shared-refs requires operation rows, got schema rows\n hint: operations | shared-refs(2)"}, + } { + t.Run(tt.query, func(t *testing.T) { + t.Parallel() + _, err := oq.Execute(tt.query, g) + require.EqualError(t, err, tt.message, "pipeline should report the failing stage") + }) + } +} + +func TestExecute_TypeMismatchedStages_ReturnEmpty(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + for _, query := range []string{ + `operations | refs`, `operations | properties`, `operations | properties(*)`, + `operations | items`, `operations | members`, `operations | parent`, + `operations | orphans`, `operations | leaves`, `operations | cross-tag`, + `operations | duplicates`, `operations | to-operations`, + `operations | additional-properties`, `operations | pattern-properties`, + `schemas | to-schemas`, `schemas | parameters`, `schemas | responses`, + `schemas | request-body`, `schemas | callbacks`, `schemas | links`, + `schemas | operation`, `schemas | security`, + `operations | group-by(method, operationId) | members`, + } { + t.Run(query, func(t *testing.T) { + t.Parallel() + result, err := oq.Execute(query, g) + require.NoError(t, err, "unsupported row types should be skipped") + assert.Empty(t, result.Rows, "navigation should not invent rows for unrelated objects") + }) + } +} + +func TestExecute_EmptyInputStages_ReturnEmpty(t *testing.T) { + t.Parallel() + g := loadTestGraph(t) + for _, query := range []string{ + `schemas | where(false) | shared-refs`, + `operations | where(false) | shared-refs`, + `schemas | where(false) | members`, + } { + t.Run(query, func(t *testing.T) { + t.Parallel() + result, err := oq.Execute(query, g) + require.NoError(t, err, "empty input should be accepted") + assert.Empty(t, result.Rows, "empty input should not produce rows") + }) + } +} + +func TestExecute_AbsentSourceMetadata_ReturnEmpty(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + g *graph.SchemaGraph + }{ + {"no index", &graph.SchemaGraph{}}, + {"no document", &graph.SchemaGraph{Index: &openapi.Index{}}}, + {"no components", &graph.SchemaGraph{Index: &openapi.Index{Doc: &openapi.OpenAPI{Servers: []*openapi.Server{nil}}}}}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + for _, source := range []string{"components", "security", "servers", "tags", "webhooks"} { + result, err := oq.Execute(source, tt.g) + require.NoError(t, err, "source %s should tolerate absent metadata", source) + assert.Empty(t, result.Rows, "source %s should have no objects", source) + } + }) + } +} + +func TestExecute_GlobalSecurityAndSparseComponents_Success(t *testing.T) { + t.Parallel() + scheme := &openapi.SecurityScheme{Type: openapi.SecuritySchemeTypeAPIKey} + body := &openapi.RequestBody{} + header := &openapi.Header{} + parameter := &openapi.Parameter{Name: "valid"} + response := &openapi.Response{} + req := &openapi.SecurityRequirement{Map: *sequencedmap.New(sequencedmap.NewElem("known", []string{"read"}), sequencedmap.NewElem("missing", []string{"write"}))} + doc := &openapi.OpenAPI{ + Security: []*openapi.SecurityRequirement{nil, req}, + Components: &openapi.Components{ + Parameters: sequencedmap.New(sequencedmap.NewElem[string, *openapi.ReferencedParameter]("nil", nil), sequencedmap.NewElem("unresolved", &openapi.ReferencedParameter{}), sequencedmap.NewElem("valid", &openapi.ReferencedParameter{Object: parameter})), + Responses: sequencedmap.New(sequencedmap.NewElem[string, *openapi.ReferencedResponse]("nil", nil), sequencedmap.NewElem("unresolved", &openapi.ReferencedResponse{}), sequencedmap.NewElem("valid", &openapi.ReferencedResponse{Object: response})), + RequestBodies: sequencedmap.New(sequencedmap.NewElem[string, *openapi.ReferencedRequestBody]("nil", nil), sequencedmap.NewElem("unresolved", &openapi.ReferencedRequestBody{}), sequencedmap.NewElem("valid", &openapi.ReferencedRequestBody{Object: body})), + Headers: sequencedmap.New(sequencedmap.NewElem[string, *openapi.ReferencedHeader]("nil", nil), sequencedmap.NewElem("unresolved", &openapi.ReferencedHeader{}), sequencedmap.NewElem("valid", &openapi.ReferencedHeader{Object: header})), + SecuritySchemes: sequencedmap.New(sequencedmap.NewElem[string, *openapi.ReferencedSecurityScheme]("nil", nil), sequencedmap.NewElem("unresolved", &openapi.ReferencedSecurityScheme{}), sequencedmap.NewElem("known", &openapi.ReferencedSecurityScheme{Object: scheme})), + }, + } + g := &graph.SchemaGraph{Index: &openapi.Index{Doc: doc}} + result, err := oq.Execute("components", g) + require.NoError(t, err, "programmatically built components should skip missing objects") + assert.Equal(t, []oq.Row{ + {Kind: oq.ParameterResult, Parameter: parameter, ComponentKey: "valid", SourceOpIdx: -1}, + {Kind: oq.ResponseResult, Response: response, ComponentKey: "valid", SourceOpIdx: -1}, + {Kind: oq.RequestBodyResult, RequestBody: body, ComponentKey: "valid", SourceOpIdx: -1}, + {Kind: oq.HeaderResult, Header: header, HeaderName: "valid", SourceOpIdx: -1}, + {Kind: oq.SecuritySchemeResult, SecurityScheme: scheme, SchemeName: "known", SourceOpIdx: -1}, + }, result.Rows, "component sources should preserve order and mark the lack of source operation") + security, err := oq.Execute("security", g) + require.NoError(t, err, "global security should resolve known schemes") + assert.Equal(t, []oq.Row{ + {Kind: oq.SecurityRequirementResult, SchemeName: "known", SecurityScheme: scheme, Scopes: []string{"read"}, SourceOpIdx: -1}, + {Kind: oq.SecurityRequirementResult, SchemeName: "missing", Scopes: []string{"write"}, SourceOpIdx: -1}, + }, security.Rows, "unknown schemes should retain their requirements without a resolved object") + for _, query := range []string{"components | operation", "security | operation"} { + result, err := oq.Execute(query, g) + require.NoError(t, err, "objects without a source operation should be skipped") + assert.Empty(t, result.Rows, "global objects should not back-navigate to operation zero") + } +} + +func TestExecute_ComposedArraysAndReversePaths_Success(t *testing.T) { + t.Parallel() + const spec = `openapi: 3.1.0 +info: {title: Composition, version: '1'} +paths: {} +components: + schemas: + Item: + type: object + properties: + id: {type: string} + Nested: + allOf: + - allOf: + - $ref: '#/components/schemas/Item' + Array: + allOf: + - type: array + items: {$ref: '#/components/schemas/Item'} + Page: + allOf: + - type: object + properties: + metadata: {type: string} + items: + type: array + items: {$ref: '#/components/schemas/Item'} + Isolated: {type: boolean} +` + ctx := t.Context() + doc, _, err := openapi.Unmarshal(ctx, strings.NewReader(spec), openapi.WithSkipValidation()) + require.NoError(t, err, "composition document should unmarshal") + g := graph.Build(ctx, openapi.BuildIndex(ctx, doc, references.ResolveOptions{RootDocument: doc, TargetDocument: doc, TargetLocation: "composition.yaml"})) + + for _, name := range []string{"Array", "Page"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + result, err := oq.Execute(`schemas | where(isComponent and name == "`+name+`") | items`, g) + require.NoError(t, err, "items should navigate through allOf composition") + require.Len(t, result.Rows, 1, "composed array should have one item row") + row := result.Rows[0] + assert.Equal(t, "items", row.EdgeKind, "item edge should retain its kind") + assert.Equal(t, name, row.Traversal, "composition should attribute items to the input schema") + assert.Equal(t, name, row.Seed, "composition should preserve the traversal seed") + props, err := oq.Execute(`schemas | where(isComponent and name == "`+name+`") | items | properties`, g) + require.NoError(t, err, "array element reference should resolve to its properties") + require.Len(t, props.Rows, 1, "item schema should contain exactly one property") + assert.Equal(t, "id", props.Rows[0].EdgeLabel, "item property should be id") + }) + } + props, err := oq.Execute(`schemas | where(isComponent and name == "Nested") | properties`, g) + require.NoError(t, err, "nested allOf should flatten properties") + require.Len(t, props.Rows, 1, "nested allOf should expose one property") + assert.Equal(t, "Nested", props.Rows[0].Traversal, "flattened property should belong to outer composition") + assert.Equal(t, "id", props.Rows[0].EdgeLabel, "nested property should be id") + + path, err := oq.Execute("path(Item, Page)", g) + require.NoError(t, err, "path may travel backwards through structural edges") + require.NotEmpty(t, path.Rows, "reverse path should connect the component schemas") + assert.Equal(t, expr.StringVal("Item"), oq.FieldValuePublic(path.Rows[0], "name", g), "path should begin at Item") + assert.Equal(t, expr.StringVal("Page"), oq.FieldValuePublic(path.Rows[len(path.Rows)-1], "name", g), "path should end at Page") + for i, row := range path.Rows[1:] { + assert.Equal(t, "โ†", row.Direction, "all hops should traverse incoming edges") + assert.Equal(t, i+1, row.Hops, "path should annotate consecutive hops") + } + isolated, err := oq.Execute("path(Item, Isolated)", g) + require.NoError(t, err, "disconnected named nodes should not be an error") + assert.Empty(t, isolated.Rows, "disconnected nodes should have no path") + same, err := oq.Execute("path(Item, Item)", g) + require.NoError(t, err, "same-node path should succeed") + item, ok := g.SchemaByName("Item") + require.True(t, ok, "Item should be registered") + assert.Equal(t, []oq.Row{{Kind: oq.SchemaResult, SchemaIdx: int(item.ID)}}, same.Rows, "same-node path should contain only its seed") +} + +func TestExecute_BindingsAndProjectedIdentity_Success(t *testing.T) { + t.Parallel() + g := &graph.SchemaGraph{Schemas: []graph.SchemaNode{ + {Name: "first", Type: "object", Depth: 1}, + {Name: "second", Type: "object", Depth: 1}, + {Name: "third", Type: "object", Depth: 2}, + }} + result, err := oq.Execute(`schemas | select type, depth | format json | to-yaml | unique | let $a = depth | let $b = $a + 1 | where(depth == $b) | last(5) | sample(5)`, g) + require.NoError(t, err, "bindings should survive consecutive let stages") + assert.Equal(t, []oq.Row{{Kind: oq.SchemaResult, SchemaIdx: 2}}, result.Rows, "projected identity should consider every field and retain the depth-2 row") + assert.Equal(t, []string{"type", "depth"}, result.Fields, "selection should survive derived results") + assert.Equal(t, "json", result.FormatHint, "format preference should survive derived results") + assert.True(t, result.EmitYAML, "YAML preference should survive derived results") + grouped, err := oq.Execute(`schemas | group-by(type) | members`, g) + require.NoError(t, err, "group members should resolve schema names") + assert.Equal(t, []oq.Row{{Kind: oq.SchemaResult, SchemaIdx: 0}, {Kind: oq.SchemaResult, SchemaIdx: 1}, {Kind: oq.SchemaResult, SchemaIdx: 2}}, grouped.Rows, "member expansion should retain schema order") +} diff --git a/oq/expr/expr_test.go b/oq/expr/expr_test.go index 35b94e83..9af656fc 100644 --- a/oq/expr/expr_test.go +++ b/oq/expr/expr_test.go @@ -831,6 +831,86 @@ func TestContains_ArrayValue(t *testing.T) { assert.False(t, e.Eval(row).Bool) } +func TestEval_FunctionContracts_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want expr.Value + }{ + {`upper()`, expr.NullVal()}, + {`len()`, expr.NullVal()}, + {`count("a", "b")`, expr.NullVal()}, + {`trim()`, expr.NullVal()}, + {`startswith("a")`, expr.NullVal()}, + {`endswith("a")`, expr.NullVal()}, + {`contains("a")`, expr.NullVal()}, + {`replace("a", "b")`, expr.NullVal()}, + {`split("a")`, expr.NullVal()}, + {`split("a", ",", 0, 1)`, expr.NullVal()}, + {`contains(split("a,b", ","), "b")`, expr.BoolVal(true)}, + {`contains(split("a,b", ","), "c")`, expr.BoolVal(false)}, + {`not split("a,b", ",")`, expr.BoolVal(false)}, + {`not empty`, expr.BoolVal(true)}, + {`upper(split("a,b", ","))`, expr.StringVal("A, B")}, + {`false + 3`, expr.IntVal(3)}, + {`missing == true`, expr.BoolVal(false)}, + {`matches(name, '^Pe')`, expr.BoolVal(true)}, + {`"prefix \(len(name)) suffix"`, expr.StringVal("prefix 3 suffix")}, + {`"\(len(name))"`, expr.IntVal(3)}, + {`'\(name)'`, expr.StringVal(`\(name)`)}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + parsed, err := expr.Parse(tt.input) + require.NoError(t, err, "expression should parse") + assert.Equal(t, tt.want, parsed.Eval(testRow{"name": expr.StringVal("Pet"), "empty": expr.ArrayVal(nil)}), "expression should preserve its value kind and contents") + }) + } +} + +func TestParse_MalformedOperands_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + message string + }{ + {`true or`, `unexpected token: ""`}, + {`true and`, `unexpected token: ""`}, + {`name contains`, `unexpected token: ""`}, + {`name startswith`, `unexpected token: ""`}, + {`name endswith`, `unexpected token: ""`}, + {`name default`, `unexpected token: ""`}, + {`1 +`, `unexpected token: ""`}, + {`1 *`, `unexpected token: ""`}, + {`not`, `unexpected token: ""`}, + {`(`, `unexpected token: ""`}, + {`(true`, `expected ")", got ""`}, + {`has name`, `expected "(", got "name"`}, + {`matches name`, `expected "(", got "name"`}, + {`matches(name "Pet")`, `expected ",", got "\"Pet\""`}, + {`matches(name, "[")`, `invalid regex "[": error parsing regexp: missing closing ]: ` + "`[`"}, + {`"Pet" matches "Pet"`, "matches requires a field on the left side"}, + {`replace("a" "b")`, `expected ",", got "\"b\""`}, + {`upper(,)`, `unexpected token: ","`}, + {`upper(`, `expected ")", got ""`}, + {`if`, `unexpected token: ""`}, + {`if true then`, `unexpected token: ""`}, + {`if false then 1 elif`, `unexpected token: ""`}, + {`if false then 1 else`, `unexpected token: ""`}, + {`"before \(1 +) after"`, `interpolation error: unexpected token: ""`}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + _, err := expr.Parse(tt.input) + require.EqualError(t, err, tt.message, "malformed operand should report its parse error") + }) + } +} + func TestParse_EdgeCases(t *testing.T) { t.Parallel() diff --git a/oq/field_test.go b/oq/field_test.go new file mode 100644 index 00000000..cadfa90e --- /dev/null +++ b/oq/field_test.go @@ -0,0 +1,208 @@ +package oq_test + +import ( + "testing" + + "github.com/speakeasy-api/openapi/extensions" + "github.com/speakeasy-api/openapi/graph" + "github.com/speakeasy-api/openapi/jsonschema/oas3" + "github.com/speakeasy-api/openapi/openapi" + "github.com/speakeasy-api/openapi/oq" + "github.com/speakeasy-api/openapi/oq/expr" + "github.com/speakeasy-api/openapi/pointer" + "github.com/speakeasy-api/openapi/sequencedmap" + "github.com/speakeasy-api/openapi/values" + "github.com/speakeasy-api/openapi/yml" + "github.com/stretchr/testify/assert" +) + +func TestFieldValuePublic_SchemaContent_Success(t *testing.T) { + t.Parallel() + + child := oas3.NewJSONSchemaFromBool(true) + schema := &oas3.Schema{ + Description: pointer.From("description"), Title: pointer.From("title"), + Format: pointer.From("uuid"), Pattern: pointer.From("^[a-z]+$"), + Nullable: pointer.From(true), ReadOnly: pointer.From(true), WriteOnly: pointer.From(true), + Deprecated: pointer.From(true), UniqueItems: pointer.From(true), + Discriminator: &oas3.Discriminator{ + PropertyName: "kind", + Mapping: sequencedmap.New(sequencedmap.NewElem("pet", "#/components/schemas/Pet")), + }, + Required: []string{"id"}, Enum: []values.Value{yml.CreateStringNode("a"), yml.CreateStringNode("b")}, + Minimum: pointer.From(2.0), Maximum: pointer.From(10.0), + MinLength: pointer.From(int64(3)), MaxLength: pointer.From(int64(12)), + MinItems: pointer.From(int64(1)), MaxItems: pointer.From(int64(4)), + MinProperties: pointer.From(int64(2)), MaxProperties: pointer.From(int64(5)), + Default: yml.CreateStringNode("fallback"), Const: yml.CreateStringNode("fixed"), + ContentEncoding: pointer.From("base64"), ContentMediaType: pointer.From("image/png"), + MultipleOf: pointer.From(2.0), Anchor: pointer.From("pet"), ID: pointer.From("https://example.com/pet"), + Schema: pointer.From("https://json-schema.org/draft/2020-12/schema"), + PrefixItems: []*oas3.JSONSchemaReferenceable{child}, + DependentSchemas: sequencedmap.New(sequencedmap.NewElem("id", child)), + Defs: sequencedmap.New(sequencedmap.NewElem("Pet", child)), + Examples: []values.Value{yml.CreateStringNode("example")}, + AdditionalProperties: child, PatternProperties: sequencedmap.New(sequencedmap.NewElem("^x-", child)), + XML: &oas3.XML{Name: pointer.From("pet")}, ExternalDocs: &oas3.ExternalDocumentation{URL: "https://example.com"}, + Not: child, If: child, Then: child, Else: child, Contains: child, PropertyNames: child, + UnevaluatedItems: child, UnevaluatedProperties: child, + Extensions: extensions.New(extensions.NewElem("x-test-value", yml.CreateStringNode("extension"))), + } + + g := &graph.SchemaGraph{Schemas: []graph.SchemaNode{{Schema: oas3.NewJSONSchemaFromSchema[oas3.Referenceable](schema)}}} + row := oq.Row{Kind: oq.SchemaResult, SchemaIdx: 0, Traversal: "Pet/allOf/Base"} + + tests := []struct { + field string + want expr.Value + }{ + {"description", expr.StringVal("description")}, {"title", expr.StringVal("title")}, + {"format", expr.StringVal("uuid")}, {"pattern", expr.StringVal("^[a-z]+$")}, + {"nullable", expr.BoolVal(true)}, {"readOnly", expr.BoolVal(true)}, {"writeOnly", expr.BoolVal(true)}, + {"deprecated", expr.BoolVal(true)}, {"uniqueItems", expr.BoolVal(true)}, + {"discriminatorProperty", expr.StringVal("kind")}, {"discriminatorMappingCount", expr.IntVal(1)}, + {"requiredProperties", expr.ArrayVal([]string{"id"})}, {"requiredCount", expr.IntVal(1)}, + {"enum", expr.ArrayVal([]string{"a", "b"})}, {"enumCount", expr.IntVal(2)}, + {"minimum", expr.IntVal(2)}, {"maximum", expr.IntVal(10)}, + {"minLength", expr.IntVal(3)}, {"maxLength", expr.IntVal(12)}, + {"minItems", expr.IntVal(1)}, {"maxItems", expr.IntVal(4)}, + {"minProperties", expr.IntVal(2)}, {"maxProperties", expr.IntVal(5)}, + {"default", expr.StringVal("fallback")}, {"const", expr.StringVal("fixed")}, + {"contentEncoding", expr.StringVal("base64")}, {"contentMediaType", expr.StringVal("image/png")}, + {"extensionCount", expr.IntVal(1)}, {"x-test-value", expr.StringVal("extension")}, {"x_test_value", expr.StringVal("extension")}, + {"multiple_of", expr.IntVal(2)}, {"anchor", expr.StringVal("pet")}, {"id", expr.StringVal("https://example.com/pet")}, + {"schema", expr.StringVal("Base")}, + {"prefix_items", expr.IntVal(1)}, {"dependent_schemas", expr.IntVal(1)}, {"defs", expr.IntVal(1)}, {"examples", expr.IntVal(1)}, + {"additional_properties", expr.BoolVal(true)}, {"pattern_properties", expr.BoolVal(true)}, + {"xml", expr.BoolVal(true)}, {"external_docs", expr.BoolVal(true)}, + {"not", expr.BoolVal(true)}, {"if", expr.BoolVal(true)}, {"then", expr.BoolVal(true)}, {"else", expr.BoolVal(true)}, + {"contains", expr.BoolVal(true)}, {"property_names", expr.BoolVal(true)}, + {"unevaluated_items", expr.BoolVal(true)}, {"unevaluated_properties", expr.BoolVal(true)}, + {"unknown", expr.NullVal()}, {"x-missing", expr.NullVal()}, + } + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, oq.FieldValuePublic(row, tt.field, g), "field should preserve its value and expression type") + }) + } +} + +func TestFieldValuePublic_MissingSchemaContent_ReturnsDefaults(t *testing.T) { + t.Parallel() + + g := &graph.SchemaGraph{Schemas: []graph.SchemaNode{{Schema: oas3.NewJSONSchemaFromBool(true)}}} + row := oq.Row{Kind: oq.SchemaResult, SchemaIdx: 0} + tests := []struct { + fields []string + want expr.Value + }{ + {[]string{"description", "title", "format", "pattern", "discriminatorProperty", "contentEncoding", "contentMediaType"}, expr.StringVal("")}, + {[]string{"nullable", "readOnly", "writeOnly", "deprecated", "uniqueItems"}, expr.BoolVal(false)}, + {[]string{"discriminatorMappingCount", "requiredCount", "enumCount", "extensionCount"}, expr.IntVal(0)}, + {[]string{"requiredProperties", "enum"}, expr.ArrayVal(nil)}, + {[]string{"minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties", "default", "unknown"}, expr.NullVal()}, + } + for _, tt := range tests { + for _, field := range tt.fields { + t.Run(field, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, oq.FieldValuePublic(row, field, g), "boolean schemas should return the documented default for content fields") + }) + } + } +} + +func TestFieldValuePublic_AbsentRawFields_ReturnsNull(t *testing.T) { + t.Parallel() + + g := &graph.SchemaGraph{Schemas: []graph.SchemaNode{{Schema: oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{})}}} + row := oq.Row{Kind: oq.SchemaResult, SchemaIdx: 0} + fields := []string{"const", "multipleOf", "anchor", "id", "prefixItems", "dependentSchemas", "defs", "examples", "additionalProperties", "patternProperties", "xml", "externalDocs", "not", "if", "then", "else", "contains", "propertyNames", "unevaluatedItems", "unevaluatedProperties", "x-missing"} + for _, field := range fields { + t.Run(field, func(t *testing.T) { + t.Parallel() + assert.Equal(t, expr.NullVal(), oq.FieldValuePublic(row, field, g), "an absent raw field should not be reported as present") + }) + } +} + +func TestFieldValuePublic_AbsentNavigation_ReturnsNull(t *testing.T) { + t.Parallel() + + g := &graph.SchemaGraph{} + tests := []struct { + name string + row oq.Row + }{ + {"negative schema index", oq.Row{Kind: oq.SchemaResult, SchemaIdx: -1}}, + {"out of range schema index", oq.Row{Kind: oq.SchemaResult}}, + {"negative operation index", oq.Row{Kind: oq.OperationResult, OpIdx: -1}}, + {"out of range operation index", oq.Row{Kind: oq.OperationResult}}, + {"parameter", oq.Row{Kind: oq.ParameterResult}}, {"response", oq.Row{Kind: oq.ResponseResult}}, + {"request body", oq.Row{Kind: oq.RequestBodyResult}}, {"content type", oq.Row{Kind: oq.ContentTypeResult}}, + {"header", oq.Row{Kind: oq.HeaderResult}}, {"security scheme", oq.Row{Kind: oq.SecuritySchemeResult}}, + {"server", oq.Row{Kind: oq.ServerResult}}, {"tag", oq.Row{Kind: oq.TagResult}}, {"link", oq.Row{Kind: oq.LinkResult}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, expr.NullVal(), oq.FieldValuePublic(tt.row, "name", g), "missing navigation objects should produce null rather than panic") + }) + } +} + +func TestFieldValuePublic_NavigationMetadata_Success(t *testing.T) { + t.Parallel() + + g := &graph.SchemaGraph{Operations: []graph.OperationNode{{Name: "getPets"}}} + tests := []struct { + name string + row oq.Row + fields map[string]expr.Value + }{ + { + name: "parameter flags", + row: oq.Row{Kind: oq.ParameterResult, Parameter: &openapi.Parameter{ + AllowEmptyValue: pointer.From(true), AllowReserved: pointer.From(true), Explode: pointer.From(true), + Style: pointer.From(openapi.SerializationStyle("form")), + }}, + fields: map[string]expr.Value{"allowEmptyValue": expr.BoolVal(true), "allowReserved": expr.BoolVal(true), "explode": expr.BoolVal(true), "style": expr.StringVal("form"), "operation": expr.StringVal("getPets")}, + }, + { + name: "link metadata", + row: oq.Row{Kind: oq.LinkResult, LinkName: "next", StatusCode: "200", Link: &openapi.Link{ + OperationID: pointer.From("nextPage"), OperationRef: pointer.From("#/paths/~1next/get"), Description: pointer.From("next page"), Server: &openapi.Server{}, + }}, + fields: map[string]expr.Value{"name": expr.StringVal("next"), "operationId": expr.StringVal("nextPage"), "operationRef": expr.StringVal("#/paths/~1next/get"), "description": expr.StringVal("next page"), "hasServer": expr.BoolVal(true), "statusCode": expr.StringVal("200"), "operation": expr.StringVal("getPets")}, + }, + { + name: "empty link defaults", + row: oq.Row{Kind: oq.LinkResult, SourceOpIdx: -1, Link: &openapi.Link{}}, + fields: map[string]expr.Value{"operationId": expr.StringVal(""), "operationRef": expr.StringVal(""), "description": expr.StringVal(""), "parameterCount": expr.IntVal(0), "hasServer": expr.BoolVal(false), "hasRequestBody": expr.BoolVal(false), "operation": expr.StringVal("")}, + }, + { + name: "security scopes without scheme", + row: oq.Row{Kind: oq.SecurityRequirementResult, Scopes: []string{"read", "write"}, SchemeName: "oauth"}, + fields: map[string]expr.Value{"schemeName": expr.StringVal("oauth"), "schemeType": expr.StringVal(""), "scopes": expr.ArrayVal([]string{"read", "write"}), "scopeCount": expr.IntVal(2)}, + }, + { + name: "empty tag metadata", + row: oq.Row{Kind: oq.TagResult, Tag: &openapi.Tag{Name: "pets"}}, + fields: map[string]expr.Value{"name": expr.StringVal("pets"), "description": expr.StringVal(""), "summary": expr.StringVal("")}, + }, + { + name: "empty server variables", + row: oq.Row{Kind: oq.ServerResult, Server: &openapi.Server{URL: "https://example.com"}}, + fields: map[string]expr.Value{"url": expr.StringVal("https://example.com"), "variableCount": expr.IntVal(0)}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + for field, want := range tt.fields { + assert.Equal(t, want, oq.FieldValuePublic(tt.row, field, g), "navigation field %s should return its metadata or default", field) + } + }) + } +} diff --git a/oq/parse_test.go b/oq/parse_test.go new file mode 100644 index 00000000..26bcfb31 --- /dev/null +++ b/oq/parse_test.go @@ -0,0 +1,87 @@ +package oq_test + +import ( + "testing" + + "github.com/speakeasy-api/openapi/oq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse_InvalidContracts_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + query string + message string + }{ + {`include "helpers"`, "include missing terminating ;"}, + {`include ""; schemas`, "include requires a path"}, + {`def : properties; schemas`, "def requires a name"}, + {`def ($x): properties; schemas`, "def requires a name"}, + {`def f($x: properties; schemas`, "def params missing closing )"}, + {`def f(x): properties; schemas`, `def param "x" must start with $`}, + {`schemas | where name`, "where requires parentheses: where(expr)"}, + {`schemas | where (name)`, "where requires parentheses: where(expr)"}, + {`schemas | where(name`, `unknown stage: "where(name"`}, + {`schemas | where(name) extra`, `unknown stage: "where(name)"`}, + {`schemas | select(name)`, "select is for projection, not filtering โ€” use where(expr) to filter"}, + {`schemas | select`, "select requires field names"}, + {`schemas | sort-by()`, "sort-by requires a field name"}, + {`schemas | group-by()`, "group-by requires a field name"}, + {`schemas | group-by(, name)`, "group-by requires a field name"}, + {`schemas | last(nope)`, `last requires a number: strconv.Atoi: parsing "nope": invalid syntax`}, + {`schemas | path(Pet,)`, "path requires two schema names"}, + {`schemas | highest(3)`, "highest requires a number and a field name"}, + {`schemas | highest(nope, depth)`, `highest requires a number: strconv.Atoi: parsing "nope": invalid syntax`}, + {`schemas | highest 3`, "highest requires a number and a field name"}, + {`schemas | highest nope depth`, `highest requires a number: strconv.Atoi: parsing "nope": invalid syntax`}, + {`schemas | lowest(3)`, "lowest requires a number and a field name"}, + {`schemas | lowest(nope, depth)`, `lowest requires a number: strconv.Atoi: parsing "nope": invalid syntax`}, + {`schemas | lowest 3`, "lowest requires a number and a field name"}, + {`schemas | lowest nope depth`, `lowest requires a number: strconv.Atoi: parsing "nope": invalid syntax`}, + {`schemas | format xml`, `format must be table, json, markdown, toon, or gcf, got "xml"`}, + {`schemas | shared-refs(nope)`, `shared-refs requires a minimum count: strconv.Atoi: parsing "nope": invalid syntax`}, + {`schemas | properties(nope)`, `properties requires a depth number or *: strconv.Atoi: parsing "nope": invalid syntax`}, + {`schemas | refs(sideways, 2)`, `refs first argument must be out or in, got "sideways"`}, + {`schemas | refs(out, nope)`, `refs requires a depth number or *: strconv.Atoi: parsing "nope": invalid syntax`}, + {`schemas | let $ = name`, "let variable must start with $"}, + } + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + t.Parallel() + _, err := oq.ParseQuery(tt.query) + require.EqualError(t, err, tt.message, "invalid query should report the specific contract") + }) + } +} + +func TestParse_ExactStages_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + query string + stages []oq.Stage + }{ + {`path(Pet, Owner) | last(2)`, []oq.Stage{{Kind: oq.StagePath, PathFrom: "Pet", PathTo: "Owner"}, {Kind: oq.StageLast, Limit: 2}}}, + {`schemas || refs() | refs(2) | refs(in, 3)`, []oq.Stage{{Kind: oq.StageSource, Source: "schemas"}, {Kind: oq.StageRefs, Limit: 1}, {Kind: oq.StageRefs, Limit: 2}, {Kind: oq.StageRefs, RefsDir: "in", Limit: 3}}}, + {`schemas | path "Pet Store" "Owner"`, []oq.Stage{{Kind: oq.StageSource, Source: "schemas"}, {Kind: oq.StagePath, PathFrom: "Pet Store", PathTo: "Owner"}}}, + {`schemas | properties(2) | select name, , depth`, []oq.Stage{{Kind: oq.StageSource, Source: "schemas"}, {Kind: oq.StageProperties, Limit: 2}, {Kind: oq.StageSelect, Fields: []string{"name", "depth"}}}}, + {`schemas | where(contains(name, 'a\'|b')) | select name`, []oq.Stage{{Kind: oq.StageSource, Source: "schemas"}, {Kind: oq.StageWhere, Expr: `contains(name, 'a\'|b')`}, {Kind: oq.StageSelect, Fields: []string{"name"}}}}, + } + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + t.Parallel() + stages, err := oq.Parse(tt.query) + require.NoError(t, err, "valid query should parse") + assert.Equal(t, tt.stages, stages, "stage arguments and quoted delimiters should survive parsing") + }) + } +} + +func TestParseQuery_DeclarationsOnly_Success(t *testing.T) { + t.Parallel() + q, err := oq.ParseQuery(`include "semi;colon"; def pick($x): where(name == "escaped\";pipe|value");`) + require.NoError(t, err, "quoted delimiters should not terminate declarations") + assert.Equal(t, &oq.Query{Includes: []string{"semi;colon"}, Defs: []oq.FuncDef{{Name: "pick", Params: []string{"$x"}, Body: `where(name == "escaped\";pipe|value")`}}}, q, "declarations-only queries should preserve their definitions") +} diff --git a/swagger/walk_test.go b/swagger/walk_test.go index e13ad481..e7e820e9 100644 --- a/swagger/walk_test.go +++ b/swagger/walk_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "strconv" "testing" "github.com/speakeasy-api/openapi/extensions" @@ -33,6 +34,32 @@ func loadSwaggerDocument(ctx context.Context) (*swagger.Swagger, error) { return s, nil } +func TestWalkSwagger_StopAtEachNode_Success(t *testing.T) { + t.Parallel() + + doc, err := loadSwaggerDocument(t.Context()) + require.NoError(t, err, "walk fixture should load") + var locations []string + for item := range swagger.Walk(t.Context(), doc) { + locations = append(locations, item.Location.ToJSONPointer().String()) + } + require.NotEmpty(t, locations, "fixture should contain traversal nodes") + + for stopAt, location := range locations { + t.Run(strconv.Itoa(stopAt)+":"+location, func(t *testing.T) { + t.Parallel() + freshDoc, err := loadSwaggerDocument(t.Context()) + require.NoError(t, err, "each traversal should have its own document") + var visited []string + swagger.Walk(t.Context(), freshDoc)(func(item swagger.WalkItem) bool { + visited = append(visited, item.Location.ToJSONPointer().String()) + return len(visited) <= stopAt + }) + assert.Equal(t, locations[:stopAt+1], visited, "a false yield must stop every ancestor without visiting later nodes") + }) + } +} + func TestWalkSwagger_Success(t *testing.T) { t.Parallel()