From 9f1e5b702fe4d4ded2e3cbc5d5f89dea05e87ac7 Mon Sep 17 00:00:00 2001 From: Luca Candela Date: Thu, 22 Oct 2026 14:07:42 -0700 Subject: [PATCH 1/3] ci: run coverage consumers once in the root suite --- .agents/skills/loom-framework/SKILL.md | 10 ++ .github/workflows/test.yml | 13 ++- Makefile | 18 ++- scripts/coveragecheck/main.go | 31 +++-- scripts/coveragecheck/suite.go | 153 +++++++++++++++++++++++++ scripts/coveragecheck/suite_test.go | 113 ++++++++++++++++++ scripts/lint_ci_contract.sh | 18 ++- 7 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 scripts/coveragecheck/suite.go create mode 100644 scripts/coveragecheck/suite_test.go diff --git a/.agents/skills/loom-framework/SKILL.md b/.agents/skills/loom-framework/SKILL.md index 4f454f5e0..676cd3aad 100644 --- a/.agents/skills/loom-framework/SKILL.md +++ b/.agents/skills/loom-framework/SKILL.md @@ -2732,6 +2732,7 @@ go fmt ./... make lint make test make coverage-ratchet # protected consumer-aware boundaries +make test-ci # full root suite + scoped ratchet, without duplicate tests make openapi-contract # OpenAPI work make generated-code-quality # generated Go/output work make test-testdata-compile # all exported design fixtures: gen, example, build, vet @@ -2747,6 +2748,15 @@ consumer-aware profile. Treat any reduction as an explicit reviewed contract change and document its reason; aggregate coverage remains an inspection artifact, not the ratchet. +`make ci` and `make ci-local` use `test-ci` instead of running `test` followed +by `coverage-ratchet`. The coverage runner expands the configured consumer set +and the complete root-module inventory, runs each package once, and checks the +floors using only the configured consumers. Remaining packages cannot inflate +the ratchet. It publishes the combined `cover.out` only after both groups and +the coverage floors pass. Standalone `test` and `coverage-ratchet` remain useful +for local iteration; do not chain them in CI. Race/shuffle, generated-design, +contract and integration tiers retain their separate checks. + ## Local and Remote Source Modes HTTP and JSON-RPC temp-module tests share a worktree-local source selector: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c378abdfb..b2a1cc600 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,8 +25,17 @@ jobs: go-version-file: go.mod id: go - - name: Build - run: make ci + - name: Install dependencies + run: make depend + + - name: Static checks + run: make lint + + - name: Unit tests and coverage ratchet + run: make test-ci + + - name: Transport integration tests + run: make integration-test - name: Upload test coverage for deep source uses: actions/upload-artifact@v7 diff --git a/Makefile b/Makefile index b1d6b14fb..9681a8569 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ PROTOC_GEN_GO_GRPC_VERSION?=v1.6.2 PROTOC_BIN=protoc PROTOC_DEST=$(GOBIN_DIR)/$(PROTOC_BIN) -.PHONY: all all-tests ci ci-local clean coverage-baseline coverage-ratchet depend fmt install-hooks lint lint-docs lint-filesize lint-gofmt lint-json-v2 lint-legacy-middleware lint-namescope lint-test-sources lint-toolchain test test-race test-pulse-redis test-testdata-compile integration-test integration-test-fast generated-code-quality openapi-contract value-contract-proof value-contract-conformance build-loom build-loom-cached loom-local loom-remote loom-status release release-promote +.PHONY: all all-tests ci ci-local clean coverage-baseline coverage-ratchet depend fmt install-hooks lint lint-docs lint-filesize lint-gofmt lint-json-v2 lint-legacy-middleware lint-namescope lint-test-sources lint-toolchain test test-ci test-race test-pulse-redis test-testdata-compile integration-test integration-test-fast generated-code-quality openapi-contract value-contract-proof value-contract-conformance build-loom build-loom-cached loom-local loom-remote loom-status release release-promote .NOTPARALLEL: release ci-local # Only list test and build dependencies @@ -46,7 +46,9 @@ all: lint test integration-test all-tests: lint test integration-test -ci: depend all coverage-ratchet +# test-ci combines the full unit suite and the scoped coverage ratchet. Running +# all + coverage-ratchet would execute the expensive generator suites twice. +ci: depend lint test-ci integration-test # Hermetic targets must never reach a real Redis server: the pulse tests flush # the server named by LOOM_PULSE_REDIS_ADDR. Only test-pulse-redis passes it on. @@ -59,7 +61,7 @@ unexport LOOM_PULSE_REDIS_ADDR # rsync, and network access are also required by the external contract gates. # Formal verification is opt-in design work and must not enter this dependency tree. # The source mode is intentionally inherited from the worktree or LOOM_DIR. -ci-local: all coverage-ratchet test-race openapi-contract generated-code-quality test-testdata-compile +ci-local: lint test-ci integration-test test-race openapi-contract generated-code-quality test-testdata-compile # Install protoc PROTOC_VERSION=36.2 @@ -166,6 +168,16 @@ endif coverage-ratchet: go run ./scripts/coveragecheck -test-timeout=$(LOOM_TEST_TIMEOUT) +# Every root test runs exactly once. Protected consumers retain their scoped +# coverage instrumentation; all remaining packages contribute ordinary coverage. +# The aggregate cover.out is published only after tests and ratchets pass. +test-ci: +ifneq ($(GOOS),windows) + PATH="$(GOBIN_DIR):$$PATH" go run ./scripts/coveragecheck -suite -coverprofile=cover.out -test-timeout=$(LOOM_TEST_TIMEOUT) +else + go run ./scripts/coveragecheck -suite -coverprofile=cover.out -test-timeout=$(LOOM_TEST_TIMEOUT) +endif + # Rewrite the checked-in floors from the current consumer-aware profile. Review # every reduction and document why the protected boundary lost coverage. coverage-baseline: diff --git a/scripts/coveragecheck/main.go b/scripts/coveragecheck/main.go index 8df7a9a8f..d32868f25 100644 --- a/scripts/coveragecheck/main.go +++ b/scripts/coveragecheck/main.go @@ -50,9 +50,20 @@ func main() { configPath := flag.String("config", "coverage/baselines.json", "path to the checked-in coverage baseline") update := flag.Bool("update", false, "replace each baseline with the current measured coverage") testTimeout := flag.String("test-timeout", "", "go test -timeout for the coverage run; empty keeps the go test default") + suite := flag.Bool("suite", false, "run every root-module test once, enforce coverage, and write the aggregate profile") + profilePath := flag.String("coverprofile", "cover.out", "aggregate profile written by -suite after all checks pass") flag.Parse() - if err := run(*configPath, *update, *testTimeout); err != nil { + var err error + switch { + case *suite && *update: + err = errors.New("-suite and -update cannot be combined; update the scoped baseline separately") + case *suite: + err = runSuite(*configPath, *testTimeout, *profilePath) + default: + err = run(*configPath, *update, *testTimeout) + } + if err != nil { fmt.Fprintln(os.Stderr, "coverage ratchet:", err) os.Exit(1) } @@ -104,10 +115,7 @@ func collectCoverage(config baselineConfig, testTimeout string) (blocks map[stri } }() - command := exec.Command("go", coverageTestArgs(config, profilePath, testTimeout)...) - command.Stdout = os.Stdout - command.Stderr = os.Stderr - if err := command.Run(); err != nil { + if err := runCoverage(config, profilePath, testTimeout); err != nil { return nil, fmt.Errorf("collect consumer-aware coverage: %w", err) } @@ -127,15 +135,24 @@ func coverageTestArgs(config baselineConfig, profilePath, testTimeout string) [] "test", "-count=1", "-covermode=count", - "-coverpkg="+strings.Join(config.CoverPackages, ","), - "-coverprofile="+profilePath, ) + if len(config.CoverPackages) > 0 { + args = append(args, "-coverpkg="+strings.Join(config.CoverPackages, ",")) + } + args = append(args, "-coverprofile="+profilePath) if testTimeout != "" { args = append(args, "-timeout="+testTimeout) } return append(args, config.TestPackages...) } +func runCoverage(config baselineConfig, profilePath, testTimeout string) error { + command := exec.Command("go", coverageTestArgs(config, profilePath, testTimeout)...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Run() +} + func measureBoundaries(config baselineConfig, blocks map[string]profileBlock) (map[string]measurement, error) { measurements := make(map[string]measurement, len(config.Boundaries)) for _, boundary := range config.Boundaries { diff --git a/scripts/coveragecheck/suite.go b/scripts/coveragecheck/suite.go new file mode 100644 index 000000000..e9b9b4335 --- /dev/null +++ b/scripts/coveragecheck/suite.go @@ -0,0 +1,153 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "time" +) + +// runSuite preserves the ratchet's consumer set while avoiding a second run of +// those tests for aggregate coverage. Remaining tests cannot inflate its floors. +func runSuite(configPath, testTimeout, profilePath string) (returnErr error) { + if testTimeout != "" { + if _, err := time.ParseDuration(testTimeout); err != nil { + return fmt.Errorf("parse -test-timeout: %w", err) + } + } + if err := os.Remove(profilePath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove previous coverage profile: %w", err) + } + config, err := readConfig(configPath) + if err != nil { + return err + } + all, err := listPackages([]string{"./..."}) + if err != nil { + return err + } + selected, err := listPackages(config.TestPackages) + if err != nil { + return err + } + remaining, err := remainingPackages(all, selected) + if err != nil { + return err + } + dir, err := os.MkdirTemp("", "loom-suite-coverage-*") + if err != nil { + return fmt.Errorf("create coverage directory: %w", err) + } + defer func() { + if err := os.RemoveAll(dir); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("remove coverage directory: %w", err)) + } + }() + + data, err := collectSelectedCoverage(config, selected, dir, testTimeout) + if err != nil { + return err + } + blocks, err := parseProfile(bytes.NewReader(data)) + if err != nil { + return err + } + measurements, err := measureBoundaries(config, blocks) + if err != nil { + return err + } + data, err = appendRemainingCoverage(data, remaining, dir, testTimeout) + if err != nil { + return err + } + printMeasurements(config, measurements) + if regressions := evaluateMeasurements(config, measurements); len(regressions) > 0 { + return errors.New(strings.Join(regressions, "; ")) + } + if err := os.WriteFile(profilePath, data, 0o600); err != nil { + return fmt.Errorf("write aggregate coverage: %w", err) + } + return nil +} + +func collectSelectedCoverage(config baselineConfig, selected []string, dir, testTimeout string) ([]byte, error) { + // Also instrument each selected package itself, preserving the ordinary + // profile's coverage of packages such as codegen/generator. This adds no + // consumers to the configured boundaries. + config.TestPackages = selected + config.CoverPackages = slices.Clone(config.CoverPackages) + for _, p := range selected { + if !slices.Contains(config.CoverPackages, p) { + config.CoverPackages = append(config.CoverPackages, p) + } + } + profile := filepath.Join(dir, "selected.out") + if err := runCoverage(config, profile, testTimeout); err != nil { + return nil, fmt.Errorf("collect consumer-aware coverage: %w", err) + } + data, err := os.ReadFile(profile) + if err != nil { + return nil, fmt.Errorf("read consumer-aware coverage: %w", err) + } + return data, nil +} + +func appendRemainingCoverage(data []byte, remaining []string, dir, testTimeout string) ([]byte, error) { + if len(remaining) == 0 { + return data, nil + } + profile := filepath.Join(dir, "remaining.out") + if err := runCoverage(baselineConfig{TestPackages: remaining}, profile, testTimeout); err != nil { + return nil, fmt.Errorf("collect remaining coverage: %w", err) + } + other, err := os.ReadFile(profile) + if err != nil { + return nil, fmt.Errorf("read remaining coverage: %w", err) + } + if _, err := parseProfile(bytes.NewReader(other)); err != nil { + return nil, err + } + body, ok := bytes.CutPrefix(other, []byte("mode: count\n")) + if !ok { + return nil, errors.New("remaining coverage must use mode: count") + } + return append(data, body...), nil +} + +func listPackages(patterns []string) ([]string, error) { + command := exec.Command("go", append([]string{"list"}, patterns...)...) + command.Stderr = os.Stderr + output, err := command.Output() + if err != nil { + return nil, fmt.Errorf("list test packages: %w", err) + } + return strings.Fields(string(output)), nil +} + +func remainingPackages(all, selected []string) ([]string, error) { + if len(selected) == 0 { + return nil, errors.New("no coverage test packages") + } + root := make(map[string]bool, len(all)) + for _, p := range all { + root[p] = true + } + for _, p := range selected { + if _, ok := root[p]; !ok { + return nil, fmt.Errorf("coverage test package %s is outside the root suite", p) + } + root[p] = false + } + var remaining []string + for _, p := range all { + if root[p] { + remaining = append(remaining, p) + } + } + return remaining, nil +} diff --git a/scripts/coveragecheck/suite_test.go b/scripts/coveragecheck/suite_test.go new file mode 100644 index 000000000..6ef118ad6 --- /dev/null +++ b/scripts/coveragecheck/suite_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPartitionPackages(t *testing.T) { + for _, tc := range []struct { + name string + selected []string + remaining []string + wantError string + }{ + {name: "partial", selected: []string{"b"}, remaining: []string{"a", "c"}}, + {name: "complete", selected: []string{"a", "b", "c"}}, + {name: "overlapping selectors", selected: []string{"b", "b"}, remaining: []string{"a", "c"}}, + {name: "outside root", selected: []string{"outside"}, wantError: "outside the root suite"}, + {name: "empty", wantError: "no coverage test packages"}, + } { + t.Run(tc.name, func(t *testing.T) { + remaining, err := remainingPackages([]string{"a", "b", "c"}, tc.selected) + if tc.wantError != "" { + require.ErrorContains(t, err, tc.wantError) + return + } + require.NoError(t, err) + require.Equal(t, tc.remaining, remaining) + }) + } +} + +// Exercise real Go discovery, test execution and coverage with a tiny independent +// module. The exclusive marker fails if either test package runs a second time. +func TestSuitePreservesTestsAndConsumerCoverage(t *testing.T) { + for _, tc := range []struct { + name string + floor int + fail string + wantError string + }{ + {name: "success", floor: 5000}, + {name: "remaining consumer cannot inflate baseline", floor: 10000, wantError: "below checked-in baseline"}, + {name: "selected failure", floor: 5000, fail: "consumer", wantError: "collect consumer-aware coverage"}, + {name: "remaining failure", floor: 5000, fail: "other", wantError: "collect remaining coverage"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv("GOWORK", "off") + t.Setenv("LOOM_SUITE_FAIL", tc.fail) + writeSuiteFile(t, "go.mod", "module example.com/suite\n\ngo 1.27.0\n") + writeSuiteFile(t, "service/service.go", "package service\nfunc A() int { return 1 }\nfunc B() int { return 2 }\n") + writeSuiteFile(t, "consumer/consumer.go", "package consumer\nfunc Value() int { return 3 }\n") + writeSuiteFile(t, "other/other.go", "package other\nfunc Value() int { return 4 }\n") + for _, p := range []struct{ name, call string }{{"consumer", "A"}, {"other", "B"}} { + writeSuiteFile(t, p.name+"/once_test.go", strings.ReplaceAll(strings.ReplaceAll(`package PKG +import ( + "os" + "testing" + "example.com/suite/service" +) +func TestOnce(t *testing.T) { + f, err := os.OpenFile("ran", os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { t.Fatal(err) } + if err := f.Close(); err != nil { t.Fatal(err) } + if os.Getenv("LOOM_SUITE_FAIL") == "PKG" { t.Fatal("requested failure") } + if service.CALL() == 0 || Value() == 0 { t.Fatal("missing value") } +} +`, "PKG", p.name), "CALL", p.call)) + } + config := baselineConfig{ + CoverPackages: []string{"example.com/suite/service"}, + TestPackages: []string{"./service", "./consumer/...", "./consumer"}, + Boundaries: []boundary{{ + Name: "service", Packages: []string{"example.com/suite/service"}, + MinimumBasisPoints: tc.floor, Rationale: "Consumer coverage must remain scoped.", + }}, + } + require.NoError(t, writeConfig("baseline.json", config)) + writeSuiteFile(t, "cover.out", "stale profile") + err := runSuite("baseline.json", "1m", "cover.out") + if tc.wantError != "" { + require.ErrorContains(t, err, tc.wantError) + require.NoFileExists(t, "cover.out", "a failed suite must not publish stale or partial coverage") + return + } + require.NoError(t, err) + require.FileExists(t, "consumer/ran") + require.FileExists(t, "other/ran") + data, err := os.ReadFile("cover.out") + require.NoError(t, err) + require.Equal(t, 1, strings.Count(string(data), "mode: count")) + blocks, err := parseProfile(strings.NewReader(string(data))) + require.NoError(t, err) + for _, p := range []string{"service", "consumer", "other"} { + measured, err := measureBoundary(blocks, boundary{Name: p, Packages: []string{"example.com/suite/" + p}}) + require.NoError(t, err) + require.Positive(t, measured.CoveredStatements) + } + }) + } +} + +func writeSuiteFile(t *testing.T, path, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} diff --git a/scripts/lint_ci_contract.sh b/scripts/lint_ci_contract.sh index 702dabe73..9e1593f2c 100644 --- a/scripts/lint_ci_contract.sh +++ b/scripts/lint_ci_contract.sh @@ -75,12 +75,17 @@ if target_prerequisites missing "$PREREQUISITE_FIXTURE" >/dev/null; then fi assert_prerequisites all "lint test integration-test" -assert_prerequisites ci "depend all coverage-ratchet" -assert_prerequisites ci-local "all coverage-ratchet test-race openapi-contract generated-code-quality test-testdata-compile" +assert_prerequisites ci "depend lint test-ci integration-test" +assert_prerequisites ci-local "lint test-ci integration-test test-race openapi-contract generated-code-quality test-testdata-compile" assert_prerequisites value-contract-conformance "value-contract-proof" assert_prerequisites release "" assert_prerequisites release-promote "" +suite_recipe="$(make --no-print-directory -C "$ROOT" -n test-ci LOOM_TEST_TIMEOUT=41m)" +if ! grep -Fq 'go run ./scripts/coveragecheck -suite -coverprofile=cover.out -test-timeout=41m' <<<"$suite_recipe"; then + fail "test-ci must run the complete suite and scoped ratchet together" +fi + conformance_recipe="$(make --no-print-directory -C "$ROOT" -n value-contract-conformance)" proof_line="$(grep -nFx 'bash ./scripts/check_value_contract_proof.sh' <<<"$conformance_recipe" | cut -d: -f1)" conformance_line="$(grep -nFx 'bash ./scripts/check_value_contract_conformance.sh' <<<"$conformance_recipe" | cut -d: -f1)" @@ -102,10 +107,12 @@ workflow_targets="$({ sed -nE 's/^[[:space:]]*run:[[:space:]]*make[[:space:]]+([^[:space:]#]+).*$/\1/p' "$WORKFLOW" } | LC_ALL=C sort -u)" expected_workflow_targets="$(printf '%s\n' \ - ci \ depend \ generated-code-quality \ + integration-test \ + lint \ openapi-contract \ + test-ci \ test-pulse-redis \ test-testdata-compile \ test-race \ @@ -115,10 +122,9 @@ if [[ "$workflow_targets" != "$expected_workflow_targets" ]]; then fi # Every workflow gate must also run in ci-local, except the ones listed here. -# ci is covered by ci-local's all and coverage-ratchet, and depend installs -# tools. test-pulse-redis needs Docker, which pre-push must not require (see +# depend installs tools. test-pulse-redis needs Docker, which pre-push must not require (see # the ci-local comment in the Makefile). -ci_local_exceptions=" ci depend test-pulse-redis " +ci_local_exceptions=" depend test-pulse-redis " ci_local_prerequisites=" $(target_prerequisites ci-local) " for target in $workflow_targets; do if [[ "$ci_local_exceptions" == *" $target "* ]]; then From e67179765e5a84aa2ad84431471e82650c6837f0 Mon Sep 17 00:00:00 2001 From: Luca Candela Date: Thu, 22 Oct 2026 14:23:05 -0700 Subject: [PATCH 2/3] fix(http): reject response media types without a subtype --- http/response_negotiation.go | 4 +++- http/transport_policy_test.go | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/http/response_negotiation.go b/http/response_negotiation.go index 3d570e320..5da0fa83a 100644 --- a/http/response_negotiation.go +++ b/http/response_negotiation.go @@ -36,7 +36,9 @@ func NewResponseNegotiationPolicy(mediaTypes ...string) (ResponseNegotiationPoli normalized := make([]string, 0, len(mediaTypes)) for _, value := range mediaTypes { mediaType, _, err := mime.ParseMediaType(value) - if err != nil || mediaType == "" || strings.Contains(mediaType, "*") { + // ParseMediaType also accepts bare Content-Disposition tokens. The + // matching policy requires a concrete type/subtype pair. + if err != nil || !strings.Contains(mediaType, "/") || strings.Contains(mediaType, "*") { return ResponseNegotiationPolicy{}, fmt.Errorf("response media type %q must be concrete", value) } if _, ok := seen[mediaType]; ok { diff --git a/http/transport_policy_test.go b/http/transport_policy_test.go index dfe4ce5e6..4c5f11223 100644 --- a/http/transport_policy_test.go +++ b/http/transport_policy_test.go @@ -367,10 +367,12 @@ func TestResponseNegotiationPolicyCombinesAcceptFieldLines(t *testing.T) { } func TestNewResponseNegotiationPolicyRejectsInvalidMediaTypes(t *testing.T) { - tests := [][]string{nil, {""}, {"not a media type"}, {"application/*"}} + tests := [][]string{nil, {""}, {"not a media type"}, {"application/*"}, {"application"}, {"application; charset=utf-8"}, {"application/json", "text"}} for _, mediaTypes := range tests { - _, err := NewResponseNegotiationPolicy(mediaTypes...) - require.Error(t, err) + t.Run(strings.Join(mediaTypes, ","), func(t *testing.T) { + _, err := NewResponseNegotiationPolicy(mediaTypes...) + require.Error(t, err) + }) } } From 9c30b10c79ab5088df4ca3c9754dafb429aec846 Mon Sep 17 00:00:00 2001 From: Luca Candela Date: Thu, 22 Oct 2026 14:27:36 -0700 Subject: [PATCH 3/3] fix(openapi): isolate documentation union schemas from runtime codecs Allow scalar and nested branches in documentation-only untagged bodies while retaining runtime codec admission. Copy documentation occurrences before modifiers and preserve explicit schema naming without runtime body transformation. Fixes #1034 --- .agents/skills/loom-framework/SKILL.md | 9 +- .../references/formal-models.md | 4 + .agents/skills/loom/SKILL.md | 6 + docs/dsl-reference.md | 13 + docs/http-guide.md | 7 + dsl/attribute_oneof.go | 2 + dsl/http_endpoint_body.go | 14 +- expr/attribute_schema.go | 20 ++ expr/attribute_schema_validation_test.go | 64 ++++ expr/attribute_validate.go | 38 +- expr/attribute_wire_names_test.go | 4 +- expr/http_body_types.go | 16 +- expr/http_documentation_union_test.go | 77 +++++ expr/http_endpoint_finalize.go | 2 +- expr/http_endpoint_validate.go | 2 +- expr/http_response.go | 9 +- expr/tla/documentation_schema/Checked.cfg | 4 + .../DocumentationSchema.tla | 21 ++ expr/tla/documentation_schema/Old.cfg | 4 + expr/tla/documentation_schema/README.md | 40 +++ expr/tla/documentation_schema/Shared.cfg | 4 + expr/tla/union_branch_admission/README.md | 2 +- expr/transport_nullable_test.go | 2 +- .../documentation_union_compile_test.go | 35 ++ .../openapi/v3/documentation_union_test.go | 113 ++++++ .../golden/raw-request-bodies_file0.golden | 327 ++++++++++++++++++ .../golden/raw-request-bodies_file1.golden | 190 ++++++++++ http/codegen/testdata/streaming_dsls.go | 34 ++ 28 files changed, 1018 insertions(+), 45 deletions(-) create mode 100644 expr/attribute_schema.go create mode 100644 expr/attribute_schema_validation_test.go create mode 100644 expr/http_documentation_union_test.go create mode 100644 expr/tla/documentation_schema/Checked.cfg create mode 100644 expr/tla/documentation_schema/DocumentationSchema.tla create mode 100644 expr/tla/documentation_schema/Old.cfg create mode 100644 expr/tla/documentation_schema/README.md create mode 100644 expr/tla/documentation_schema/Shared.cfg create mode 100644 http/codegen/documentation_union_compile_test.go create mode 100644 http/codegen/openapi/v3/documentation_union_test.go diff --git a/.agents/skills/loom-framework/SKILL.md b/.agents/skills/loom-framework/SKILL.md index 676cd3aad..e31f2a7d2 100644 --- a/.agents/skills/loom-framework/SKILL.md +++ b/.agents/skills/loom-framework/SKILL.md @@ -2203,8 +2203,13 @@ filter, and serialization rules belong here. do not infer body presence from direct or inherited field counts. Constructor legality and response presence are separate obligations; keep the imported headerless, selected and inherited empty-object wire/spec controls aligned. -- `expr.explicitHTTPResponseBody` prepares both runtime and documentation-only - response shapes. Preserve authored zero-field objects; only the `Empty` sentinel +- `expr.explicitHTTPResponseBody` prepares runtime response shapes. + Documentation bodies own a copied schema graph and finalize it directly; + they must not acquire runtime wrappers or forced canonical component names. + `attributeValidator` separates schema visits from runtime codec admission. + Schema validation preserves contract constraints while omitting generated + untagged codec shape restrictions; never share its visited cache with runtime + validation. Keep the documentation-schema model and raw-body specimen aligned. Preserve authored zero-field objects; only the `Empty` sentinel means no explicit body. Field-count inference belongs to the separate inferred body projection. Response plans, codecs and OpenAPI consume the prepared shape; never compensate for discarded presence in individual generators. Keep the diff --git a/.agents/skills/loom-framework/references/formal-models.md b/.agents/skills/loom-framework/references/formal-models.md index 5326b4ce3..3891dc263 100644 --- a/.agents/skills/loom-framework/references/formal-models.md +++ b/.agents/skills/loom-framework/references/formal-models.md @@ -16,6 +16,10 @@ stated predicates under its hypotheses; it does not establish handwritten Go refinement, correct plan extraction, arbitrary codec behavior or generated-code correctness. Keep proved, tested, bounded-checked and assumed claims distinct. +The [documentation schema admission model](../../../../expr/tla/documentation_schema/README.md) +separates valid documentation schemas from unsupported generated codecs and +checks that validation visits cannot leak between those contexts. + ## Compilation value ownership The [service semantic ownership model](../../../../codegen/service/tla/semantic_ownership/README.md) diff --git a/.agents/skills/loom/SKILL.md b/.agents/skills/loom/SKILL.md index 4bbd96335..6649722e5 100644 --- a/.agents/skills/loom/SKILL.md +++ b/.agents/skills/loom/SKILL.md @@ -1047,6 +1047,12 @@ clients and servers after upgrading to adopt typed Tag binding. stream. Declare `ContentType(...)` on the response. The generated server applies that static media type before it streams the body. Use `OpenAPIBody(...)` only to document the schema. +- Documentation body modifiers are local to their occurrence. Use + `OpenAPIBody(OneOf(String, Document), Untagged)` or the equivalent + `OpenAPIRequestBody` call for untagged scalar/object or nested union schemas. + Shared named runtime types keep their codecs. Apply `Untagged` at the body, + not on a named type that violates runtime branch rules; nested unions retain + their authored representation. Regenerate OpenAPI to adopt this support. - String-backed path, query, header, and cookie fields with `Meta("struct:field:type", ...)` decode through `encoding.TextUnmarshaler`. CLI flags accept raw text through the same parser; omitted optional fields diff --git a/docs/dsl-reference.md b/docs/dsl-reference.md index 710f4673b..f08e7ff52 100644 --- a/docs/dsl-reference.md +++ b/docs/dsl-reference.md @@ -1119,6 +1119,19 @@ form, multipart, optional JSON body, and non-raw request modes. same request schema for multiple media types. Loom does not decode these formats. The service must inspect the content type and decode the raw stream. +Documentation-only bodies accept scalar/object unions and branches containing +nested unions. For example, `OpenAPIBody(OneOf(String, Document), Untagged)` +publishes a `oneOf` without a discriminator envelope. The equivalent request +form is `OpenAPIRequestBody(OneOf(String, Document), "application/json", true, Untagged)`. +A modifier applies only to that body occurrence, even when its argument is a +shared named type. Nested unions retain their own authored tagged or untagged +representation. Required fields, constraints, and branch identities are still +validated. Generated runtime untagged codecs retain their narrower branch rules; +a named type declared with `Untagged()` must still satisfy those rules. Apply +`Untagged` on the documentation body to describe a broader raw wire contract. +Regenerate OpenAPI artifacts to adopt this support; raw stream handling remains +application-owned. + `Link(...)` inside an HTTP `Response(...)` publishes OpenAPI response links. Use `LinkOperation(...)` or `LinkOperationRef(...)` to identify the target operation, `LinkParam(...)` to map link parameters from runtime expressions, diff --git a/docs/http-guide.md b/docs/http-guide.md index d2fe5bacc..5298f673f 100644 --- a/docs/http-guide.md +++ b/docs/http-guide.md @@ -674,6 +674,13 @@ generated handler sets this static header before it streams the body. encoding. Raw body modes are HTTP-only. You cannot combine them with gRPC or streaming payload or result methods. +Documentation bodies can describe untagged scalar/object unions and nested +unions, for example `OpenAPIBody(OneOf(String, Manifest), Untagged)`. The same +schema works with `OpenAPIRequestBody`. These modifiers are local to the body; +sharing a named type with a typed method does not change that method's codec. +The application remains responsible for producing or consuming the documented +raw bytes. Runtime untagged union restrictions still apply to typed methods. + Use `FileResponse` for seekable downloads that need standard range and conditional request behavior. It is HTTP-only and accepts explicit `GET` and `HEAD` routes; Loom does not synthesize a `HEAD` route. diff --git a/dsl/attribute_oneof.go b/dsl/attribute_oneof.go index 92c8396d6..ad256d9e0 100644 --- a/dsl/attribute_oneof.go +++ b/dsl/attribute_oneof.go @@ -60,6 +60,8 @@ func OneOf(arg any, args ...any) expr.DataType { // branch must be a concrete named object or array containing primitives, named // objects, or arrays of these shapes. Encoding and decoding require the same // unique branch in both the JSON schema and the generated Go representation. +// In OpenAPIBody and OpenAPIRequestBody, Untagged describes only the schema: +// scalar and nested union branches are allowed, and runtime types are unchanged. func Untagged() { attribute, ok := eval.Current().(*expr.AttributeExpr) if !ok { diff --git a/dsl/http_endpoint_body.go b/dsl/http_endpoint_body.go index 1ddd17731..0ff28c0b7 100644 --- a/dsl/http_endpoint_body.go +++ b/dsl/http_endpoint_body.go @@ -71,7 +71,10 @@ func Body(args ...any) { // the generated OpenAPI contract only and does not change runtime transport // encode/decode behavior. // -// OpenAPIBody accepts the same arguments as Body. +// OpenAPIBody accepts the same arguments as Body, plus direct data types. +// Its schema modifiers affect only this documentation occurrence. Untagged +// unions may contain scalar, collection, object, or nested union branches; +// the generated runtime codec restrictions do not apply to documentation. func OpenAPIBody(args ...any) { body(args, true) } @@ -86,7 +89,9 @@ func OpenAPIBody(args ...any) { // The body argument may be a payload attribute name, data type, or user type. // The contentType argument is the request media type, and required controls the // OpenAPI requestBody.required value. An optional DSL function may set schema -// details such as Description, Example, Format, or Meta. +// details such as Description, Example, Format, Meta, or Untagged. Modifiers +// affect only this documentation occurrence. Untagged unions admit scalar and +// nested branches without enabling generated codecs for those shapes. // // Example: // @@ -231,13 +236,16 @@ func resolveBodyAttribute(args []any, ref *expr.AttributeExpr, kind string, open attr, ok := resolveNamedBodyAttribute(ref, kind, a) return attr, fn, ok case expr.UserType: + if openAPIOnly { + a = expr.Dup(a).(expr.UserType) + } return &expr.AttributeExpr{Type: a}, fn, true case expr.DataType: if !openAPIOnly { eval.InvalidArgError("attribute name, user type or DSL", a) return nil, nil, false } - return &expr.AttributeExpr{Type: a}, fn, true + return &expr.AttributeExpr{Type: expr.Dup(a)}, fn, true case func(): if ref == nil { if !openAPIOnly { diff --git a/expr/attribute_schema.go b/expr/attribute_schema.go new file mode 100644 index 000000000..bb7e32f7f --- /dev/null +++ b/expr/attribute_schema.go @@ -0,0 +1,20 @@ +package expr + +import "github.com/CaliLuke/loom/eval" + +func (a *AttributeExpr) validateSchema(ctx string, parent eval.Expression) *eval.ValidationErrors { + validator := attributeValidator{schemaOnly: true, seen: make(map[*AttributeExpr]bool)} + return validator.validate(a, ctx, parent) +} + +// finalizeSchema preserves authored documentation without runtime body wrappers. +// An explicit occurrence name still claims a component, including inline schemas. +func (a *AttributeExpr) finalizeSchema() { + if name, ok := a.Meta.Last("openapi:typename"); ok && name != "" { + a.AddMeta("openapi:typename:canonical", "true") + if _, named := a.Type.(UserType); !named && a.Type != Empty { + a.Type = &UserTypeExpr{TypeName: name, AttributeExpr: DupAtt(a)} + } + } + a.Finalize() +} diff --git a/expr/attribute_schema_validation_test.go b/expr/attribute_schema_validation_test.go new file mode 100644 index 000000000..a484d7203 --- /dev/null +++ b/expr/attribute_schema_validation_test.go @@ -0,0 +1,64 @@ +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSchemaValidationDoesNotAdmitUnsupportedCodecs(t *testing.T) { + for _, schemaFirst := range []bool{false, true} { + name := "runtime-first" + if schemaFirst { + name = "schema-first" + } + t.Run(name, func(t *testing.T) { + union := &AttributeExpr{Type: &Union{TypeName: "ScalarChoice", Untagged: true, Values: []*NamedAttributeExpr{ + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + {Name: "number", Attribute: &AttributeExpr{Type: Int}}, + }}} + attribute := &AttributeExpr{Type: &Map{KeyType: &AttributeExpr{Type: String}, ElemType: &AttributeExpr{ + Type: &Array{ElemType: &AttributeExpr{Type: &Object{{Name: "choice", Attribute: union}}}}, + }}} + checkSchema := func() { + require.Empty(t, attribute.validateSchema("documentation", attribute).Errors) + } + checkRuntime := func() { + require.ErrorContains(t, attribute.Validate("runtime", attribute), "must be a concrete named object or array type") + } + if schemaFirst { + checkSchema() + checkRuntime() + } else { + checkRuntime() + checkSchema() + } + }) + } +} + +func TestSchemaValidationPreservesContractChecks(t *testing.T) { + for _, tc := range []struct { + name string + attribute *AttributeExpr + message string + }{ + { + name: "required field", + attribute: &AttributeExpr{Type: &Object{}, Validation: &ValidationExpr{Required: []string{"missing"}}}, + message: `required field "missing" does not exist`, + }, + { + name: "duplicate union identity", + attribute: &AttributeExpr{Type: &Union{TypeName: "Choice", Untagged: true, Values: []*NamedAttributeExpr{ + {Name: "same", Attribute: &AttributeExpr{Type: String}}, + {Name: "same", Attribute: &AttributeExpr{Type: Int}}, + }}}, + message: "repeats logical name", + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.ErrorContains(t, tc.attribute.validateSchema("documentation", tc.attribute), tc.message) + }) + } +} diff --git a/expr/attribute_validate.go b/expr/attribute_validate.go index 07645587c..55e1c3040 100644 --- a/expr/attribute_validate.go +++ b/expr/attribute_validate.go @@ -9,15 +9,27 @@ import ( "github.com/CaliLuke/loom/eval" ) +// attributeValidator keeps schema-only visits separate from runtime admission. +// A documentation occurrence must not mark a shared codec contract as checked. +type attributeValidator struct { + schemaOnly bool + seen map[*AttributeExpr]bool +} + // Validate tests whether the attribute required fields exist. Since attributes // are unaware of their context, additional context information can be provided // to be used in error messages. The parent definition context is automatically // added to error messages. func (a *AttributeExpr) Validate(ctx string, parent eval.Expression) *eval.ValidationErrors { - if validated[a] { + validator := attributeValidator{seen: validated} + return validator.validate(a, ctx, parent) +} + +func (v *attributeValidator) validate(a *AttributeExpr, ctx string, parent eval.Expression) *eval.ValidationErrors { + if v.seen[a] { return nil } - validated[a] = true + v.seen[a] = true verr := new(eval.ValidationErrors) if err := a.resolveTypeRef(); err != nil { verr.Add(parent, "%s", err.Error()) @@ -39,7 +51,7 @@ func (a *AttributeExpr) Validate(ctx string, parent eval.Expression) *eval.Valid verr.Merge(a.validatePresence(ctx, parent)) verr.Merge(a.validateStructTagKeys(ctx, parent)) verr.Merge(validateStructMeta(ctx, a.Meta, parent)) - verr.Merge(a.validateChildTypes(ctx, parent)) + verr.Merge(v.validateChildTypes(a, ctx, parent)) verr.Merge(a.validateViewReference(ctx, parent)) return verr @@ -287,30 +299,32 @@ func (a *AttributeExpr) validatePkgPath(pkgPath string, t DataType) *eval.Valida return nil } -func (a *AttributeExpr) validateChildTypes(ctx string, parent eval.Expression) *eval.ValidationErrors { +func (v *attributeValidator) validateChildTypes(a *AttributeExpr, ctx string, parent eval.Expression) *eval.ValidationErrors { verr := new(eval.ValidationErrors) if o := AsObject(a.Type); o != nil { - verr.Merge(a.validateObjectChildren(ctx, parent, o)) + verr.Merge(v.validateObjectChildren(a, ctx, parent, o)) return verr } if ar := AsArray(a.Type); ar != nil { - verr.Merge(ar.ElemType.Validate(appendValidationContext(ctx, "[]"), a)) + verr.Merge(v.validate(ar.ElemType, appendValidationContext(ctx, "[]"), a)) return verr } if mapping := AsMap(a.Type); mapping != nil { - verr.Merge(mapping.KeyType.Validate(appendValidationContext(ctx, "[key]"), a)) - verr.Merge(mapping.ElemType.Validate(appendValidationContext(ctx, "[value]"), a)) + verr.Merge(v.validate(mapping.KeyType, appendValidationContext(ctx, "[key]"), a)) + verr.Merge(v.validate(mapping.ElemType, appendValidationContext(ctx, "[value]"), a)) return verr } if u := AsUnion(a.Type); u != nil { verr.Merge(validateUnionBranchIdentities(u, ctx, parent)) if u.Untagged { - verr.Merge(validateUntaggedBranches(u, ctx, parent)) + if !v.schemaOnly { + verr.Merge(validateUntaggedBranches(u, ctx, parent)) + } } else { verr.Merge(validateUnionWireKeys(ctx, parent, u)) } for _, ut := range u.Values { - verr.Merge(ut.Attribute.Validate(appendValidationContext(ctx, "<"+ut.Name+">"), parent)) + verr.Merge(v.validate(ut.Attribute, appendValidationContext(ctx, "<"+ut.Name+">"), parent)) } if hasInlineUnionCycle(u) { verr.Add(parent, "%srecursive OneOf branch cycle in %q is not supported; place the recursive reference inside an object field", ctx, u.Name()) @@ -359,7 +373,7 @@ func attributeJSONTag(attribute *AttributeExpr) (string, bool) { // the service types, which name a field declared as "n:m" after its attribute // name "n". HTTPEndpointExpr.validateBodyElementNames checks the element names // that HTTP and JSON-RPC bodies use. -func (a *AttributeExpr) validateObjectChildren(ctx string, parent eval.Expression, obj *Object) *eval.ValidationErrors { +func (v *attributeValidator) validateObjectChildren(a *AttributeExpr, ctx string, parent eval.Expression, obj *Object) *eval.ValidationErrors { verr := new(eval.ValidationErrors) for _, name := range a.AllRequired() { if a.Find(name) == nil { @@ -375,7 +389,7 @@ func (a *AttributeExpr) validateObjectChildren(ctx string, parent eval.Expressio for _, nat := range *obj { verr.Merge(a.validatePkgPath(pkgPath, nat.Attribute.Type)) fieldCtx := appendValidationContext(ctx, nat.Name) - verr.Merge(nat.Attribute.Validate(fieldCtx, parent)) + verr.Merge(v.validate(nat.Attribute, fieldCtx, parent)) name := AttributeName(nat.Name) wireName := JSONFieldName(name, nat.Attribute) if wireName == "-" { diff --git a/expr/attribute_wire_names_test.go b/expr/attribute_wire_names_test.go index 2c3a978d6..6fd675886 100644 --- a/expr/attribute_wire_names_test.go +++ b/expr/attribute_wire_names_test.go @@ -29,7 +29,7 @@ func TestObjectValidationNamesSuffixedKeysByAttribute(t *testing.T) { object = append(object, &NamedAttributeExpr{Name: name, Attribute: &AttributeExpr{Type: String}}) } attribute := &AttributeExpr{Type: &object} - verr := attribute.validateObjectChildren("", attribute, &object) + verr := attribute.Validate("", attribute) if c.want == "" { assert.Empty(t, verr.Errors) return @@ -65,7 +65,7 @@ func TestUntaggedBranchValidationNamesSuffixedKeysByAttribute(t *testing.T) { Untagged: true, Values: []*NamedAttributeExpr{{Name: "Branch", Attribute: &AttributeExpr{Type: branch}}}, }} - verr := union.validateChildTypes("", union) + verr := union.Validate("", union) if c.want == "" { assert.Empty(t, verr.Errors) return diff --git a/expr/http_body_types.go b/expr/http_body_types.go index 1452b096b..7aac09d05 100644 --- a/expr/http_body_types.go +++ b/expr/http_body_types.go @@ -242,19 +242,6 @@ func httpResponseBody(a *HTTPEndpointExpr, resp *HTTPResponseExpr) *AttributeExp return buildHTTPResponseBody(name, a.MethodExpr.Result, resp, a.Service) } -// httpOpenAPIResponseBody returns the documentation-only HTTP response body for -// the given endpoint and response, if any. -func httpOpenAPIResponseBody(a *HTTPEndpointExpr, resp *HTTPResponseExpr) *AttributeExpr { - if resp.OpenAPIBody == nil { - return nil - } - name := concat(a.Name(), "OpenAPI", "Response", "Body") - if len(a.Responses) > 1 { - name = concat(a.Name(), http.StatusText(resp.StatusCode), "OpenAPI", "Response", "Body") - } - return explicitHTTPResponseBody(resp.OpenAPIBody, name, "ResponseBody", a.Service) -} - // httpErrorResponseBody returns an attribute describing the response body of a // given error. If the DSL defines a body explicitly via the Body function then // the corresponding attribute is returned. Otherwise the attribute is computed @@ -344,8 +331,7 @@ func responseBodyObject(attr *AttributeExpr, resp *HTTPResponseExpr) *MappedAttr return body } -// explicitHTTPResponseBody preserves authored presence for runtime and documented -// responses. An object with no fields still has a body; only Empty denotes none. +// explicitHTTPResponseBody preserves authored presence for runtime responses. An object with no fields still has a body; only Empty denotes none. func explicitHTTPResponseBody(body *AttributeExpr, name, suffix string, svc *HTTPServiceExpr) *AttributeExpr { if body.Type == Empty { return body diff --git a/expr/http_documentation_union_test.go b/expr/http_documentation_union_test.go new file mode 100644 index 000000000..2823a64f1 --- /dev/null +++ b/expr/http_documentation_union_test.go @@ -0,0 +1,77 @@ +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + . "github.com/CaliLuke/loom/dsl" + "github.com/CaliLuke/loom/expr" +) + +func TestDocumentationUnionSchemas(t *testing.T) { + for _, shape := range []string{"scalar-object", "inline-scalar-object", "nested-array-object"} { + for _, request := range []bool{false, true} { + name := shape + "/response" + if request { + name = shape + "/request" + } + t.Run(name, func(t *testing.T) { + var source expr.DataType + root := expr.RunDSL(t, func() { + leaf := Type("Leaf", func() { + Attribute("label", String) + Required("label") + }) + choice := Type("Choice", OneOf(String, leaf)) + item := Type("Item", func() { + Attribute("choice", choice) + Required("choice") + }) + source = Type("Documented", OneOf(String, item)) + if shape == "inline-scalar-object" { + source = OneOf(String, item) + } + if shape == "nested-array-object" { + items := Type("Items", ArrayOf(item)) + page := Type("Page", func() { + Attribute("results", items) + Required("results") + }) + source = Type("DocumentedPage", OneOf(items, page)) + } + Service("documentation", func() { + Method("raw", func() { + HTTP(func() { + POST("/raw") + if request { + SkipRequestBodyEncodeDecode() + OpenAPIRequestBody(source, "application/json", true, Untagged) + } else { + SkipResponseBodyEncodeDecode() + Response(StatusOK, func() { + OpenAPIBody(source, Untagged) + }) + } + }) + }) + Method("typed", func() { + Result(source) + HTTP(func() { + GET("/typed") + }) + }) + }) + }) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + body := endpoint.OpenAPIRequestBody + if !request { + body = endpoint.Responses[0].OpenAPIBody + } + require.True(t, expr.AsUnion(body.Type).Untagged) + require.False(t, expr.AsUnion(source).Untagged, "documentation must not change the source codec") + require.False(t, expr.AsUnion(root.Services[0].Methods[1].Result.Type).Untagged) + }) + } + } +} diff --git a/expr/http_endpoint_finalize.go b/expr/http_endpoint_finalize.go index 23bc2fdb5..2efe56b0b 100644 --- a/expr/http_endpoint_finalize.go +++ b/expr/http_endpoint_finalize.go @@ -42,7 +42,7 @@ func (e *HTTPEndpointExpr) finalizeTransportBodies() { } e.Body.Finalize() if e.OpenAPIRequestBody != nil { - e.OpenAPIRequestBody.Finalize() + e.OpenAPIRequestBody.finalizeSchema() } e.StreamingBody = httpStreamingBody(e) diff --git a/expr/http_endpoint_validate.go b/expr/http_endpoint_validate.go index 369d5126c..0da165bbf 100644 --- a/expr/http_endpoint_validate.go +++ b/expr/http_endpoint_validate.go @@ -457,7 +457,7 @@ func (e *HTTPEndpointExpr) validateRequestBodyOptions(verr *eval.ValidationError } func (e *HTTPEndpointExpr) validateOpenAPIRequestBody(verr *eval.ValidationErrors) { - verr.Merge(e.OpenAPIRequestBody.Validate("HTTP OpenAPI request body", e)) + verr.Merge(e.OpenAPIRequestBody.validateSchema("HTTP OpenAPI request body", e)) if !e.SkipRequestBodyEncodeDecode { verr.Add(e, "OpenAPIRequestBody requires SkipRequestBodyEncodeDecode.") } diff --git a/expr/http_response.go b/expr/http_response.go index cb2d62d21..1e9c260a8 100644 --- a/expr/http_response.go +++ b/expr/http_response.go @@ -340,7 +340,7 @@ func (r *HTTPResponseExpr) validateBodyAndLinks(e *HTTPEndpointExpr, resultType } } if r.OpenAPIBody != nil { - verr.Merge(r.OpenAPIBody.Validate("HTTP response OpenAPI body", r)) + verr.Merge(r.OpenAPIBody.validateSchema("HTTP response OpenAPI body", r)) } if len(r.Links) > 0 { seen := make(map[string]struct{}, len(r.Links)) @@ -410,7 +410,7 @@ func (r *HTTPResponseExpr) Finalize(a *HTTPEndpointExpr, svcAtt *AttributeExpr) r.finalizeBody(a, svcAtt) } if r.OpenAPIBody != nil { - r.finalizeOpenAPIBody(a) + r.OpenAPIBody.finalizeSchema() } initAttr(r.Headers, svcAtt) initResponseCookies(r.Cookies, svcAtt) @@ -513,11 +513,6 @@ func ensureValidation(att *AttributeExpr) *ValidationExpr { return att.Validation } -func (r *HTTPResponseExpr) finalizeOpenAPIBody(a *HTTPEndpointExpr) { - r.OpenAPIBody = httpOpenAPIResponseBody(a, r) - r.OpenAPIBody.Finalize() -} - // Dup creates a copy of the response expression. func (r *HTTPResponseExpr) Dup() *HTTPResponseExpr { res := HTTPResponseExpr{ diff --git a/expr/tla/documentation_schema/Checked.cfg b/expr/tla/documentation_schema/Checked.cfg new file mode 100644 index 000000000..7dc2e92dd --- /dev/null +++ b/expr/tla/documentation_schema/Checked.cfg @@ -0,0 +1,4 @@ +CONSTANT Mode = "checked" +SPECIFICATION Spec +INVARIANTS SchemaAccepted RuntimeRejected +CHECK_DEADLOCK FALSE diff --git a/expr/tla/documentation_schema/DocumentationSchema.tla b/expr/tla/documentation_schema/DocumentationSchema.tla new file mode 100644 index 000000000..c3eb11dd8 --- /dev/null +++ b/expr/tla/documentation_schema/DocumentationSchema.tla @@ -0,0 +1,21 @@ +----------------------- MODULE DocumentationSchema ----------------------- +EXTENDS Naturals, FiniteSets +CONSTANT Mode +Contexts == {"schema", "runtime"} +VARIABLES seen, result +vars == <> +Init == /\ seen = {} + /\ result = [c \in Contexts |-> "unchecked"] +Validate(c) == + /\ result[c] = "unchecked" + /\ LET cached == IF Mode = "shared" THEN seen # {} ELSE c \in seen + decision == IF cached THEN "accept" + ELSE IF c = "schema" /\ Mode # "old" THEN "accept" + ELSE "reject" + IN /\ result' = [result EXCEPT ![c] = decision] + /\ seen' = seen \cup {c} +Next == \E c \in Contexts : Validate(c) +Spec == Init /\ [][Next]_vars +SchemaAccepted == result["schema"] \in {"unchecked", "accept"} +RuntimeRejected == result["runtime"] \in {"unchecked", "reject"} +============================================================================= diff --git a/expr/tla/documentation_schema/Old.cfg b/expr/tla/documentation_schema/Old.cfg new file mode 100644 index 000000000..a8e32d929 --- /dev/null +++ b/expr/tla/documentation_schema/Old.cfg @@ -0,0 +1,4 @@ +CONSTANT Mode = "old" +SPECIFICATION Spec +INVARIANT SchemaAccepted +CHECK_DEADLOCK FALSE diff --git a/expr/tla/documentation_schema/README.md b/expr/tla/documentation_schema/README.md new file mode 100644 index 000000000..e989adfa9 --- /dev/null +++ b/expr/tla/documentation_schema/README.md @@ -0,0 +1,40 @@ +# Documentation schema admission (#1034) + +A documentation-only body describes JSON Schema, not a generated codec. +The model considers one valid schema whose untagged shape has no supported +runtime codec, visited once in each context and in either order. + +`Old.cfg` models the original shared admission policy and rejects the valid +schema. `Shared.cfg` relaxes schema admission but shares a visited cache, letting +a schema-first visit accidentally admit the runtime codec. `Checked.cfg` keeps +context-local visits and admits the schema while rejecting the codec. + +Run explicitly with the reusable TLA+ 1.7.4 jar (TLC 2.19), SHA-256 +`936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88`, +and Java 25, from this directory: + +```sh +java -cp /path/to/tla2tools-1.7.4.jar tlc2.TLC \ + -config Checked.cfg -metadir /tmp/loom-documentation-schema DocumentationSchema.tla +``` + +Repeat with separate temporary directories for `Old.cfg` and `Shared.cfg`. +The old policy fails `SchemaAccepted`; shared-cache admission fails +`RuntimeRejected`. The corrected model checks 5 generated / 4 distinct states. +These commands are design tools and are never build, test, hook, or CI gates. + +`attributeValidator` in `expr/attribute_validate.go` owns recursive admission; +`validateSchema` allocates its own visit map. `TestSchemaValidation*` checks both +visit orders through object, array and map nesting and retains required-field +and branch-identity rejection. `TestDocumentationUnionSchemas` checks real +request/response DSL evaluation and isolation from a shared typed result. +`resolveBodyAttribute` copies documentation type graphs before modifiers run; +response finalization preserves those schema graphs without runtime wrappers. +The raw request specimen and its rendered/compiled tests check scalar/object, +array/object, nested tagged unions and shared named types. + +The model assumes schema validity and runtime unsupportedness; it does not +prove the Go traversal, copying, examples, naming, rendering, or generated code. +Those obligations belong to the implementation tests. Overlapping `oneOf` +branches retain JSON Schema's exactly-one meaning; documentation does not add a +runtime decoder or promise that every possible value matches a branch. diff --git a/expr/tla/documentation_schema/Shared.cfg b/expr/tla/documentation_schema/Shared.cfg new file mode 100644 index 000000000..a460adab4 --- /dev/null +++ b/expr/tla/documentation_schema/Shared.cfg @@ -0,0 +1,4 @@ +CONSTANT Mode = "shared" +SPECIFICATION Spec +INVARIANTS SchemaAccepted RuntimeRejected +CHECK_DEADLOCK FALSE diff --git a/expr/tla/union_branch_admission/README.md b/expr/tla/union_branch_admission/README.md index 23be53acb..575d827b4 100644 --- a/expr/tla/union_branch_admission/README.md +++ b/expr/tla/union_branch_admission/README.md @@ -42,7 +42,7 @@ Repeat with each other configuration and a separate temporary metadir. `AttributeName` supplies logical names; `UnionVariantTag` owns occurrence metadata, named-type metadata and fallback-name precedence. `eval.RunDSL` runs DSL bodies -and preparation before expression validation, then finalization. `AttributeExpr.validateChildTypes` calls `validateUnionBranchIdentities` for +and preparation before expression validation, then finalization. `attributeValidator.validateChildTypes` calls `validateUnionBranchIdentities` for tagged and untagged unions. Preparation resolves branch types before this check evaluates inherited tags. `valueOccurrenceBuilder.union` keeps its independent effective-tag integrity diff --git a/expr/transport_nullable_test.go b/expr/transport_nullable_test.go index c5750abdb..137c232ff 100644 --- a/expr/transport_nullable_test.go +++ b/expr/transport_nullable_test.go @@ -269,7 +269,7 @@ func TestObjectValidationRejectsDesignWireNameCollisions(t *testing.T) { } attribute := &AttributeExpr{Type: object} - verr := attribute.validateObjectChildren("", attribute, object) + verr := attribute.Validate("", attribute) require.ErrorContains(t, verr, "conflicts with another design field name") } diff --git a/http/codegen/documentation_union_compile_test.go b/http/codegen/documentation_union_compile_test.go new file mode 100644 index 000000000..9d7f537c2 --- /dev/null +++ b/http/codegen/documentation_union_compile_test.go @@ -0,0 +1,35 @@ +package codegen + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/CaliLuke/loom/http/codegen/testdata" +) + +func TestGeneratedDocumentationUnionsCompile(t *testing.T) { + root := RunHTTPDSL(t, testdata.RawRequestBodyOpenAPIDSL) + dir := t.TempDir() + if output := os.Getenv("LOOM_DOCUMENTATION_OUTPUT"); output != "" { + dir = output + } + renderHTTPModule(t, dir, "example.com/documentation", root) + files, err := OpenAPIFiles(root) + require.NoError(t, err) + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + service, err := os.ReadFile(filepath.Join(dir, "gen", "raw_upload", "service.go")) + require.NoError(t, err) + require.NotContains(t, string(service), "type RawUploadResult", "documentation-only response schemas must not generate codecs") + require.Contains(t, string(service), "type RawUploadChoice", "the separately used typed union still needs a codec") + if os.Getenv("LOOM_DOCUMENTATION_GENERATE_ONLY") != "" { + return + } + runGoCommand(t, dir, "mod", "tidy") + runGoCommand(t, dir, "build", "./...") +} diff --git a/http/codegen/openapi/v3/documentation_union_test.go b/http/codegen/openapi/v3/documentation_union_test.go new file mode 100644 index 000000000..e63616973 --- /dev/null +++ b/http/codegen/openapi/v3/documentation_union_test.go @@ -0,0 +1,113 @@ +package openapiv3_test + +import ( + "encoding/json/v2" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + . "github.com/CaliLuke/loom/dsl" + "github.com/CaliLuke/loom/http/codegen/testdata" +) + +func TestRenderedDocumentationUnionShapes(t *testing.T) { + artifacts := renderOpenAPIArtifacts(t, testdata.RawRequestBodyOpenAPIDSL) + var document map[string]any + require.NoError(t, json.Unmarshal(artifacts.JSON, &document)) + resolve := func(raw any) map[string]any { + schema := requireMap(t, raw, "schema") + if ref, ok := schema["$ref"].(string); ok { + return componentSchema(t, document, strings.TrimPrefix(ref, "#/components/schemas/")) + } + return schema + } + exchange := requireOperation(t, document, "/exchange", "post") + request := requireMap(t, exchange["requestBody"], "request body") + if ref, ok := request["$ref"].(string); ok { + components := requireMap(t, document["components"], "components") + bodies := requireMap(t, components["requestBodies"], "request bodies") + request = requireMap(t, bodies[strings.TrimPrefix(ref, "#/components/requestBodies/")], "request body") + } + content := requireMap(t, request["content"], "request content") + media := requireMap(t, content["application/json"], "request media") + scalarChoice := resolve(media["schema"]) + require.NotContains(t, scalarChoice, "discriminator") + branches := requireSlice(t, scalarChoice["oneOf"], "scalar/object branches") + require.Len(t, branches, 2) + kinds := make([]any, 0, len(branches)) + for _, branch := range branches { + kinds = append(kinds, resolve(branch)["type"]) + } + require.ElementsMatch(t, []any{"string", "object"}, kinds) + + response := resolve(requireResponseMediaType(t, exchange, "application/json")["schema"]) + require.NotContains(t, response, "discriminator") + branches = requireSlice(t, response["oneOf"], "array/object branches") + require.Len(t, branches, 2) + kinds = kinds[:0] + for _, branch := range branches { + schema := resolve(branch) + kinds = append(kinds, schema["type"]) + if schema["type"] == "object" { + require.Equal(t, []any{"results"}, schema["required"]) + schema = resolve(requireMap(t, schema["properties"], "page fields")["results"]) + } + item := resolve(schema["items"]) + require.Equal(t, []any{"choice"}, item["required"]) + choice := resolve(requireMap(t, item["properties"], "item fields")["choice"]) + require.Contains(t, choice, "discriminator", "nested tagged unions retain authored semantics") + require.Len(t, requireSlice(t, choice["oneOf"], "nested branches"), 2) + } + require.ElementsMatch(t, []any{"array", "object"}, kinds) + typed := requireOperation(t, document, "/choice", "post") + typedChoice := resolve(requireResponseMediaType(t, typed, "application/json")["schema"]) + require.Contains(t, typedChoice, "discriminator", "documentation must not alter the shared typed response") +} + +func TestDocumentationBodyExplicitComponentNames(t *testing.T) { + for _, named := range []bool{false, true} { + name := "inline" + if named { + name = "named" + } + t.Run(name, func(t *testing.T) { + artifacts := renderOpenAPIArtifacts(t, func() { + body := Type("Source", func() { + Attribute("label", String) + }) + Service("documentation", func() { + Method("raw", func() { + HTTP(func() { + POST("/raw") + SkipRequestBodyEncodeDecode() + OpenAPIRequestBody(body, "application/json", true, func() { + Meta("openapi:typename", "Wire_Request") + }) + SkipResponseBodyEncodeDecode() + Response(StatusOK, func() { + if named { + OpenAPIBody(body, func() { + Meta("openapi:typename", "Wire_Response") + }) + } else { + OpenAPIBody(func() { + Attribute("label", String) + Meta("openapi:typename", "Wire_Response") + }) + } + }) + }) + }) + }) + }) + var document map[string]any + require.NoError(t, json.Unmarshal(artifacts.JSON, &document)) + for _, name := range []string{"Wire_Request", "Wire_Response"} { + schema := componentSchema(t, document, name) + require.Equal(t, "object", schema["type"]) + require.Contains(t, requireMap(t, schema["properties"], "fields"), "label") + } + }) + } +} diff --git a/http/codegen/openapi/v3/testdata/golden/raw-request-bodies_file0.golden b/http/codegen/openapi/v3/testdata/golden/raw-request-bodies_file0.golden index 3e4e477f6..fe462710c 100644 --- a/http/codegen/openapi/v3/testdata/golden/raw-request-bodies_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/raw-request-bodies_file0.golden @@ -6,6 +6,108 @@ } }, "schemas": { + "RawUploadChoice": { + "discriminator": { + "mapping": { + "RawUploadManifest": "#/components/schemas/RawUploadChoiceRawUploadManifestEnvelope", + "String": "#/components/schemas/RawUploadChoiceStringEnvelope" + }, + "propertyName": "type" + }, + "example": { + "type": "String", + "value": "Sint rerum facilis maiores." + }, + "oneOf": [ + { + "$ref": "#/components/schemas/RawUploadChoiceRawUploadManifestEnvelope" + }, + { + "$ref": "#/components/schemas/RawUploadChoiceStringEnvelope" + } + ], + "type": "object" + }, + "RawUploadChoiceRawUploadManifestEnvelope": { + "description": "Synthetic wrapper for union variant \"RawUploadManifest\".", + "properties": { + "type": { + "enum": [ + "RawUploadManifest" + ], + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/RawUploadManifest", + "example": { + "name": "Expedita quae illo provident.", + "size": 1974262441509041867 + } + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + "RawUploadChoiceStringEnvelope": { + "description": "Synthetic wrapper for union variant \"String\".", + "properties": { + "type": { + "enum": [ + "String" + ], + "type": "string" + }, + "value": { + "example": "Et qui.", + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + "RawUploadItem": { + "properties": { + "choice": { + "$ref": "#/components/schemas/RawUploadChoice" + } + }, + "required": [ + "choice" + ], + "type": "object" + }, + "RawUploadItems": { + "example": [ + { + "choice": { + "type": "String", + "value": "Ducimus aspernatur." + } + }, + { + "choice": { + "type": "String", + "value": "Ducimus aspernatur." + } + }, + { + "choice": { + "type": "String", + "value": "Ducimus aspernatur." + } + } + ], + "items": { + "$ref": "#/components/schemas/RawUploadItem" + }, + "type": "array" + }, "RawUploadManifest": { "example": { "name": "Doloribus velit facilis.", @@ -27,6 +129,39 @@ "size" ], "type": "object" + }, + "RawUploadPage": { + "example": { + "results": [ + { + "choice": { + "type": "RawUploadManifest", + "value": { + "name": "Sit rerum tempore animi alias.", + "size": 1042549988806284555 + } + } + }, + { + "choice": { + "type": "RawUploadManifest", + "value": { + "name": "Sit rerum tempore animi alias.", + "size": 1042549988806284555 + } + } + } + ] + }, + "properties": { + "results": { + "$ref": "#/components/schemas/RawUploadItems" + } + }, + "required": [ + "results" + ], + "type": "object" } }, "securitySchemes": { @@ -44,6 +179,198 @@ "jsonSchemaDialect": "https://spec.openapis.org/oas/3.1/dialect/base", "openapi": "3.2.0", "paths": { + "/choice": { + "post": { + "operationId": "raw_upload.typed_choice", + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "type": "String", + "value": "Voluptatem vero." + }, + "schema": { + "discriminator": { + "mapping": { + "RawUploadManifest": "#/components/schemas/RawUploadChoiceRawUploadManifestEnvelope", + "String": "#/components/schemas/RawUploadChoiceStringEnvelope" + }, + "propertyName": "type" + }, + "example": { + "type": "String", + "value": "Voluptatem vero." + }, + "oneOf": [ + { + "$ref": "#/components/schemas/RawUploadChoiceRawUploadManifestEnvelope" + }, + { + "$ref": "#/components/schemas/RawUploadChoiceStringEnvelope" + } + ], + "type": "object" + } + } + }, + "description": "OK response." + } + }, + "security": [], + "summary": "typed_choice raw_upload", + "tags": [ + "raw_upload" + ] + } + }, + "/exchange": { + "post": { + "operationId": "raw_upload.exchange", + "requestBody": { + "content": { + "application/json": { + "example": "Neque omnis illum id sed.", + "schema": { + "example": "Neque omnis illum id sed.", + "oneOf": [ + { + "$ref": "#/components/schemas/RawUploadManifest", + "example": { + "name": "Consequatur quam.", + "size": 569645239990550720 + } + }, + { + "example": "Nam alias voluptas delectus.", + "type": "string" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": [ + { + "choice": { + "type": "String", + "value": "Non eos aut." + } + }, + { + "choice": { + "type": "String", + "value": "Non eos aut." + } + }, + { + "choice": { + "type": "String", + "value": "Non eos aut." + } + } + ], + "schema": { + "example": [ + { + "choice": { + "type": "String", + "value": "Non eos aut." + } + }, + { + "choice": { + "type": "String", + "value": "Non eos aut." + } + }, + { + "choice": { + "type": "String", + "value": "Non eos aut." + } + } + ], + "oneOf": [ + { + "$ref": "#/components/schemas/RawUploadItems", + "example": [ + { + "choice": { + "type": "RawUploadManifest", + "value": { + "name": "Quam tenetur id omnis accusantium.", + "size": 3100183855936877164 + } + } + }, + { + "choice": { + "type": "RawUploadManifest", + "value": { + "name": "Quam tenetur id omnis accusantium.", + "size": 3100183855936877164 + } + } + }, + { + "choice": { + "type": "RawUploadManifest", + "value": { + "name": "Quam tenetur id omnis accusantium.", + "size": 3100183855936877164 + } + } + }, + { + "choice": { + "type": "RawUploadManifest", + "value": { + "name": "Quam tenetur id omnis accusantium.", + "size": 3100183855936877164 + } + } + } + ] + }, + { + "$ref": "#/components/schemas/RawUploadPage", + "example": { + "results": [ + { + "choice": { + "type": "String", + "value": "Omnis maiores mollitia et et est necessitatibus." + } + }, + { + "choice": { + "type": "String", + "value": "Omnis maiores mollitia et et est necessitatibus." + } + } + ] + } + } + ] + } + } + }, + "description": "OK response." + } + }, + "security": [], + "summary": "exchange raw_upload", + "tags": [ + "raw_upload" + ] + } + }, "/imports": { "post": { "operationId": "raw_upload.upload_text", diff --git a/http/codegen/openapi/v3/testdata/golden/raw-request-bodies_file1.golden b/http/codegen/openapi/v3/testdata/golden/raw-request-bodies_file1.golden index 16c6fedad..591f5aef1 100644 --- a/http/codegen/openapi/v3/testdata/golden/raw-request-bodies_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/raw-request-bodies_file1.golden @@ -8,6 +8,113 @@ servers: url: http://localhost:80 description: Default server for test api paths: + /choice: + post: + tags: + - raw_upload + summary: typed_choice raw_upload + operationId: raw_upload.typed_choice + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: object + example: + type: String + value: Voluptatem vero. + oneOf: + - $ref: '#/components/schemas/RawUploadChoiceRawUploadManifestEnvelope' + - $ref: '#/components/schemas/RawUploadChoiceStringEnvelope' + discriminator: + propertyName: type + mapping: + RawUploadManifest: '#/components/schemas/RawUploadChoiceRawUploadManifestEnvelope' + String: '#/components/schemas/RawUploadChoiceStringEnvelope' + example: + type: String + value: Voluptatem vero. + security: [] + /exchange: + post: + tags: + - raw_upload + summary: exchange raw_upload + operationId: raw_upload.exchange + requestBody: + required: true + content: + application/json: + schema: + example: Neque omnis illum id sed. + oneOf: + - example: + name: Consequatur quam. + size: 569645239990550720 + $ref: '#/components/schemas/RawUploadManifest' + - type: string + example: Nam alias voluptas delectus. + example: Neque omnis illum id sed. + responses: + "200": + description: OK response. + content: + application/json: + schema: + example: + - choice: + type: String + value: Non eos aut. + - choice: + type: String + value: Non eos aut. + - choice: + type: String + value: Non eos aut. + oneOf: + - example: + - choice: + type: RawUploadManifest + value: + name: Quam tenetur id omnis accusantium. + size: 3100183855936877164 + - choice: + type: RawUploadManifest + value: + name: Quam tenetur id omnis accusantium. + size: 3100183855936877164 + - choice: + type: RawUploadManifest + value: + name: Quam tenetur id omnis accusantium. + size: 3100183855936877164 + - choice: + type: RawUploadManifest + value: + name: Quam tenetur id omnis accusantium. + size: 3100183855936877164 + $ref: '#/components/schemas/RawUploadItems' + - example: + results: + - choice: + type: String + value: Omnis maiores mollitia et et est necessitatibus. + - choice: + type: String + value: Omnis maiores mollitia et et est necessitatibus. + $ref: '#/components/schemas/RawUploadPage' + example: + - choice: + type: String + value: Non eos aut. + - choice: + type: String + value: Non eos aut. + - choice: + type: String + value: Non eos aut. + security: [] /imports: post: tags: @@ -99,6 +206,70 @@ paths: - upload_key: [] components: schemas: + RawUploadChoice: + type: object + example: + type: String + value: Sint rerum facilis maiores. + oneOf: + - $ref: '#/components/schemas/RawUploadChoiceRawUploadManifestEnvelope' + - $ref: '#/components/schemas/RawUploadChoiceStringEnvelope' + discriminator: + propertyName: type + mapping: + RawUploadManifest: '#/components/schemas/RawUploadChoiceRawUploadManifestEnvelope' + String: '#/components/schemas/RawUploadChoiceStringEnvelope' + RawUploadChoiceRawUploadManifestEnvelope: + type: object + properties: + type: + type: string + enum: + - RawUploadManifest + value: + example: + name: Expedita quae illo provident. + size: 1974262441509041867 + $ref: '#/components/schemas/RawUploadManifest' + description: Synthetic wrapper for union variant "RawUploadManifest". + required: + - type + - value + RawUploadChoiceStringEnvelope: + type: object + properties: + type: + type: string + enum: + - String + value: + type: string + example: Et qui. + description: Synthetic wrapper for union variant "String". + required: + - type + - value + RawUploadItem: + type: object + properties: + choice: + $ref: '#/components/schemas/RawUploadChoice' + required: + - choice + RawUploadItems: + type: array + items: + $ref: '#/components/schemas/RawUploadItem' + example: + - choice: + type: String + value: Ducimus aspernatur. + - choice: + type: String + value: Ducimus aspernatur. + - choice: + type: String + value: Ducimus aspernatur. RawUploadManifest: type: object properties: @@ -115,6 +286,25 @@ components: required: - name - size + RawUploadPage: + type: object + properties: + results: + $ref: '#/components/schemas/RawUploadItems' + example: + results: + - choice: + type: RawUploadManifest + value: + name: Sit rerum tempore animi alias. + size: 1042549988806284555 + - choice: + type: RawUploadManifest + value: + name: Sit rerum tempore animi alias. + size: 1042549988806284555 + required: + - results responses: NoContentResponse: description: No Content response. diff --git a/http/codegen/testdata/streaming_dsls.go b/http/codegen/testdata/streaming_dsls.go index 0f8630b61..83f077b2e 100644 --- a/http/codegen/testdata/streaming_dsls.go +++ b/http/codegen/testdata/streaming_dsls.go @@ -42,6 +42,18 @@ var RawRequestBodyOpenAPIDSL = func() { Required("name", "size") }) + choice := Type("RawUploadChoice", OneOf(String, manifest)) + item := Type("RawUploadItem", func() { + Attribute("choice", choice) + Required("choice") + }) + items := Type("RawUploadItems", ArrayOf(item)) + page := Type("RawUploadPage", func() { + Attribute("results", items) + Required("results") + }) + result := Type("RawUploadResult", OneOf(items, page)) + Service("raw_upload", func() { Method("upload_binary", func() { Security(uploadKey) @@ -89,6 +101,28 @@ var RawRequestBodyOpenAPIDSL = func() { }) }) }) + Method("exchange", func() { + NoSecurity() + HTTP(func() { + POST("/exchange") + SkipRequestBodyEncodeDecode() + OpenAPIRequestBody(choice, "application/json", true, Untagged) + SkipResponseBodyEncodeDecode() + Response(StatusOK, func() { + ContentType("application/json") + OpenAPIBody(result, Untagged) + }) + }) + }) + Method("typed_choice", func() { + NoSecurity() + Result(choice) + HTTP(func() { + POST("/choice") + SkipRequestBodyEncodeDecode() + }) + }) + }) }